Your Name commited on
Commit
f14ef69
ยท
1 Parent(s): 42005ae

feat: add additive answer-scanning schema and related functionality

Browse files

- Introduced new JavaScript file for shared camera preparation for answer scans.
- Created new tables in the Supabase database for managing math question attempts, answer scans, and attachments.
- Implemented row-level security and policies for data access control.
- Added a new RPC function for handling answer scans, including actions for creating, confirming, and finalizing scans.
- Developed tests for the new functionality, ensuring proper handling of answer scans, including retries and error management.
- Established a local testing environment with PostgreSQL and integrated Playwright for end-to-end testing of the answer scanning process.

.env.example CHANGED
@@ -4,6 +4,8 @@ SUPABASE_PUBLISHABLE_KEY=
4
 
5
  # Required for class roster management.
6
  SUPABASE_SECRET_KEY=
 
 
7
  TEACHER_EMAILS=
8
  DEVELOPER_EMAILS=
9
 
 
4
 
5
  # Required for class roster management.
6
  SUPABASE_SECRET_KEY=
7
+ # Enable only after applying the additive answer-scan migration and creating its private bucket.
8
+ MATHLY_ANSWER_SCANS_ENABLED=0
9
  TEACHER_EMAILS=
10
  DEVELOPER_EMAILS=
11
 
README.md CHANGED
@@ -37,3 +37,32 @@ For an existing deployment, back up Supabase and then run [docs/mathly-supabase.
37
  ```bash
38
  venv/bin/python -m pytest
39
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  ```bash
38
  venv/bin/python -m pytest
39
  ```
40
+
41
+ ## Handwritten answers from a phone
42
+
43
+ Open-ended questions support handwritten calculations and drawings. On the computer, open the question and select **ืกืจื™ืงืช ื”ืชืฉื•ื‘ื” ืฉืœื™ ื‘ื˜ืœืคื•ืŸ** (โ€œScan my answer with my phoneโ€). Open the same site or installed Mathly PWA on your phone and sign in to the **same account**. Pending requests appear with the question and attempt identifier; choose one and confirm. Select **ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื”**, allow the camera if asked, review the photograph, and use **ืฆื™ืœื•ื ืžื—ื“ืฉ** to retake it before selecting **ืฉืœื™ื—ืช ื”ืชืฉื•ื‘ื” ืœืžื—ืฉื‘**. No image is uploaded before submission.
44
+
45
+ The computer shows waiting, uploading, answer received, upload failed, or request expired. After receipt, it displays the saved image on the original attempt, including after refresh. Receipt means the answer was saved; it does **not** award correctness or mark the skill mastered. Existing multiple-choice practice and photographed-problem AI help remain separate.
46
+
47
+ Requests last **15 minutes**. A failed upload can be retried on the phone without taking another photo while that page remains open. An expired request offers **ื™ืฆื™ืจืช ื‘ืงืฉืช ืฆื™ืœื•ื ื—ื“ืฉื”** on either device; this keeps the attempt and invalidates the old request. A lost success response can safely be retried. Closing or refreshing the phone page discards an unsubmitted preview. If camera access is denied or capture is cancelled, retry the camera button and allow camera access in the site's browser settings. Camera behavior varies by platform; deploy over **HTTPS** and test on the phones you support.
48
+
49
+ ### Enable private answer storage
50
+
51
+ 1. Configure `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, and the **server-only** `SUPABASE_SECRET_KEY` for the same project. Guest practice cannot initiate or access answer scans.
52
+ 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.
53
+ 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.
54
+ 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.
55
+
56
+ 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. Images and private storage URLs are never sent to the AI solver. The new flow works across workers and server restarts.
57
+
58
+ For rollback, turn `MATHLY_ANSWER_SCANS_ENABLED` off and restart; preserve the new tables and private bucket. The skills API serves the original multiple-choice geometry sample while scanning is disabled, including for guest practice and older frontends. Enabling scanning again restores open-ended presentation and access to saved answers. Keep stored answer records for recovery. No destructive down migration is needed.
59
+
60
+ Integration tests use bundled disposable PostgreSQL and local private test blobs, without project credentials or Docker:
61
+
62
+ ```bash
63
+ .venv/bin/python -m pip install -r requirements.txt -r requirements-dev.txt
64
+ .venv/bin/python -m playwright install chromium
65
+ .venv/bin/python -m pytest tests/test_answer_scans.py tests/test_answer_browser.py
66
+ ```
67
+
68
+ See [scan verification](docs/answer-scan-verification.md) for coverage and device checks.
app/answer_cleanup.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run hourly: python -m app.answer_cleanup (same server-only configuration)."""
2
+ from pathlib import Path
3
+
4
+ from dotenv import load_dotenv
5
+
6
+ from .answer_scans import AnswerScans, ScanError
7
+ from .auth import load_auth
8
+
9
+
10
+ def main():
11
+ load_dotenv(Path(__file__).resolve().parent.parent / '.env')
12
+ try:
13
+ # Rollback disables student access, not deletion of orphaned private data.
14
+ removed = AnswerScans(load_auth(), enabled=True).cleanup()
15
+ except ScanError as exc:
16
+ raise SystemExit('Answer cleanup failed: ' + exc.code) from None
17
+ print(f'Removed {removed} unreferenced answer images.')
18
+
19
+
20
+ if __name__ == '__main__':
21
+ main()
app/answer_routes.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authenticated student-facing API for photographed answers."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+
6
+ from fastapi import Depends, File, UploadFile
7
+ from fastapi.responses import JSONResponse, Response
8
+ from starlette.concurrency import run_in_threadpool
9
+
10
+ from .answer_scans import AnswerScans, MAX_IMAGE, ScanError, clean_image, public_result, uuid_text
11
+ from .math_skills import algebra9, question_version
12
+
13
+
14
+ def install_answer_routes(app, signed_in):
15
+ if not hasattr(app.state, "answers"):
16
+ app.state.answers = AnswerScans(app.state.auth)
17
+
18
+ @app.exception_handler(ScanError)
19
+ async def scan_error(_request, exc):
20
+ return JSONResponse({"detail": exc.code}, status_code=exc.status,
21
+ headers={"Cache-Control": "no-store"})
22
+
23
+ def student(user=Depends(signed_in)):
24
+ if not user or not user.get("id"):
25
+ raise ScanError(401, "sign_in_required")
26
+ if not app.state.answers.configured:
27
+ raise ScanError(503, "answer_storage_unavailable")
28
+ return user["id"]
29
+
30
+ def call(user, action, **data):
31
+ return app.state.answers.action(user, action, **data)
32
+
33
+ def reply(result):
34
+ return JSONResponse(public_result(result), headers={"Cache-Control": "no-store"})
35
+
36
+ def usable(result):
37
+ if not result.get("scan"):
38
+ raise ScanError(404, "not_found")
39
+ if result["scan"]["state"] == "expired":
40
+ raise ScanError(410, "expired")
41
+ return result
42
+
43
+ @app.post("/api/math/answer-attempts")
44
+ def resume_attempt(body: dict, user=Depends(student)):
45
+ question = next((s for s in algebra9()["skills"] if s["id"] == body.get("question_id")), None)
46
+ if not question or question["sample"].get("type") != "open-ended":
47
+ raise ScanError(404, "not_found")
48
+ sample = question["sample"]
49
+ version = question_version(sample)
50
+ if body.get("question_version") != version:
51
+ raise ScanError(409, "question_changed")
52
+ return reply(call(user, "resume", question_id=question["id"], question_version=version,
53
+ snapshot={"name": question["name"], **sample}, new=body.get("new") is True))
54
+
55
+ @app.get("/api/math/answer-attempts/{attempt_id}")
56
+ def get_attempt(attempt_id: str, user=Depends(student)):
57
+ return reply(call(user, "get", attempt_id=uuid_text(attempt_id)))
58
+
59
+ @app.post("/api/math/answer-attempts/{attempt_id}/scans")
60
+ def create_scan(attempt_id: str, body: dict, user=Depends(student)):
61
+ fields = {"attempt_id": uuid_text(attempt_id), "renew": body.get("renew") is True}
62
+ if "question_id" in body:
63
+ fields["question_id"] = str(body["question_id"])
64
+ return reply(call(user, "create_scan", **fields))
65
+
66
+ @app.get("/api/math/answer-scans")
67
+ def pending(user=Depends(student)):
68
+ return reply(call(user, "pending"))
69
+
70
+ @app.get("/api/math/answer-scans/{scan_id}")
71
+ def get_scan(scan_id: str, user=Depends(student)):
72
+ return reply(call(user, "get", scan_id=uuid_text(scan_id)))
73
+
74
+ @app.post("/api/math/answer-scans/{scan_id}/confirm")
75
+ def confirm(scan_id: str, user=Depends(student)):
76
+ return reply(usable(call(user, "confirm", scan_id=uuid_text(scan_id))))
77
+
78
+ @app.post("/api/math/answer-scans/{scan_id}/uploads")
79
+ def begin(scan_id: str, body: dict, user=Depends(student)):
80
+ result = usable(call(user, "begin", scan_id=uuid_text(scan_id), key=uuid_text(body.get("key"))))
81
+ response = public_result(result)
82
+ # Only this authenticated upload operation returns the bounded lease.
83
+ response["lease_id"] = result["scan"].get("lease_id")
84
+ return JSONResponse(response, headers={"Cache-Control": "no-store"})
85
+
86
+ @app.post("/api/math/answer-scans/{scan_id}/uploads/{lease_id}")
87
+ async def upload(scan_id: str, lease_id: str, photo: UploadFile = File(...), user=Depends(student)):
88
+ sid, lease = uuid_text(scan_id), uuid_text(lease_id)
89
+ data = await photo.read(MAX_IMAGE + 1)
90
+ return await run_in_threadpool(submit, user, sid, lease, data, photo.content_type)
91
+
92
+ def submit(user, sid, lease, data, content_type):
93
+ result = usable(call(user, "get", scan_id=sid))
94
+ if result.get("attachment"):
95
+ return reply(result) # an earlier response may have been lost
96
+ scan = result["scan"]
97
+ if scan["state"] != "uploading" or scan.get("lease_id") != lease:
98
+ raise ScanError(409, "invalid_upload")
99
+ try:
100
+ image = clean_image(data, content_type)
101
+ object_key = f"{user}/{sid}/{lease}/{uuid.uuid4()}.jpg"
102
+ app.state.answers.put(object_key, image)
103
+ result = usable(call(user, "finalize", scan_id=sid, lease_id=lease,
104
+ object_key=object_key, byte_size=len(image)))
105
+ # Another transfer with the same lease may have finalized first.
106
+ if result["attachment"]["object_key"] != object_key:
107
+ try:
108
+ app.state.answers.delete(object_key)
109
+ except ScanError:
110
+ pass # orphan cleanup is safe even after an uncertain response
111
+ return reply(result)
112
+ except ScanError:
113
+ try:
114
+ call(user, "fail", scan_id=sid, lease_id=lease)
115
+ except ScanError:
116
+ pass
117
+ raise
118
+
119
+ @app.get("/api/math/answer-attempts/{attempt_id}/image")
120
+ def image(attempt_id: str, user=Depends(student)):
121
+ result = call(user, "get", attempt_id=uuid_text(attempt_id))
122
+ if not result.get("attachment"):
123
+ raise ScanError(404, "not_found")
124
+ return Response(app.state.answers.read(result["attachment"]["object_key"]), media_type="image/jpeg",
125
+ headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"})
app/answer_scans.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Durable answer scans. RPC transactions own state; the server owns image access.
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
26
+ class ScanError(Exception):
27
+ status: int
28
+ code: str
29
+
30
+
31
+ def clean_image(data: bytes, content_type: str) -> bytes:
32
+ if not data:
33
+ raise ScanError(400, "empty_image")
34
+ if len(data) > MAX_IMAGE:
35
+ raise ScanError(413, "image_too_large")
36
+ if content_type not in ("image/jpeg", "image/png", "image/webp"):
37
+ raise ScanError(415, "unsupported_image")
38
+ try:
39
+ with warnings.catch_warnings():
40
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
41
+ with Image.open(io.BytesIO(data)) as source:
42
+ if source.format not in ("JPEG", "PNG", "WEBP") or source.width * source.height > 20_000_000:
43
+ raise ValueError("image dimensions or format")
44
+ image = ImageOps.exif_transpose(source).convert("RGB")
45
+ image.thumbnail((2400, 2400))
46
+ output = io.BytesIO()
47
+ image.save(output, "JPEG", quality=88) # drops EXIF, GPS, and embedded metadata
48
+ result = output.getvalue()
49
+ if len(result) > MAX_IMAGE:
50
+ raise ScanError(413, "image_too_large")
51
+ return result
52
+ except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError,
53
+ Image.DecompressionBombWarning) as exc:
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):
64
+ return bool(self.enabled and self.auth and self.auth.secret)
65
+
66
+ def action(self, user_id: str | None, action: str, **data):
67
+ if not self.configured:
68
+ raise ScanError(503, "answer_storage_unavailable")
69
+ body = {"p_user": user_id, "p_action": action, "p_data": data}
70
+ raw = self._request("/rest/v1/rpc/math_answer_action", "POST", json.dumps(body).encode(), "application/json")
71
+ return json.loads(raw)
72
+
73
+ def _request(self, path, method="GET", data=None, content_type=None):
74
+ headers = {"Authorization": "Bearer " + self.auth.secret, "apikey": self.auth.secret,
75
+ "User-Agent": "mathly/1.0"}
76
+ if content_type:
77
+ headers["Content-Type"] = content_type
78
+ req = urllib.request.Request(self.auth.url + path, data=data, method=method, headers=headers)
79
+ try:
80
+ with urllib.request.urlopen(req, timeout=30) as response:
81
+ return response.read()
82
+ except urllib.error.HTTPError as exc:
83
+ # Only recognized database errors become public errors. Never expose SQL or keys.
84
+ try:
85
+ error = json.loads(exc.read())
86
+ except (ValueError, OSError):
87
+ error = {}
88
+ code, message = error.get("code"), error.get("message", "")
89
+ if code == "P0002":
90
+ raise ScanError(404, "not_found") from exc
91
+ if code == "P0001" and message in {"superseded", "invalid_upload", "upload_in_progress", "confirmation_required"}:
92
+ raise ScanError(409, message) from exc
93
+ raise ScanError(503, "answer_storage_unavailable") from exc
94
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
95
+ raise ScanError(503, "answer_storage_unavailable") from exc
96
+
97
+ def put(self, object_key, data):
98
+ self._request(f"/storage/v1/object/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}",
99
+ "POST", data, "image/jpeg")
100
+
101
+ def read(self, object_key):
102
+ return self._request(f"/storage/v1/object/authenticated/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}")
103
+
104
+ def delete(self, object_key):
105
+ self._request(f"/storage/v1/object/{BUCKET}", "DELETE",
106
+ json.dumps({"prefixes": [object_key]}).encode(), "application/json")
107
+
108
+ def cleanup(self):
109
+ candidates = self.action(None, "cleanup_candidates")
110
+ for key in candidates:
111
+ self.delete(key)
112
+ return len(candidates)
113
+
114
+
115
+ def public_result(result):
116
+ """Keep storage paths and upload coordination secrets out of public responses."""
117
+ if isinstance(result, list):
118
+ return [public_result(item) for item in result]
119
+ result = {k: dict(v) if isinstance(v, dict) else v for k, v in result.items()}
120
+ if result.get("scan"):
121
+ for key in ("idempotency_key", "lease_id", "lease_until"):
122
+ result["scan"].pop(key, None)
123
+ if result.get("attachment"):
124
+ result["attachment"].pop("object_key", None)
125
+ return result
126
+
127
+
128
+ def uuid_text(value):
129
+ try:
130
+ return str(uuid.UUID(str(value)))
131
+ except (ValueError, TypeError, AttributeError) as exc:
132
+ raise ScanError(404, "not_found") from exc
app/main.py CHANGED
@@ -17,10 +17,11 @@ from fastapi.staticfiles import StaticFiles
17
  from starlette.concurrency import run_in_threadpool
18
 
19
  from .access import has_permission, resolve_access
 
20
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
21
  from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES,
22
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
23
- from .math_skills import algebra9
24
 
25
  logging.basicConfig(level=logging.INFO)
26
  log = logging.getLogger("mathly")
@@ -97,7 +98,9 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
97
  async def me(user: dict | None = Depends(signed_in)):
98
  mine = (user or {}).get("classes", [])
99
  email = (user or {}).get("email", "")
100
- return {"email": email, "roles": (user or {}).get("roles", []),
 
 
101
  "permissions": (user or {}).get("permissions", []),
102
  "teacher": bool((user or {}).get("teacher")),
103
  "classes": [{"id": row["id"], "name": row["name"], "lessons": units_of(row),
@@ -105,7 +108,7 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
105
 
106
  @app.get("/api/math/skills")
107
  async def math_skills():
108
- return algebra9()
109
 
110
  @app.get("/api/classes")
111
  async def classes(_user: dict = Depends(manager)):
@@ -256,6 +259,7 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
256
  session.update(state="done", result=result)
257
  return {"ok": True, "readable": result.get("readable", False)}
258
 
 
259
  return app
260
 
261
 
 
17
  from starlette.concurrency import run_in_threadpool
18
 
19
  from .access import has_permission, resolve_access
20
+ from .answer_routes import install_answer_routes
21
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
22
  from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES,
23
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
24
+ from .math_skills import algebra9, practice_graph
25
 
26
  logging.basicConfig(level=logging.INFO)
27
  log = logging.getLogger("mathly")
 
98
  async def me(user: dict | None = Depends(signed_in)):
99
  mine = (user or {}).get("classes", [])
100
  email = (user or {}).get("email", "")
101
+ return {"email": email, "user_id": (user or {}).get("id"),
102
+ "answer_scans": app.state.answers.configured,
103
+ "roles": (user or {}).get("roles", []),
104
  "permissions": (user or {}).get("permissions", []),
105
  "teacher": bool((user or {}).get("teacher")),
106
  "classes": [{"id": row["id"], "name": row["name"], "lessons": units_of(row),
 
108
 
109
  @app.get("/api/math/skills")
110
  async def math_skills():
111
+ return JSONResponse(practice_graph(app.state.answers.configured), headers={"Cache-Control": "no-store"})
112
 
113
  @app.get("/api/classes")
114
  async def classes(_user: dict = Depends(manager)):
 
259
  session.update(state="done", result=result)
260
  return {"ok": True, "readable": result.get("readable", False)}
261
 
262
+ install_answer_routes(app, signed_in)
263
  return app
264
 
265
 
app/math_algebra9.json CHANGED
@@ -1942,6 +1942,19 @@
1942
  ],
1943
  "description": "ืฉื˜ื— ืžืฉื•ืœืฉ ื”ื•ื ืžื—ืฆื™ืช ืžื›ืคืœืช ื”ื‘ืกื™ืก ื‘ื’ื•ื‘ื” ื”ืžืื•ื ืš ืœื•.",
1944
  "sample": {
 
 
 
 
 
 
 
 
 
 
 
 
 
1945
  "question": "ื‘ืžืฉื•ืœืฉ ื™ืฉืจ ื–ื•ื•ื™ืช, ืื•ืจื›ื™ ื”ื ื™ืฆื‘ื™ื ื”ื $6$ ืกืดืž ื•ึพ$8$ ืกืดืž. ืžื” ืฉื˜ื—ื•?",
1946
  "choices": [
1947
  {
@@ -1964,8 +1977,7 @@
1964
  "side": "",
1965
  "alt": "ืžืฉื•ืœืฉ ื™ืฉืจ ื–ื•ื•ื™ืช ืขื ื ื™ืฆื‘ื™ื ื‘ืื•ืจืš 6 ื•ึพ8 ืกื ื˜ื™ืžื˜ืจื™ื"
1966
  }
1967
- },
1968
- "core": false
1969
  },
1970
  {
1971
  "id": "g_pythagoras",
 
1942
  ],
1943
  "description": "ืฉื˜ื— ืžืฉื•ืœืฉ ื”ื•ื ืžื—ืฆื™ืช ืžื›ืคืœืช ื”ื‘ืกื™ืก ื‘ื’ื•ื‘ื” ื”ืžืื•ื ืš ืœื•.",
1944
  "sample": {
1945
+ "question": "ื‘ืžืฉื•ืœืฉ ื™ืฉืจ ื–ื•ื•ื™ืช, ืื•ืจื›ื™ ื”ื ื™ืฆื‘ื™ื ื”ื $6$ ืกืดืž ื•ึพ$8$ ืกืดืž. ืฉืจื˜ื˜ื• ืืช ื”ืžืฉื•ืœืฉ, ื—ืฉื‘ื• ืืช ืฉื˜ื—ื• ื•ื›ืชื‘ื• ืืช ื“ืจืš ื”ืคืชืจื•ืŸ ืขืœ ื“ืฃ.",
1946
+ "diagram": {
1947
+ "type": "triangle",
1948
+ "base": "8 cm",
1949
+ "height": "6 cm",
1950
+ "side": "",
1951
+ "alt": "ืžืฉื•ืœืฉ ื™ืฉืจ ื–ื•ื•ื™ืช ืขื ื ื™ืฆื‘ื™ื ื‘ืื•ืจืš 6 ื•ึพ8 ืกื ื˜ื™ืžื˜ืจื™ื"
1952
+ },
1953
+ "type": "open-ended",
1954
+ "version": 1
1955
+ },
1956
+ "core": false,
1957
+ "fallback_sample": {
1958
  "question": "ื‘ืžืฉื•ืœืฉ ื™ืฉืจ ื–ื•ื•ื™ืช, ืื•ืจื›ื™ ื”ื ื™ืฆื‘ื™ื ื”ื $6$ ืกืดืž ื•ึพ$8$ ืกืดืž. ืžื” ืฉื˜ื—ื•?",
1959
  "choices": [
1960
  {
 
1977
  "side": "",
1978
  "alt": "ืžืฉื•ืœืฉ ื™ืฉืจ ื–ื•ื•ื™ืช ืขื ื ื™ืฆื‘ื™ื ื‘ืื•ืจืš 6 ื•ึพ8 ืกื ื˜ื™ืžื˜ืจื™ื"
1979
  }
1980
+ }
 
1981
  },
1982
  {
1983
  "id": "g_pythagoras",
app/math_skills.py CHANGED
@@ -1,6 +1,7 @@
1
  """The 9th-grade algebra skill graph for the math path (`math_algebra9.json`).
2
 
3
- Each skill names the skills it needs first, a sample question, and for each wrong choice the mistake it shows.
 
4
  A mistake sends the student back to the skill it comes from (`back`), or keeps them on the same skill (`null`).
5
  The graph follows the Ministry of Education's 9th-grade teaching schedule (2026/27) and the summer review for
6
  students placed in 5 units; `core` marks what that review stresses. The questions are written for the app, and
@@ -11,12 +12,20 @@ every skill, link and mistake still needs a 5-unit teacher's review.
11
  from __future__ import annotations
12
 
13
  import json
 
 
14
  from functools import cache
15
  from pathlib import Path
16
 
17
  GRAPH = Path(__file__).with_name("math_algebra9.json")
18
 
19
 
 
 
 
 
 
 
20
  def problems(graph: dict) -> list[str]:
21
  """What is wrong with a graph: unknown units, skills or mistakes, a cycle, a link implied by another,
22
  a question without exactly one right answer, or a mistake that sends the student to a skill that is not before it."""
@@ -43,7 +52,20 @@ def problems(graph: dict) -> list[str]:
43
  return [f"cycle: {cycle}"]
44
  for s in skills.values():
45
  out += [f"{s['id']}: {p} is implied by {q}" for p in s["prereqs"] for q in s["prereqs"] if p != q and p in below[q]]
46
- choices = s["sample"]["choices"]
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  if sum(bool(c.get("correct")) for c in choices) != 1:
48
  out.append(f"{s['id']}: needs exactly one correct choice")
49
  if len({c["text"] for c in choices}) != len(choices):
@@ -66,3 +88,18 @@ def algebra9() -> dict:
66
  if found := problems(graph):
67
  raise ValueError(f"{GRAPH.name}: " + "; ".join(found))
68
  return graph
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """The 9th-grade algebra skill graph for the math path (`math_algebra9.json`).
2
 
3
+ Each skill names its prerequisites and a sample question. Choice samples identify mistakes;
4
+ open-ended samples name their type and revision and accept a photographed answer.
5
  A mistake sends the student back to the skill it comes from (`back`), or keeps them on the same skill (`null`).
6
  The graph follows the Ministry of Education's 9th-grade teaching schedule (2026/27) and the summer review for
7
  students placed in 5 units; `core` marks what that review stresses. The questions are written for the app, and
 
12
  from __future__ import annotations
13
 
14
  import json
15
+ import hashlib
16
+ import copy
17
  from functools import cache
18
  from pathlib import Path
19
 
20
  GRAPH = Path(__file__).with_name("math_algebra9.json")
21
 
22
 
23
+ def question_version(sample: dict) -> str:
24
+ """Bind attempts to the complete prompt, including its diagram and revision."""
25
+ return hashlib.sha256(json.dumps(sample, ensure_ascii=False, sort_keys=True,
26
+ separators=(",", ":")).encode()).hexdigest()
27
+
28
+
29
  def problems(graph: dict) -> list[str]:
30
  """What is wrong with a graph: unknown units, skills or mistakes, a cycle, a link implied by another,
31
  a question without exactly one right answer, or a mistake that sends the student to a skill that is not before it."""
 
52
  return [f"cycle: {cycle}"]
53
  for s in skills.values():
54
  out += [f"{s['id']}: {p} is implied by {q}" for p in s["prereqs"] for q in s["prereqs"] if p != q and p in below[q]]
55
+ sample = s["sample"]
56
+ kind = sample.get("type", "multiple-choice")
57
+ if kind not in ("open-ended", "multiple-choice"):
58
+ out.append(f"{s['id']}: unknown question type {kind}")
59
+ continue
60
+ if kind == "open-ended":
61
+ if "choices" in sample:
62
+ out.append(f"{s['id']}: open-ended questions cannot have choices")
63
+ if not isinstance(sample.get("question"), str) or not sample["question"].strip():
64
+ out.append(f"{s['id']}: question text is required")
65
+ if type(sample.get("version")) is not int or sample["version"] < 1:
66
+ out.append(f"{s['id']}: positive question version is required")
67
+ continue
68
+ choices = sample.get("choices", [])
69
  if sum(bool(c.get("correct")) for c in choices) != 1:
70
  out.append(f"{s['id']}: needs exactly one correct choice")
71
  if len({c["text"] for c in choices}) != len(choices):
 
88
  if found := problems(graph):
89
  raise ValueError(f"{GRAPH.name}: " + "; ".join(found))
90
  return graph
91
+
92
+
93
+ def practice_graph(answer_scans_enabled: bool) -> dict:
94
+ """Keep practice usable during a scan rollout or rollback.
95
+
96
+ Public responses never expose fallback choices for an open-ended question.
97
+ The original choice sample is served when authenticated storage is disabled.
98
+ """
99
+ graph = copy.deepcopy(algebra9())
100
+ for skill in graph["skills"]:
101
+ fallback = skill.pop("fallback_sample", None)
102
+ if not answer_scans_enabled and fallback is not None:
103
+ skill["sample"] = fallback
104
+ skill["question_version"] = question_version(skill["sample"])
105
+ return graph
docs/answer-scan-verification.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Answer scan verification
2
+
3
+ The automated database tests run the actual additive migration in disposable PostgreSQL. Auth and Storage metadata schemas are minimal local fixtures; private test image bytes are stored on disk. No hosted Supabase project is modified. Storage HTTP requests have separate adapter contract tests; live deployment must still verify the hosted bucket and API configuration.
4
+
5
+ | Contract | Automated verification |
6
+ | --- | --- |
7
+ | Open-ended rendering; existing choices | Graph validation and desktop browser checks |
8
+ | Stable owned attempt; content changes | API and real database tests |
9
+ | Same-account discovery; several requests | Two independent browser contexts and database tests |
10
+ | Confirm before camera; review and retake | Mobile browser capture simulation |
11
+ | Private requests and images | API, RLS, service-only RPC, and restrictive Storage policies, including a pre-existing permissive policy |
12
+ | Waiting/uploading/received/failure/expiry | API state checks and desktop/mobile browser checks |
13
+ | Lost response; concurrent finalization | Idempotency and real PostgreSQL concurrent connection tests |
14
+ | Expiry, stale lease, renewal | Server-clock database and API tests |
15
+ | Restart persistence; deletion cleanup | New application instance against the same database and private files |
16
+ | No grading or mastery from upload | Browser progress assertion |
17
+ | AI solver remains independent | Existing photo API and phone browser regressions |
18
+
19
+ ## Local verification record โ€” 2026-10-31
20
+
21
+ - Full suite: `.venv/bin/python -m pytest -q` โ€” **28 passed**, including three new desktop/mobile browser tests and the existing phone-solver browser regression.
22
+ - The actual migration was applied through the Supabase CLI to a disposable PostgreSQL database with local Auth/Storage metadata fixtures. The pre-existing class row was preserved. Local `supabase db advisors` returned no issues; migration listing recognized the generated file.
23
+ - Verified rollback by disabling scan access: APIs fail closed and the skills endpoint serves the original choice question. Re-enabling restores the open question and access to the same saved image; class and answer records remain intact.
24
+ - Browser tests use separate account contexts with an SDK sign-in stub; app APIs, ownership checks, PostgreSQL transactions, and private file reads run normally. They cover several pending requests, confirmation/cancellation, review/retake, upload failure, lost success response, reconnect, expiry/renewal, question switching, reload, and account change. The question version from the displayed graph is sent with attempt creation.
25
+ - Image orientation and metadata stripping are checked using actual JPEG EXIF data. Concurrent finalization and renewal run on independent PostgreSQL connections. Storage request methods, paths, headers, and error redaction have adapter contract coverage.
26
+ - JavaScript syntax, Python compilation, `git diff --check`, and strict OpenSpec validation pass.
27
+ - No hosted migration, live Supabase OAuth/Storage test, or physical iOS/Android camera/PWA test was performed. Tasks 4.2 and 4.4 retain open checkboxes for the real-device checks below, despite their capture UI and documentation being implemented.
28
+
29
+ ## Real-phone smoke check
30
+
31
+ This is a manual checklist, not a claim that physical devices have been tested. Serve the migrated app over HTTPS with answer scanning enabled, and record device/browser versions and the result.
32
+
33
+ 1. On a computer sign in, open the triangle-area open question, and request a scan.
34
+ 2. On an actual phone/PWA sign in as the same student. Verify automatic pending-request discovery and correct question/attempt context.
35
+ 3. Decline confirmation: no camera or permission prompt should appear. Reopen and confirm; then select the camera button.
36
+ 4. Deny camera permission or cancel capture. Verify guidance and that the request remains available. Enable camera access and retry. Some mobile browsers use an OS capture picker rather than exposing a web camera permission prompt.
37
+ 5. Photograph a page in portrait and landscape; verify orientation and legibility. Retake and check that only the new preview is submitted.
38
+ 6. Interrupt network access during upload; restore access and retry. Check that the computer displays one attached answer on the original attempt and that the phone confirms success.
39
+ 7. Sign out and use a different student account: the old request and image must not appear. Repeat on a second computer tab with a different question to check association.
40
+ 8. Let a request expire or shorten its expiry in an isolated test project; renew and complete the same attempt.
41
+
42
+ Physical camera permissions, OS picker behavior, and installed-PWA resume must be checked on supported iOS/Safari and Android/Chrome releases. Browser automation supplies image files and simulates cancellation; it cannot certify those OS interactions.
openspec/changes/add-open-question-phone-scan/.openspec.yaml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ schema: spec-driven
2
+ created: 2026-10-31
openspec/changes/add-open-question-phone-scan/design.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Design
2
+
3
+ ## Context
4
+
5
+ See proposal.md for motivation. `app/math_skills.py` requires sample choices; `static/math.js` rotates them unconditionally and keeps progress and try counts in localStorage. There is no persistent question-attempt model. `app/main.py` stores existing photo sessions in memory for 15 minutes; phone uploads authenticate through a bearer link, invoke the AI solver, and retain no image. That flow cannot provide account-only discovery or durable answer attachments.
6
+
7
+ The repository contains a responsive web app with a PWA manifest, not a native mobile project. This design treats the signed-in mobile web/PWA as the Mathly phone app. Existing Supabase authentication exposes a stable user ID. The current database script is destructive and must not be reused for this addition.
8
+
9
+ ## Goals / Non-Goals
10
+
11
+ **Goals:** Durable cross-device association; ownership enforced at every boundary; correct behavior under refresh, retry, concurrency, and lost responses; no AI dependency for capture.
12
+
13
+ **Non-Goals:** Native iOS/Android clients, background push notifications, OCR, automatic grading, teacher review, multiple-page answers, and changing existing multiple-choice mastery behavior. One photograph is accepted per scan request.
14
+
15
+ ## Decisions
16
+
17
+ ### Explicit question type and stable attempts
18
+
19
+ Add `sample.type: open-ended`; missing type continues to mean multiple-choice. Validate each variant separately. Open-ended samples have no choices and may retain a diagram. Use skill ID plus sample content version as question identity; persist a prompt snapshot/version on an attempt so later content edits cannot silently change what was answered. Add an original geometry sample exercising this variant.
20
+
21
+ Create `math_question_attempts` with UUID ID, user ID, question ID/version, snapshot, submission status, and timestamps. Resume the owner's active attempt; an explicit new attempt produces a separate ID. Do not treat upload receipt as a correct answer or update mastery. Keeping only local try counts was rejected because another device cannot reliably associate them.
22
+
23
+ ### Additive Supabase persistence and private storage
24
+
25
+ Add `math_answer_scans` (ID, immutable owner/attempt references, state, expiry, generation, upload lease, timestamps, safe error code) and `math_answer_attachments` (ID, owner, attempt, unique scan ID, private object key, validated media type/size, timestamps). Use composite ownership constraints or equivalent transactional checks to prevent owner/attempt mismatches. Enforce one active scan per attempt and one committed answer per attempt in this first version. Other attempts can have independent pending scans.
26
+
27
+ Use a private answer-image bucket with server-generated object paths. Enable RLS on new exposed tables, owner-scoped SELECT policies using `auth.uid()`, and deny direct client writes to scan state and attachments. FastAPI validates user ownership and executes narrowly scoped server-side mutations; privileged credentials stay on the server. No user-editable metadata or email-based ownership checks. Serve image bytes through an authenticated owner-checking endpoint with `Cache-Control: no-store`; avoid public or reusable signed image links. Restrict Storage access to the trusted server path and explicit owner reads only if needed.
28
+
29
+ This follows current [Supabase private bucket guidance](https://supabase.com/docs/guides/storage/buckets/fundamentals) and [Storage access controls](https://supabase.com/docs/guides/storage/security/access-control). In-memory state was rejected because restarts and multiple workers would lose or disagree about attachments. Reusing the existing bearer-link upload was rejected because possession of its token authorizes another phone.
30
+
31
+ ### Authenticated request API and state machine
32
+
33
+ Add APIs for creating/resuming attempts; creating a scan under an owned attempt; listing the signed-in student's pending scans; reading and confirming a selected scan; beginning and submitting an upload; renewing an expired scan; and reading the attempt attachment. All routes require real authenticated identity even when the deployment otherwise permits guest practice. Unknown and foreign IDs return the same not-found response; unauthenticated access returns sign-in-required. Disabled storage returns a configuration error, never a local-memory substitute.
34
+
35
+ Persist states `waiting`, `confirmed`, `uploading`, `received`, `failed`, `expired`, and `superseded`. Map waiting/confirmed to Waiting for phone. Requests expire 15 minutes after creation by server time. Discover pending requests on mobile app entry and foreground resume, with bounded polling while open. Show question text and attempt context in a chooser when several requests exist. Desktop polls the selected attempt every two seconds while visible, backs off on errors, and refreshes immediately on resume. Responses are private and uncached. Polling fits the current stack; Realtime and push add infrastructure without being necessary for foreground discovery.
36
+
37
+ Confirmation is an authenticated transition. Camera access is invoked only from the subsequent student gesture using the phone's capture picker, requesting permission only as the browser requires. Reuse the existing image orientation, compression, and preview techniques without changing the solver endpoints. Retakes stay local until explicit submission. Permission denial or picker cancellation leaves the request resumable. Use HTTPS in deployment for camera support.
38
+
39
+ ### Observable upload, idempotency, and races
40
+
41
+ Before transferring bytes, the phone begins an upload with an idempotency key; this persists `uploading` so the computer sees transfer status. A conditional database update grants a bounded upload lease to one submission. Use existing 5 MB JPEG/PNG/WebP limits, validate actual decoded image content and dimensions server-side, and strip metadata through re-encoding. Never trust a client-supplied object path, owner, question, or attempt during upload.
42
+
43
+ Store the image privately, then atomically insert the attachment and transition the matching scan generation to `received` after rechecking ownership, lease, expiry, and active attempt. A database transaction/function must enforce these conditions across workers; Python check-then-write is insufficient. Where a privileged database function is necessary, keep it unexposed, revoke PUBLIC execution, pin its search path, and restrict execution to the backend role. It is not a public authorization bypass.
44
+
45
+ Return success only after commit. Lost-response retries query the existing result and return the same attachment; another in-flight submission gets a conflict and refreshes status. A failed commit deletes the staged object when possible; periodic cleanup removes unreferenced objects. An abandoned upload lease becomes failed and retryable while its request is valid. Requests past expiry become expired, including stale uploads; finalization cannot accept them.
46
+
47
+ Retry of an unexpired failed request uses the same attempt and a new lease. Renewal supersedes the prior request and creates a new 15-minute generation for that attempt. Reject late commits from older generations; serialize renewal and completion on the attempt so only one wins. Never replace a received answer through scan retry. The preview stays available in the phone page after transient failure; no offline image persistence is introduced.
48
+
49
+ ## Risks / Trade-offs
50
+
51
+ - Storage and database commits are separate โ†’ stage objects, finalize transactionally, and clean orphans; report success only after finalization.
52
+ - Browser camera behavior varies โ†’ preserve capture picker fallback, test denial/cancellation on target devices, and document HTTPS requirements.
53
+ - Polling adds requests โ†’ pause when hidden and back off; status resumes on foreground entry.
54
+ - Guest mode lacks account ownership โ†’ keep guest practice available but require sign-in for answer scans.
55
+ - New persistent handwritten images change data retention โ†’ retain committed images with their attempts, cascade user/attempt deletion through a cleanup process, and purge unreferenced staging images after 24 hours. No images enter the AI solver or diagnostic logs.
56
+
57
+ ## Migration Plan
58
+
59
+ 1. Generate a separate additive migration for the new tables, constraints, indexes, functions, grants, RLS, and private bucket. Do not rerun `docs/mathly-supabase.sql`.
60
+ 2. Verify ownership policies with two test accounts and unauthenticated callers, including direct table/storage access. Verify conditional finalization and renewal with concurrent requests.
61
+ 3. Deploy backend and mobile/desktop UI together; expose capture only when authenticated answer storage is configured. Add one open-ended geometry sample after compatible rendering is deployed.
62
+ 4. Run the API and browser acceptance suite plus existing choice and photo-solver regressions. Document setup, TTLs, cleanup, and retention in README.
63
+ 5. Roll back by disabling `MATHLY_ANSWER_SCANS_ENABLED`; the skills endpoint then serves the retained original choice sample for compatibility with guest practice and older frontends. Preserve new tables and private objects for recovery. No existing tables or solver sessions need migration. Cleanup remains available with server credentials while student scanning is disabled.
openspec/changes/add-open-question-phone-scan/proposal.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Proposal
2
+
3
+ ## Why
4
+
5
+ Students need to submit handwritten reasoning and drawings for open-ended questions. Mathly currently offers multiple-choice practice and phone-based AI problem solving, but cannot securely attach a student's photographed answer to the correct question attempt.
6
+
7
+ ## What Changes
8
+
9
+ - Support open-ended question samples without predefined answer options.
10
+ - Add persistent, student-owned question attempts and answer attachments.
11
+ - Let a student request a phone scan from an open question and discover pending requests in the mobile app after signing in to the same account.
12
+ - Require confirmation before camera access, offer preview and retake, and confirm successful submission on both devices.
13
+ - Show waiting, uploading, received, failure, and expiry states; allow safe retries without duplicate attachments or association with a different attempt.
14
+ - Keep the existing photographed-problem solver available as a separate flow.
15
+
16
+ ## Capabilities
17
+
18
+ ### New Capabilities
19
+
20
+ - `open-question-answers`: Open-ended question rendering, owned attempts, and persistent photographed answer attachments.
21
+ - `phone-answer-scans`: Same-account request discovery, confirmation, capture, submission, status synchronization, expiry, and retries.
22
+
23
+ ### Modified Capabilities
24
+
25
+ None. This repository has no main capability specifications yet.
26
+
27
+ ## Impact
28
+
29
+ - Question validation in `app/math_skills.py`, sample content, and question rendering in `static/math.js`.
30
+ - Authenticated attempt and scan APIs in `app/main.py` with a dedicated persistence module.
31
+ - Existing account integration in `app/auth.py`, mobile/PWA entry in `static/index.html`, and reusable photo preparation from `static/phone.js`.
32
+ - Additive Supabase tables and private image storage; use a separate migration rather than rerunning the destructive `docs/mathly-supabase.sql` script.
33
+ - API, ownership, concurrency, and desktop/mobile browser coverage alongside current phone-solver tests.
openspec/changes/add-open-question-phone-scan/specs/open-question-answers/spec.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec Delta
2
+
3
+ ## Purpose
4
+
5
+ Allow students to answer questions requiring handwritten reasoning or drawings and retain a private photographed answer on the correct question attempt.
6
+
7
+ ## ADDED Requirements
8
+
9
+ ### Requirement: Open-ended question presentation
10
+ The system SHALL support open-ended questions without predefined answer options and SHALL offer โ€œScan my answer with my phoneโ€ on those questions, using localized text where appropriate.
11
+
12
+ #### Scenario: Open a geometry question
13
+ - **WHEN** a student opens an open-ended geometry question
14
+ - **THEN** the question and any diagram appear without answer-choice controls
15
+ - **AND** the student can initiate a phone scan
16
+
17
+ #### Scenario: Existing choice question
18
+ - **WHEN** a student opens an existing multiple-choice question
19
+ - **THEN** its answer choices and feedback continue to work
20
+
21
+ ### Requirement: Stable student question attempts
22
+ The system SHALL give each open-ended attempt a stable identity owned by the authenticated student and bound to the question and its content version. The system SHALL verify ownership before creating a scan or attaching an answer.
23
+
24
+ #### Scenario: Resume the same attempt
25
+ - **WHEN** the student reloads an open-ended question with an active attempt
26
+ - **THEN** the system restores that attempt and its scan or attached answer
27
+
28
+ #### Scenario: Reject a foreign or mismatched attempt
29
+ - **WHEN** a student requests a scan using another student's attempt or a different question identifier
30
+ - **THEN** the system rejects the request without revealing the other student's data
31
+
32
+ ### Requirement: Private durable answer attachment
33
+ The system SHALL retain a submitted photograph as an answer attachment to its original student, question, and attempt. Only that student SHALL be able to retrieve the photograph through the student-facing flow. Upload confirmation SHALL occur only after both image storage and attachment persistence succeed.
34
+
35
+ #### Scenario: Submission succeeds
36
+ - **WHEN** a confirmed scan submission completes successfully
37
+ - **THEN** the photograph appears on its original question attempt on the computer
38
+ - **AND** both devices confirm successful upload
39
+
40
+ #### Scenario: Navigate to another question
41
+ - **WHEN** the student changes questions before an upload completes
42
+ - **THEN** the received answer remains attached exclusively to the original attempt
43
+
44
+ #### Scenario: Restart after submission
45
+ - **WHEN** the server restarts and the student reopens a submitted attempt
46
+ - **THEN** the saved answer remains accessible to its owner
47
+
48
+ #### Scenario: Photograph access by another account
49
+ - **WHEN** another account requests an attachment or its image
50
+ - **THEN** access is denied without revealing the attachment contents
51
+
52
+ ### Requirement: Submission does not imply correctness
53
+ The system SHALL distinguish answer receipt from grading and SHALL NOT mark an open-ended answer correct or a skill mastered solely because a photograph was uploaded.
54
+
55
+ #### Scenario: Answer received without grading
56
+ - **WHEN** a photograph is attached successfully
57
+ - **THEN** the attempt displays an answer-received state without automatically awarding correctness or mastery
openspec/changes/add-open-question-phone-scan/specs/phone-answer-scans/spec.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec Delta
2
+
3
+ ## Purpose
4
+
5
+ Coordinate a private phone capture with a computer question attempt so students can safely submit handwritten answers across devices using the same account.
6
+
7
+ ## ADDED Requirements
8
+
9
+ ### Requirement: Owned scan request and mobile discovery
10
+ The system SHALL create a scan request only for the signed-in student's open-ended attempt and SHALL allow only that student to discover, inspect, confirm, upload to, or retry it. Opening the mobile app signed in to the same account SHALL discover pending requests without requiring a pairing link.
11
+
12
+ #### Scenario: Same account opens the phone app
13
+ - **WHEN** a student initiates a scan on the computer and opens the mobile app with the same account
14
+ - **THEN** the phone offers the pending request with enough question context to identify its attempt
15
+
16
+ #### Scenario: Several pending questions
17
+ - **WHEN** the student has pending requests for multiple question attempts
18
+ - **THEN** the mobile app lets the student select a request by its question and attempt context
19
+ - **AND** confirmation and upload apply only to that selected request
20
+
21
+ #### Scenario: Different or missing account
22
+ - **WHEN** another student or an unauthenticated caller attempts to discover or access the request
23
+ - **THEN** the system exposes no request or answer data
24
+ - **AND** knowing a request identifier does not grant access
25
+
26
+ ### Requirement: Confirm before camera access
27
+ The mobile app SHALL ask the student to confirm the selected scan before invoking camera capture or requesting camera permission. Denial or cancellation SHALL leave the answer unsubmitted and provide a way to try again.
28
+
29
+ #### Scenario: Confirmation accepted
30
+ - **WHEN** the student confirms the selected request
31
+ - **THEN** the app opens camera capture and requests permission if the platform requires it
32
+
33
+ #### Scenario: Confirmation declined
34
+ - **WHEN** the student declines confirmation
35
+ - **THEN** the app does not open the camera or request camera permission
36
+ - **AND** no answer is uploaded
37
+
38
+ #### Scenario: Camera permission denied
39
+ - **WHEN** camera access is denied or capture is cancelled
40
+ - **THEN** the app explains how to retry and leaves the attempt without a submitted photograph
41
+
42
+ ### Requirement: Review and retake before submission
43
+ The mobile app SHALL display a captured photograph for review, SHALL allow retaking it, and SHALL upload only after an explicit submit action.
44
+
45
+ #### Scenario: Retake the photograph
46
+ - **WHEN** the student retakes a photograph on the review screen
47
+ - **THEN** the new capture replaces the local preview
48
+ - **AND** neither capture is submitted until the student selects submit
49
+
50
+ ### Requirement: Shared status and upload feedback
51
+ The computer SHALL display Waiting for phone, Uploading, Answer received, Upload failed, or Request expired as appropriate. Status SHALL be associated with the original attempt and restored after reconnection. Both devices SHALL confirm success only when the answer is durably attached.
52
+
53
+ #### Scenario: Upload begins and finishes
54
+ - **WHEN** the student submits the reviewed photograph
55
+ - **THEN** the computer displays Uploading while the transfer is active
56
+ - **AND** displays Answer received with the attachment after successful completion
57
+
58
+ #### Scenario: Network interruption
59
+ - **WHEN** upload or persistence fails
60
+ - **THEN** the system displays Upload failed once the failure is detected
61
+ - **AND** the phone retains the preview for retry while its page remains open
62
+ - **AND** the computer resumes authoritative status checks after reconnection
63
+
64
+ ### Requirement: Expiry and safe retries
65
+ The system SHALL expire unused scan requests after 15 minutes and reject new submissions to expired or superseded requests. The student SHALL be able to retry a failed upload or replace an expired request for the same attempt. Repeated or concurrent submissions SHALL create at most one answer attachment for a request.
66
+
67
+ #### Scenario: Expired request
68
+ - **WHEN** the student submits to a request after its expiry
69
+ - **THEN** the system rejects the upload and offers a fresh request for the same attempt
70
+ - **AND** the replacement invalidates the expired request
71
+
72
+ #### Scenario: Upload retry after failure
73
+ - **WHEN** the student retries a failed upload on an unexpired request
74
+ - **THEN** the system retries attachment to the original attempt without creating a new attempt
75
+
76
+ #### Scenario: Success response was lost
77
+ - **WHEN** the phone repeats a submission that already completed
78
+ - **THEN** the system reports the existing successful attachment without duplicating it
79
+
80
+ #### Scenario: Concurrent or superseded upload
81
+ - **WHEN** two phones submit to one request or an older request finishes after replacement
82
+ - **THEN** at most one valid attachment is committed
83
+ - **AND** the obsolete submission cannot overwrite the accepted answer
84
+
85
+ ### Requirement: Answer capture is independent of the solver
86
+ The system SHALL support answer scanning without an AI solver key. The existing photographed-problem solving flow SHALL remain usable with its established configuration and behavior.
87
+
88
+ #### Scenario: AI is not configured
89
+ - **WHEN** a signed-in student requests an answer scan without an AI solver key configured
90
+ - **THEN** scan capture and attachment remain available when answer storage is configured
openspec/changes/add-open-question-phone-scan/tasks.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tasks
2
+
3
+ ## 1. Question model and owned attempts
4
+
5
+ - [x] 1.1 Add open-ended sample typing and version identity in `app/math_skills.py`; extend `tests/test_math_skills.py` to accept open-ended samples without choices and reject malformed variants while retaining existing choice validation.
6
+ - [x] 1.2 Generate a separate additive Supabase migration for attempts, scans, attachments, ownership constraints, indexes, RLS, grants, and private storage; verify clean application in a disposable database and confirm existing class data remains intact.
7
+ - [x] 1.3 Add a persistence adapter and authenticated create/resume/read attempt endpoints using stable user IDs; verify same-account resume, version changes, forged question IDs, foreign ownership, and guest rejection with API tests.
8
+ - [x] 1.4 Document storage/auth setup and the additive migration in README; verify setup steps against the disposable environment without rerunning the destructive legacy script.
9
+
10
+ ## 2. Request lifecycle and durable attachment
11
+
12
+ - [x] 2.1 Implement create/discover/read/confirm/renew scan APIs with server-clock expiry and generation checks; verify two-account isolation, missing authentication, several pending attempts, expiry, and supersession through API tests.
13
+ - [x] 2.2 Implement begin-upload leases, idempotency, image validation/re-encoding, private object storage, and transactional finalization; verify valid image attachment, invalid/oversized data, storage failure, and commit failure without false success.
14
+ - [x] 2.3 Enforce serialization of renewal and finalization plus unique attachments across workers; verify concurrent submissions, stale leases, late old-generation commits, and lost-response retries using database integration tests.
15
+ - [x] 2.4 Add authenticated image retrieval and staging/deletion cleanup; verify foreign and unauthenticated reads fail through API and direct table/storage access, accepted attachments survive a restart, and orphan images are purged without deleting referenced images.
16
+ - [x] 2.5 Document states, 15-minute expiry, lease timeout, errors, cleanup operation, and image retention; verify examples against endpoint responses and ensure no AI key is required for scans.
17
+
18
+ ## 3. Desktop open-ended question flow
19
+
20
+ - [x] 3.1 Render open-ended questions and diagrams without rotating or displaying choices in `static/math.js`; add a versioned geometry sample and verify browser rendering plus existing multiple-choice feedback and mastery behavior.
21
+ - [x] 3.2 Add the localized scan action, attempt restoration, polling, status messages, attachment preview, and failure/expiry retry controls; verify waiting/uploading/received/failed/expired states and question switching in browser tests without attaching an answer to the currently displayed wrong question.
22
+ - [x] 3.3 Handle desktop refresh, hidden tabs, reconnect, and sign-out safely; verify polling resumes with server state and no previous-account request or photograph remains visible after account changes.
23
+ - [x] 3.4 Document the desktop flow and answer-received versus correctness distinction in README; verify displayed copy and screenshots or browser assertions match the instructions.
24
+
25
+ ## 4. Mobile discovery and camera flow
26
+
27
+ - [x] 4.1 Add authenticated pending-request discovery on mobile/PWA entry and foreground resume, with question/attempt selection and explicit confirmation; verify same-account discovery, multiple requests, wrong-account isolation, and no camera invocation before confirmation.
28
+ - [ ] 4.2 Reuse photo preparation for camera capture, review, retake, and explicit submit with localized accessible controls; verify cancellation, denied permission, rotated images, replacement previews, and no upload before submit in browser tests and a documented phone smoke check.
29
+ - [x] 4.3 Connect begin/upload/retry/renew actions, retain the local preview after transient failure, and display durable success; verify upload failures, expired requests, duplicate taps, lost responses, and page resume with two independent browser contexts.
30
+ - [ ] 4.4 Document mobile sign-in, confirmation, permission recovery, HTTPS, and retry behavior; verify the documented happy path on the mobile web/PWA and record browser-specific camera limitations.
31
+
32
+ ## 5. Integrated acceptance
33
+
34
+ - [x] 5.1 Run a complete two-device same-account scan to the original geometry attempt and a second-account rejection case; verify every scenario in both capability specs with API, database, browser, or recorded manual evidence.
35
+ - [x] 5.2 Run the existing pytest suite and phone browser regressions alongside new checks; verify existing photographed-problem solving remains independent and functional and that answer upload never awards correctness or mastery.
36
+ - [x] 5.3 Rehearse additive deployment and rollback in a disposable environment; verify private answers remain recoverable, rollback hides unsupported open-ended content, and no production data or legacy tables are deleted.
openspec/config.yaml ADDED
@@ -0,0 +1 @@
 
 
1
+ schema: spec-driven
requirements-dev.txt CHANGED
@@ -2,3 +2,7 @@ fastapi>=0.110
2
  python-multipart>=0.0.9
3
  httpx>=0.27
4
  pytest>=8
 
 
 
 
 
2
  python-multipart>=0.0.9
3
  httpx>=0.27
4
  pytest>=8
5
+ # Optional local PostgreSQL integration checks (bundled server; no Docker or project credentials).
6
+ pgserver==0.1.4; sys_platform == 'linux'
7
+ psycopg[binary]==3.3.6
8
+ playwright==1.64.1
requirements.txt CHANGED
@@ -6,3 +6,4 @@ python-dotenv>=1.0
6
  qrcode>=7.4,<9
7
  anthropic>=1.0 # photographed-problem hints and solutions
8
  tzdata>=2024.1
 
 
6
  qrcode>=7.4,<9
7
  anthropic>=1.0 # photographed-problem hints and solutions
8
  tzdata>=2024.1
9
+ Pillow==12.3.0 # validate and strip metadata; compatible with Python 3.10 and 3.11
static/answer-scans.js ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Same-account handwritten answers. Every async continuation checks its account
2
+ and view before changing the UI. The server is authoritative for all states. */
3
+ (()=>{
4
+ const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
5
+ const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m">$1</span>');
6
+ const states={waiting:"ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ",confirmed:"ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ",uploading:"ืžืขืœื™ื ืืช ื”ืชืฉื•ื‘ื”โ€ฆ",received:"ื”ืชืฉื•ื‘ื” ื”ืชืงื‘ืœื”",failed:"ื”ื”ืขืœืื” ื ื›ืฉืœื”",expired:"ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”",superseded:"ื”ื‘ืงืฉื” ื”ื•ื—ืœืคื”"};
7
+ const errors={expired:"ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”. ืฆืจื• ื‘ืงืฉื” ื—ื“ืฉื” ื•ื ืกื• ืฉื•ื‘.",superseded:"ื”ื‘ืงืฉื” ื”ื•ื—ืœืคื”. ื‘ื—ืจื• ืืช ื”ื‘ืงืฉื” ื”ืขื“ื›ื ื™ืช.",upload_in_progress:"ื”ืขืœืื” ื›ื‘ืจ ืžืชื‘ืฆืขืช. ืžืžืชื™ื ื™ื ืœืขื“ื›ื•ืŸโ€ฆ",confirmation_required:"ื™ืฉ ืœืืฉืจ ืืช ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืชื—ื™ืœื”.",invalid_upload:"ื”ื”ืขืœืื” ื”ื•ืคืกืงื”. ื ืกื• ืฉื•ื‘.",question_changed:"ื”ืฉืืœื” ื”ืฉืชื ืชื”. ืจืขื ื ื• ืืช ื”ืขืžื•ื“ ื•ืคืชื—ื• ืื•ืชื” ืžื—ื“ืฉ.",invalid_image:"ืœื ื ื™ืชืŸ ืœืงืจื•ื ืืช ื”ืชืžื•ื ื”. ืฆืœืžื• ืฉื•ื‘.",image_too_large:"ื”ืชืžื•ื ื” ื’ื“ื•ืœื” ืžื“ื™. ืฆืœืžื• ืฉื•ื‘.",unsupported_image:"ื‘ื—ืจื• ืชืžื•ื ืช JPEG, PNG ืื• WebP.",answer_storage_unavailable:"ืฉืžื™ืจืช ืชืฉื•ื‘ื•ืช ืื™ื ื” ื–ืžื™ื ื” ื›ืจื’ืข. ื ืกื• ืฉื•ื‘ ืžืื•ื—ืจ ื™ื•ืชืจ.",sign_in_required:"ื™ืฉ ืœื”ื™ื›ื ืก ืœืื•ืชื• ื—ืฉื‘ื•ืŸ ื‘ืฉื ื™ ื”ืžื›ืฉื™ืจื™ื."};
8
+ let api=null,account=null,epoch=0,question=null,inbox=null,inboxTimer=null,inboxPoll=null,capture=null,visibleRoot=null;
9
+ const live=(e)=>e===epoch&&!!account;
10
+ async function json(url,options={}){
11
+ const r=await api(url,{cache:"no-store",...options});
12
+ const d=await r.json().catch(()=>({}));
13
+ if(!r.ok){if(r.status===401)reset();const error=Error(errors[d.detail]||"ื”ื—ื™ื‘ื•ืจ ื ืงื˜ืข. ื ืกื• ืฉื•ื‘.");error.status=r.status;error.code=d.detail;throw error;}
14
+ return d;
15
+ }
16
+ const post=(url,body={})=>json(url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
17
+ function closeQuestion(){
18
+ if(question){clearTimeout(question.timer);if(question.image)URL.revokeObjectURL(question.image);question=null;}
19
+ }
20
+ function closeCapture(){
21
+ if(capture){if(capture.url)URL.revokeObjectURL(capture.url);capture.node.remove();capture.returnFocus?.focus?.();capture=null;}
22
+ }
23
+ function reset(){
24
+ epoch++;if(question)question.node.replaceChildren();closeQuestion();closeCapture();clearTimeout(inboxTimer);inboxTimer=null;
25
+ inbox?.remove();inbox=null;account=null;
26
+ }
27
+ function configure(options){
28
+ reset();api=options.api;account=options.enabled?options.account:null;visibleRoot=options.root;
29
+ if(!account)return;
30
+ // Foreground discovery is available in the responsive phone app/PWA.
31
+ if(matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints){
32
+ inbox=document.createElement("section");inbox.className="mx-scan-inbox mx-wrap";
33
+ inbox.setAttribute("aria-label","ื‘ืงืฉื•ืช ืœืฆื™ืœื•ื ืชืฉื•ื‘ื”");visibleRoot.prepend(inbox);pollInbox(epoch);
34
+ }
35
+ }
36
+ async function pollInbox(e){
37
+ clearTimeout(inboxTimer);if(!live(e)||!inbox||inboxPoll===e)return;
38
+ if(document.hidden){inboxTimer=setTimeout(()=>pollInbox(e),5000);return;}
39
+ inboxPoll=e;
40
+ let delay=5000;
41
+ try{
42
+ const list=await json("/api/math/answer-scans");if(!live(e)||!inbox)return;
43
+ const signature=JSON.stringify(list.map(r=>[r.scan.id,r.scan.state]));
44
+ if(inbox.dataset.signature!==signature){
45
+ inbox.dataset.signature=signature;
46
+ inbox.innerHTML=list.length?`<h2>ื‘ืงืฉื•ืช ืœืฆื™ืœื•ื ืชืฉื•ื‘ื”</h2>${list.map((r,i)=>`<button class="mx-btn" data-scan-index="${i}"><b>${fmt(r.attempt.snapshot.question)}</b><small>ื ื™ืกื™ื•ืŸ ${esc(r.attempt.id.slice(0,8))} ยท ${esc(states[r.scan.state])}</small></button>`).join("")}`:"";
47
+ inbox.querySelectorAll("[data-scan-index]").forEach(button=>button.onclick=()=>openCapture(list[Number(button.dataset.scanIndex)]));
48
+ }
49
+ }catch(error){
50
+ if(!live(e))return;
51
+ if(error.status===401){reset();return;}
52
+ if(inbox){inbox.textContent="ืœื ื ื™ืชืŸ ืœื‘ื“ื•ืง ื‘ืงืฉื•ืช ืฆื™ืœื•ื ื›ืจื’ืข. ืžื ืกื™ื ืฉื•ื‘โ€ฆ";delete inbox.dataset.signature;}delay=15000;
53
+ }finally{if(inboxPoll===e)inboxPoll=null;}
54
+ if(live(e))inboxTimer=setTimeout(()=>pollInbox(e),delay);
55
+ }
56
+ async function mountQuestion(node,id,version){
57
+ closeQuestion();const e=epoch;
58
+ if(!account){node.innerHTML='<p>ื›ื“ื™ ืœืกืจื•ืง ืชืฉื•ื‘ื” ื‘ื˜ืœืคื•ืŸ, ื”ื™ื›ื ืกื• ืœืื•ืชื• ื—ืฉื‘ื•ืŸ ื‘ืฉื ื™ ื”ืžื›ืฉื™ืจื™ื. ืฉืžื™ืจืช ืชืฉื•ื‘ื•ืช ืฆืจื™ื›ื” ืœื”ื™ื•ืช ื–ืžื™ื ื” ื‘ืืชืจ.</p>';return;}
59
+ const q=question={node,id,e,timer:null,image:null,busy:false,result:null};
60
+ node.innerHTML='<p role="status">ื˜ื•ืขื ื™ื ืืช ื ื™ืกื™ื•ืŸ ื”ืชืฉื•ื‘ื”โ€ฆ</p>';
61
+ try{const r=await post("/api/math/answer-attempts",{question_id:id,question_version:version});if(!current(q))return;q.result=r;await renderQuestion(q);schedule(q);}
62
+ catch(error){if(current(q)){node.innerHTML=`<p role="status">${esc(error.message)}</p><button class="mx-btn" data-reload>ื ื™ืกื™ื•ืŸ ื ื•ืกืฃ</button>`;node.querySelector("button").onclick=()=>error.code==="question_changed"?location.reload():mountQuestion(node,id,version);}}
63
+ }
64
+ const current=q=>question===q&&live(q.e)&&q.node.isConnected;
65
+ function schedule(q,delay=2000){clearTimeout(q.timer);if(current(q))q.timer=setTimeout(()=>pollQuestion(q),delay);}
66
+ async function pollQuestion(q){
67
+ if(!current(q)||q.polling)return;
68
+ if(document.hidden){schedule(q,5000);return;}
69
+ q.polling=true;
70
+ try{
71
+ const r=await json(`/api/math/answer-attempts/${q.result.attempt.id}`);if(!current(q))return;
72
+ q.result=r;await renderQuestion(q);schedule(q);
73
+ }catch(error){if(current(q)){q.node.querySelector("[role=status]").textContent=error.message;schedule(q,8000);}}
74
+ finally{q.polling=false;}
75
+ }
76
+ async function renderQuestion(q){
77
+ if(!current(q))return;
78
+ const r=q.result,s=r.scan,state=s?.state,received=!!r.attachment;
79
+ // Rebuilding controls on every poll would steal focus and race a button click.
80
+ const signature=[state,r.attachment?.id].join(":");
81
+ if(q.signature!==signature){
82
+ q.signature=signature;
83
+ q.node.innerHTML=`<small>ื ื™ืกื™ื•ืŸ ${esc(r.attempt.id.slice(0,8))}</small><p role="status" aria-live="polite">${esc(states[state]||"ื›ืชื‘ื• ืืช ื”ืคืชืจื•ืŸ ืขืœ ื“ืฃ ื•ืฆืœืžื• ืื•ืชื• ื‘ื˜ืœืคื•ืŸ.")}</p>
84
+ ${received?'<p>ื”ืชืžื•ื ื” ื ืฉืžืจื” ื›ืชืฉื•ื‘ื”. ื”ื™ื ืขื“ื™ื™ืŸ ืœื ื ื‘ื“ืงื”.</p><img class="mx-answer-image" alt="ื”ืชืฉื•ื‘ื” ื”ืžืฆื•ืœืžืช ืฉืœื›ื" hidden>':`<button class="mx-btn solid" data-request>${s?["expired","superseded"].includes(state)?"ื™ืฆื™ืจืช ื‘ืงืฉืช ืฆื™ืœื•ื ื—ื“ืฉื”":state==="failed"?"ื ื™ืกื™ื•ืŸ ื ื•ืกืฃ":"ืคืชื—ื• ืืช Mathly ื‘ื˜ืœืคื•ืŸ ื‘ืื•ืชื• ื—ืฉื‘ื•ืŸ":"ืกืจื™ืงืช ื”ืชืฉื•ื‘ื” ืฉืœื™ ื‘ื˜ืœืคื•ืŸ"}</button>`}`;
85
+ if(!received){
86
+ const b=q.node.querySelector("[data-request]");b.disabled=["uploading","waiting","confirmed"].includes(state);
87
+ b.onclick=async()=>{
88
+ b.disabled=true;
89
+ try{const result=await post(`/api/math/answer-attempts/${r.attempt.id}/scans`,{question_id:q.id,renew:["expired","superseded","failed"].includes(state)});
90
+ if(!current(q))return;q.result=result;q.signature=null;await renderQuestion(q);schedule(q);}
91
+ catch(error){if(current(q)){q.node.querySelector("[role=status]").textContent=error.message;b.disabled=false;}}
92
+ };
93
+ }
94
+ }else q.node.querySelector("[role=status]").textContent=states[state]||"ื›ืชื‘ื• ืืช ื”ืคืชืจื•ืŸ ืขืœ ื“ืฃ ื•ืฆืœืžื• ืื•ืชื• ื‘ื˜ืœืคื•ืŸ.";
95
+ if(received&&!q.image&&!q.loadingImage){
96
+ q.loadingImage=true;
97
+ try{
98
+ const response=await api(`/api/math/answer-attempts/${r.attempt.id}/image`,{cache:"no-store"});
99
+ if(!response.ok)throw Error("ืœื ื ื™ืชืŸ ืœื˜ืขื•ืŸ ืืช ื”ืชืžื•ื ื”. ืžื ืกื™ื ืฉื•ื‘โ€ฆ");
100
+ const blob=await response.blob();if(!current(q))return;
101
+ q.image=URL.createObjectURL(blob);const img=q.node.querySelector("img");
102
+ img.onload=()=>{if(current(q))img.hidden=false;};img.src=q.image;
103
+ }catch(error){if(current(q))q.node.querySelector("[role=status]").textContent=error.message;}
104
+ finally{q.loadingImage=false;}
105
+ }else if(q.image&&q.node.querySelector("img"))q.node.querySelector("img").src=q.image;
106
+ }
107
+ function openCapture(result){
108
+ closeCapture();const e=epoch;
109
+ if(!live(e))return;
110
+ const node=document.createElement("div");node.className="mx-scan-overlay";node.setAttribute("role","dialog");node.setAttribute("aria-modal","true");node.setAttribute("aria-labelledby","scanTitle");
111
+ node.innerHTML=`<div class="mx-scan-card"><button class="mx-btn" data-close>ืกื’ื™ืจื”</button><h2 id="scanTitle">ืฆื™ืœื•ื ืชืฉื•ื‘ื”</h2>
112
+ <p>${fmt(result.attempt.snapshot.question)}</p><small>ื ื™ืกื™ื•ืŸ ${esc(result.attempt.id.slice(0,8))}</small>
113
+ <p data-status role="status" aria-live="polite">ื”ืื ืœืฆืœื ืชืฉื•ื‘ื” ืœืฉืืœื” ื”ื–ื•?</p>
114
+ <button class="mx-btn solid" data-confirm>ื›ืŸ, ืื ื™ ืจื•ืฆื” ืœืฆืœื ืชืฉื•ื‘ื”</button>
115
+ <button class="mx-btn" data-renew hidden>ื™ืฆื™ืจืช ื‘ืงืฉืช ืฆื™ืœื•ื ื—ื“ืฉื”</button>
116
+ <input data-camera type="file" accept="image/*" capture="environment" hidden>
117
+ <button class="mx-btn" data-take hidden>ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื”</button>
118
+ <img class="mx-answer-image" data-preview alt="ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืฉืœ ื”ืชืฉื•ื‘ื”" hidden>
119
+ <button class="mx-btn solid" data-submit hidden>ืฉืœื™ื—ืช ื”ืชืฉื•ื‘ื” ืœืžื—ืฉื‘</button></div>`;
120
+ const c=capture={node,e,result,photo:null,url:null,key:null,busy:false,returnFocus:document.activeElement};visibleRoot.append(node);
121
+ const status=node.querySelector("[data-status]"),camera=node.querySelector("[data-camera]"),take=node.querySelector("[data-take]"),send=node.querySelector("[data-submit]"),preview=node.querySelector("[data-preview]"),confirm=node.querySelector("[data-confirm]"),renew=node.querySelector("[data-renew]");
122
+ const valid=()=>capture===c&&live(e),say=message=>{if(valid())status.textContent=message;};
123
+ const controls=busy=>{c.busy=busy;take.disabled=send.disabled=confirm.disabled=renew.disabled=busy;};
124
+ const expired=()=>{confirm.hidden=true;take.hidden=true;send.hidden=true;renew.hidden=false;say(errors.expired);};
125
+ node.querySelector("[data-close]").onclick=closeCapture;
126
+ node.onkeydown=event=>{
127
+ if(event.key==="Escape"&&!c.busy){event.preventDefault();event.stopPropagation();closeCapture();}
128
+ if(event.key==="Tab"){
129
+ const buttons=[...node.querySelectorAll("button")].filter(b=>!b.hidden&&!b.disabled);
130
+ if(event.shiftKey&&document.activeElement===buttons[0]){event.preventDefault();buttons.at(-1)?.focus();}
131
+ else if(!event.shiftKey&&document.activeElement===buttons.at(-1)){event.preventDefault();buttons[0]?.focus();}
132
+ }
133
+ };
134
+ confirm.onclick=async()=>{
135
+ controls(true);
136
+ try{const r=await post(`/api/math/answer-scans/${c.result.scan.id}/confirm`);if(!valid())return;c.result=r;
137
+ if(r.attachment){success();return;}
138
+ confirm.hidden=true;take.hidden=false;say("ื‘ื—ืจื• ืฆื™ืœื•ื. ืื ืชืชื‘ืงืฉื•, ืืคืฉืจื• ื’ื™ืฉื” ืœืžืฆืœืžื”.");take.focus();}
139
+ catch(error){if(valid()){say(error.message);if(error.status===410)expired();}}
140
+ finally{if(valid())controls(false);}
141
+ };
142
+ take.onclick=()=>{say("ืฆืœืžื• ืืช ื”ืคืชืจื•ืŸ. ืื ื”ืžืฆืœืžื” ื—ืกื•ืžื”, ืืคืฉืจื• ืื•ืชื” ื‘ื”ื’ื“ืจื•ืช ื”ืืชืจ ื•ื ืกื• ืฉื•ื‘.");camera.click();};
143
+ camera.addEventListener("cancel",()=>say("ื”ืฆื™ืœื•ื ื‘ื•ื˜ืœ ืื• ืฉืœื ื ื™ืชื ื” ื”ืจืฉืื”. ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘ ื‘ื›ืคืชื•ืจ ื”ืฆื™ืœื•ื."));
144
+ camera.onchange=async()=>{
145
+ const file=camera.files[0];camera.value="";if(!file||!valid())return;
146
+ controls(true);say("ืžื›ื™ื ื™ื ืืช ื”ืชืžื•ื ื”โ€ฆ");
147
+ try{
148
+ const photo=await window.MathlyPhoto.prepare(file);if(!valid())return;
149
+ if(photo.size>5000000)throw Error(errors.image_too_large);
150
+ if(c.url)URL.revokeObjectURL(c.url);
151
+ c.photo=photo;c.key=null;preview.src=c.url=URL.createObjectURL(photo);preview.hidden=false;
152
+ take.textContent="ืฆื™ืœื•ื ืžื—ื“ืฉ";send.hidden=false;say("ื‘ื“ืงื• ืฉื›ืœ ื”ืคืชืจื•ืŸ ื‘ืจื•ืจ, ื•ืื– ืฉืœื—ื• ืืช ื”ืชืฉื•ื‘ื”.");
153
+ }catch(error){say("ืœื ื ื™ืชืŸ ืœื”ื›ื™ืŸ ืืช ื”ืชืžื•ื ื”. ื ืกื• ืœืฆืœื ืฉื•ื‘.");}
154
+ finally{if(valid())controls(false);}
155
+ };
156
+ const success=()=>{say("ื”ืชืฉื•ื‘ื” ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื” ื•ืžืฆื•ืจืคืช ืœืฉืืœื” ื‘ืžื—ืฉื‘.");take.hidden=send.hidden=confirm.hidden=renew.hidden=true;c.photo=null;if(valid())pollInbox(e);};
157
+ send.onclick=async()=>{
158
+ if(!c.photo||c.busy)return;
159
+ controls(true);say(states.uploading);
160
+ try{
161
+ c.key=c.key||crypto.randomUUID();
162
+ let b=await post(`/api/math/answer-scans/${c.result.scan.id}/uploads`,{key:c.key});if(!valid())return;
163
+ if(b.attachment){success();return;}
164
+ const form=new FormData();form.append("photo",c.photo,"answer.jpg");
165
+ b=await json(`/api/math/answer-scans/${c.result.scan.id}/uploads/${b.lease_id}`,{method:"POST",body:form});
166
+ if(!valid())return;c.result=b;success();
167
+ }catch(error){
168
+ if(!valid())return;say(error.message+" ื”ืชืžื•ื ื” ื ืฉืžืจื” ืœืชืฆื•ื’ื” ืžืงื“ื™ืžื”. ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘.");
169
+ if(error.status===410||error.code==="superseded")expired();
170
+ // Reuse the key after an uncertain response; the server returns prior success.
171
+ }finally{if(valid())controls(false);}
172
+ };
173
+ renew.onclick=async()=>{
174
+ controls(true);
175
+ try{const r=await post(`/api/math/answer-attempts/${c.result.attempt.id}/scans`,{renew:true});if(!valid())return;
176
+ c.result=r;c.key=null;renew.hidden=true;confirm.hidden=false;say("ื ื•ืฆืจื” ื‘ืงืฉื” ื—ื“ืฉื”. ื”ืื ืœืฆืœื ืชืฉื•ื‘ื” ืœืฉืืœื” ื”ื–ื•?");}
177
+ catch(error){say(error.message);}
178
+ finally{if(valid())controls(false);}
179
+ };
180
+ if(result.scan.state==="expired")expired();
181
+ node.querySelector("[data-close]").focus();
182
+ }
183
+ document.addEventListener("visibilitychange",()=>{
184
+ if(document.hidden)return;
185
+ if(account&&inbox)pollInbox(epoch);
186
+ if(question?.result)pollQuestion(question);
187
+ });
188
+ window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
189
+ window.MathlyAnswers={configure,mountQuestion,closeQuestion,reset};
190
+ })();
static/index.html CHANGED
@@ -24,6 +24,8 @@
24
  </main>
25
  <script>window.MATHLY={supabaseUrl:"__SUPABASE_URL__",supabaseKey:"__SUPABASE_KEY__",token:"",email:"",classes:[],permissions:[]};</script>
26
  <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
 
 
27
  <script src="/static/math.js"></script>
28
  </body>
29
  </html>
 
24
  </main>
25
  <script>window.MATHLY={supabaseUrl:"__SUPABASE_URL__",supabaseKey:"__SUPABASE_KEY__",token:"",email:"",classes:[],permissions:[]};</script>
26
  <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
27
+ <script src="/static/photo-image.js"></script>
28
+ <script src="/static/answer-scans.js"></script>
29
  <script src="/static/math.js"></script>
30
  </body>
31
  </html>
static/math.css CHANGED
@@ -143,3 +143,12 @@ html.math-on, html.math-on body{overflow:hidden}
143
  .mx-pair-content{display:flex;flex-direction:column;gap:14px;align-items:stretch}
144
  .mx-phone-qr{display:block;width:230px;max-width:100%;height:auto;background:white;margin:auto;border-radius:8px}
145
  .mx-phone-url{display:block;box-sizing:border-box;width:100%;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;font:14px monospace}
 
 
 
 
 
 
 
 
 
 
143
  .mx-pair-content{display:flex;flex-direction:column;gap:14px;align-items:stretch}
144
  .mx-phone-qr{display:block;width:230px;max-width:100%;height:auto;background:white;margin:auto;border-radius:8px}
145
  .mx-phone-url{display:block;box-sizing:border-box;width:100%;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;font:14px monospace}
146
+ /* Handwritten answer scanning */
147
+ .mx-answer{display:flex;flex-direction:column;gap:12px}
148
+ .mx-answer [hidden]{display:none}
149
+ .mx-answer-image{display:block;max-width:100%;max-height:420px;object-fit:contain;border-radius:12px;background:#fff}
150
+ .mx-scan-inbox:empty{display:none}.mx-scan-inbox{display:flex;flex-direction:column;gap:12px;padding-top:24px}
151
+ .mx-scan-inbox .mx-btn{display:flex;flex-direction:column;align-items:flex-start;text-align:start;white-space:normal;gap:8px}
152
+ .mx-scan-overlay{position:fixed;inset:0;z-index:1000;background:#000c;overflow:auto;padding:20px;display:flex;align-items:flex-start;justify-content:center}
153
+ .mx-scan-card{background:#141719;color:#f4f2ec;border:1px solid #485050;border-radius:18px;padding:24px;width:min(520px,100%);display:flex;flex-direction:column;gap:14px;line-height:1.6}
154
+ .mx-scan-card p{margin:0}.mx-scan-card [hidden]{display:none}.mx-scan-card small{color:#adb7b1}
static/math.js CHANGED
@@ -28,6 +28,7 @@ let picker=null, PH=null; // the photo file picker, and the photographed probl
28
  let advanceTimer=null;
29
  let phonePair=null;
30
  function stopPhone(){
 
31
  if(phonePair){clearTimeout(phonePair.timer);if(phonePair.qr)URL.revokeObjectURL(phonePair.qr);phonePair=null;}
32
  }
33
  function cancelAdvance(){ clearTimeout(advanceTimer); advanceTimer=null; }
@@ -70,6 +71,7 @@ async function show(){
70
  catch(e){ path.innerHTML=`<p class="mx-note">ืœื ื”ืฆืœื—ื ื• ืœื˜ืขื•ืŸ ืืช ื”ืžืกืœื•ืœ: ${esc(e.message)}. ืจืขื ื ื• ืืช ื”ืขืžื•ื“ ื•ื ืกื• ืฉื•ื‘.</p>`; return; }
71
  }
72
  P=Object.assign({done:{},tries:{}},load()); SHOWN=new Set(shownUnits().map(u=>u.id)); draw();
 
73
  }
74
  function hide(){ cancelAdvance(); if(root) root.hidden=true; document.documentElement.classList.remove("math-on"); }
75
 
@@ -110,8 +112,9 @@ function chips(ids){
110
  function open(id){
111
  stopPhone();
112
  cancelAdvance();
113
- const s=BY[id], st=status(id), unit=G.units.find(u=>u.id===s.unit), rot=ORDER.indexOf(id)%s.sample.choices.length;
114
- const choices=s.sample.choices.slice(rot).concat(s.sample.choices.slice(0,rot)), missing=s.prereqs.filter(p=>!P.done[p]&&SHOWN.has(BY[p].unit));
 
115
  if(!cur) back=document.activeElement;
116
  cur=id;
117
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
@@ -122,15 +125,16 @@ function open(id){
122
  <p>${fmt(s.description)}</p>
123
  ${st==="locked"?`<div class="mx-hint">ืžื•ืžืœืฅ ืœืขื‘ื•ืจ ืงื•ื“ื ืขืœ: <span class="mx-chips">${chips(missing)}</span></div>`:""}
124
  <div class="mx-q"><p>${fmt(s.sample.question)}</p>${s.sample.graph?plot(s.sample.graph):""}${s.sample.diagram?geometryDiagram(s.sample.diagram):""}
125
- <div class="mx-choices">${choices.map((c,i)=>`<button data-choice="${i}" data-m="${c.correct?"":esc(c.mistake)}">${fmt(c.text)}</button>`).join("")}</div>
126
  <div class="mx-fb" aria-live="polite"></div></div>
127
  <div class="mx-sec"><h4>ื“ื•ืจืฉืช</h4><div class="mx-chips">${chips(s.prereqs)}</div></div>
128
  <div class="mx-sec"><h4>ืคื•ืชื—ืช</h4><div class="mx-chips">${chips(POST[id])}</div></div>
129
  </div>`;
130
  sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
131
  sheet.querySelector(".mx-x").focus({preventScroll:true});
 
132
  }
133
- function closeSheet(){ stopPhone(); cancelAdvance(); sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
134
 
135
  function answer(btn){
136
  if(btn.disabled) return;
@@ -176,19 +180,14 @@ function onClick(e){
176
  case "work": PH.works.add(+t.dataset.i); return drawPhoto();
177
  case "all": PH.all=true; return drawPhoto();
178
  case "close": return closeSheet();
179
- case "out": return client?.auth.signOut().then(()=>location.reload());
180
  }
181
  }
182
 
183
  /* A photographed problem (/api/math/photo, app/math_photo.py): hints first. The sheet shows the problem as it was
184
  read (a wrong reading is the student's cue to photograph again), then one hint; each further hint, each step and
185
  the whole solution with its answer come only when the student asks for them. */
186
- async function shrink(file){ // a phone photo is several MB: about 1600 px on its long side is enough to read it
187
- const bmp=await createImageBitmap(file,{imageOrientation:"from-image"}), k=Math.min(1,1600/Math.max(bmp.width,bmp.height));
188
- const c=document.createElement("canvas"); c.width=Math.round(bmp.width*k); c.height=Math.round(bmp.height*k);
189
- c.getContext("2d").drawImage(bmp,0,0,c.width,c.height); bmp.close?.();
190
- return new Promise((ok,no)=>c.toBlob(b=>b?ok(b):no(new Error("toBlob")),"image/jpeg",.85));
191
- }
192
  async function photo(file){
193
  stopPhone();
194
  cancelAdvance();
@@ -342,6 +341,12 @@ async function boot(){
342
  if(AUTH_URL){
343
  if(!window.supabase){ state.className="error"; state.textContent="ืœื ื ื™ืชืŸ ืœื˜ืขื•ืŸ ืืช ืฉื™ืจื•ืช ื”ื›ื ื™ืกื”."; return; }
344
  client=window.supabase.createClient(AUTH_URL,CFG.supabaseKey);
 
 
 
 
 
 
345
  const {data}=await client.auth.getSession(), session=data.session;
346
  if(!session){
347
  state.textContent="ื™ืฉ ืœื”ื™ื›ื ืก ื›ื“ื™ ืœืคืชื•ื— ืืช ืžืกืœื•ืœ ื”ืžืชืžื˜ื™ืงื”."; login.hidden=false;
@@ -351,7 +356,7 @@ async function boot(){
351
  CFG.token=session.access_token;
352
  const response=await api("/api/me");
353
  if(!response.ok){ state.className="error"; state.textContent=response.status===403?"ื”ื—ืฉื‘ื•ืŸ ืื™ื ื• ืžืฉื•ื™ืš ืœื›ื™ืชืช ืžืชืžื˜ื™ืงื”.":"ืœื ื ื™ืชืŸ ืœื”ืฉืœื™ื ืืช ื”ื›ื ื™ืกื”."; return; }
354
- const me=await response.json(); ME_EMAIL=me.email||""; MY_CLASSES=me.classes||[]; MY_CLASS=MY_CLASSES.length===1?MY_CLASSES[0]:null;
355
  CFG.permissions=me.permissions||[];
356
  }
357
  document.getElementById("boot").hidden=true;
 
28
  let advanceTimer=null;
29
  let phonePair=null;
30
  function stopPhone(){
31
+ window.MathlyAnswers?.closeQuestion();
32
  if(phonePair){clearTimeout(phonePair.timer);if(phonePair.qr)URL.revokeObjectURL(phonePair.qr);phonePair=null;}
33
  }
34
  function cancelAdvance(){ clearTimeout(advanceTimer); advanceTimer=null; }
 
71
  catch(e){ path.innerHTML=`<p class="mx-note">ืœื ื”ืฆืœื—ื ื• ืœื˜ืขื•ืŸ ืืช ื”ืžืกืœื•ืœ: ${esc(e.message)}. ืจืขื ื ื• ืืช ื”ืขืžื•ื“ ื•ื ืกื• ืฉื•ื‘.</p>`; return; }
72
  }
73
  P=Object.assign({done:{},tries:{}},load()); SHOWN=new Set(shownUnits().map(u=>u.id)); draw();
74
+ window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root});
75
  }
76
  function hide(){ cancelAdvance(); if(root) root.hidden=true; document.documentElement.classList.remove("math-on"); }
77
 
 
112
  function open(id){
113
  stopPhone();
114
  cancelAdvance();
115
+ const s=BY[id], st=status(id), unit=G.units.find(u=>u.id===s.unit), openEnded=s.sample.type==="open-ended";
116
+ const options=s.sample.choices||[],rot=options.length?ORDER.indexOf(id)%options.length:0;
117
+ const choices=options.slice(rot).concat(options.slice(0,rot)), missing=s.prereqs.filter(p=>!P.done[p]&&SHOWN.has(BY[p].unit));
118
  if(!cur) back=document.activeElement;
119
  cur=id;
120
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
 
125
  <p>${fmt(s.description)}</p>
126
  ${st==="locked"?`<div class="mx-hint">ืžื•ืžืœืฅ ืœืขื‘ื•ืจ ืงื•ื“ื ืขืœ: <span class="mx-chips">${chips(missing)}</span></div>`:""}
127
  <div class="mx-q"><p>${fmt(s.sample.question)}</p>${s.sample.graph?plot(s.sample.graph):""}${s.sample.diagram?geometryDiagram(s.sample.diagram):""}
128
+ ${openEnded?'<div class="mx-answer"></div>':`<div class="mx-choices">${choices.map((c,i)=>`<button data-choice="${i}" data-m="${c.correct?"":esc(c.mistake)}">${fmt(c.text)}</button>`).join("")}</div>`}
129
  <div class="mx-fb" aria-live="polite"></div></div>
130
  <div class="mx-sec"><h4>ื“ื•ืจืฉืช</h4><div class="mx-chips">${chips(s.prereqs)}</div></div>
131
  <div class="mx-sec"><h4>ืคื•ืชื—ืช</h4><div class="mx-chips">${chips(POST[id])}</div></div>
132
  </div>`;
133
  sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
134
  sheet.querySelector(".mx-x").focus({preventScroll:true});
135
+ if(openEnded)window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
136
  }
137
+ function closeSheet(){ stopPhone(); cancelAdvance(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
138
 
139
  function answer(btn){
140
  if(btn.disabled) return;
 
180
  case "work": PH.works.add(+t.dataset.i); return drawPhoto();
181
  case "all": PH.all=true; return drawPhoto();
182
  case "close": return closeSheet();
183
+ case "out": window.MathlyAnswers?.reset();closeSheet();hide();return client?.auth.signOut().then(()=>location.reload());
184
  }
185
  }
186
 
187
  /* A photographed problem (/api/math/photo, app/math_photo.py): hints first. The sheet shows the problem as it was
188
  read (a wrong reading is the student's cue to photograph again), then one hint; each further hint, each step and
189
  the whole solution with its answer come only when the student asks for them. */
190
+ async function shrink(file){ return window.MathlyPhoto.prepare(file); }
 
 
 
 
 
191
  async function photo(file){
192
  stopPhone();
193
  cancelAdvance();
 
341
  if(AUTH_URL){
342
  if(!window.supabase){ state.className="error"; state.textContent="ืœื ื ื™ืชืŸ ืœื˜ืขื•ืŸ ืืช ืฉื™ืจื•ืช ื”ื›ื ื™ืกื”."; return; }
343
  client=window.supabase.createClient(AUTH_URL,CFG.supabaseKey);
344
+ client.auth.onAuthStateChange((event,session)=>{
345
+ CFG.token=session?.access_token||"";
346
+ if(event==="SIGNED_OUT"||(CFG.userId&&session?.user?.id&&session.user.id!==CFG.userId)){
347
+ window.MathlyAnswers?.reset();closeSheet();hide();location.reload();
348
+ }
349
+ });
350
  const {data}=await client.auth.getSession(), session=data.session;
351
  if(!session){
352
  state.textContent="ื™ืฉ ืœื”ื™ื›ื ืก ื›ื“ื™ ืœืคืชื•ื— ืืช ืžืกืœื•ืœ ื”ืžืชืžื˜ื™ืงื”."; login.hidden=false;
 
356
  CFG.token=session.access_token;
357
  const response=await api("/api/me");
358
  if(!response.ok){ state.className="error"; state.textContent=response.status===403?"ื”ื—ืฉื‘ื•ืŸ ืื™ื ื• ืžืฉื•ื™ืš ืœื›ื™ืชืช ืžืชืžื˜ื™ืงื”.":"ืœื ื ื™ืชืŸ ืœื”ืฉืœื™ื ืืช ื”ื›ื ื™ืกื”."; return; }
359
+ const me=await response.json();CFG.userId=me.user_id;CFG.answerScans=me.answer_scans; ME_EMAIL=me.email||""; MY_CLASSES=me.classes||[]; MY_CLASS=MY_CLASSES.length===1?MY_CLASSES[0]:null;
360
  CFG.permissions=me.permissions||[];
361
  }
362
  document.getElementById("boot").hidden=true;
static/phone.html CHANGED
@@ -20,5 +20,5 @@
20
  <img id="preview" alt="ื”ืชืจื’ื™ืœ ืฉืฆื™ืœืžืชื" hidden>
21
  <button id="send" type="button" hidden>ืฉืœื™ื—ื” ืœืžื—ืฉื‘</button>
22
  <p id="status" role="status" aria-live="polite"></p>
23
- </main><script src="/static/phone.js"></script></body>
24
  </html>
 
20
  <img id="preview" alt="ื”ืชืจื’ื™ืœ ืฉืฆื™ืœืžืชื" hidden>
21
  <button id="send" type="button" hidden>ืฉืœื™ื—ื” ืœืžื—ืฉื‘</button>
22
  <p id="status" role="status" aria-live="polite"></p>
23
+ </main><script src="/static/photo-image.js"></script><script src="/static/phone.js"></script></body>
24
  </html>
static/phone.js CHANGED
@@ -10,28 +10,13 @@ try{
10
  const camera=document.getElementById("camera"),take=document.getElementById("take"),send=document.getElementById("send"),
11
  preview=document.getElementById("preview"),status=document.getElementById("status");
12
  let photo=null,previewURL=null;
13
- async function loadPhoto(file){
14
- try{return await createImageBitmap(file,{imageOrientation:"from-image"});}
15
- catch{
16
- // Safari can display some camera formats that createImageBitmap cannot decode.
17
- const url=URL.createObjectURL(file),img=new Image();
18
- try{
19
- await new Promise((resolve,reject)=>{img.onload=resolve;img.onerror=reject;img.src=url;});
20
- return img;
21
- }finally{URL.revokeObjectURL(url);}
22
- }
23
- }
24
  if(!token){status.textContent="ืกืจืงื• ืฉื•ื‘ ืืช ื”ืงื•ื“ ืฉืžื•ืคื™ืข ื‘ืžื—ืฉื‘ ื›ื“ื™ ืœื”ืชื—ื‘ืจ.";take.disabled=true;return;}
25
  take.onclick=()=>camera.click();
26
  camera.onchange=async()=>{
27
  const file=camera.files[0];camera.value=""; if(!file)return;
28
  photo=null;take.disabled=true;send.hidden=true;status.textContent="ืžื›ื™ื ื™ื ืืช ื”ืชืžื•ื ื”โ€ฆ";
29
  try{
30
- const bitmap=await loadPhoto(file);
31
- const scale=Math.min(1,1600/Math.max(bitmap.width,bitmap.height)),canvas=document.createElement("canvas");
32
- canvas.width=Math.round(bitmap.width*scale);canvas.height=Math.round(bitmap.height*scale);
33
- canvas.getContext("2d").drawImage(bitmap,0,0,canvas.width,canvas.height);bitmap.close?.();
34
- photo=await new Promise(resolve=>canvas.toBlob(resolve,"image/jpeg",.85));
35
  if(!photo)throw Error("image");
36
  }catch{
37
  if(["image/jpeg","image/png","image/webp"].includes(file.type))photo=file;
 
10
  const camera=document.getElementById("camera"),take=document.getElementById("take"),send=document.getElementById("send"),
11
  preview=document.getElementById("preview"),status=document.getElementById("status");
12
  let photo=null,previewURL=null;
 
 
 
 
 
 
 
 
 
 
 
13
  if(!token){status.textContent="ืกืจืงื• ืฉื•ื‘ ืืช ื”ืงื•ื“ ืฉืžื•ืคื™ืข ื‘ืžื—ืฉื‘ ื›ื“ื™ ืœื”ืชื—ื‘ืจ.";take.disabled=true;return;}
14
  take.onclick=()=>camera.click();
15
  camera.onchange=async()=>{
16
  const file=camera.files[0];camera.value=""; if(!file)return;
17
  photo=null;take.disabled=true;send.hidden=true;status.textContent="ืžื›ื™ื ื™ื ืืช ื”ืชืžื•ื ื”โ€ฆ";
18
  try{
19
+ photo=await window.MathlyPhoto.prepare(file);
 
 
 
 
20
  if(!photo)throw Error("image");
21
  }catch{
22
  if(["image/jpeg","image/png","image/webp"].includes(file.type))photo=file;
static/photo-image.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Shared camera preparation for answer scans and photographed-problem help. */
2
+ window.MathlyPhoto={async prepare(file){
3
+ let bitmap,temporary;
4
+ try{bitmap=await createImageBitmap(file,{imageOrientation:"from-image"});}
5
+ catch{
6
+ temporary=URL.createObjectURL(file);
7
+ bitmap=new Image();
8
+ try{await new Promise((ok,no)=>{bitmap.onload=ok;bitmap.onerror=no;bitmap.src=temporary;});}
9
+ finally{URL.revokeObjectURL(temporary);}
10
+ }
11
+ try{
12
+ const scale=Math.min(1,1600/Math.max(bitmap.width,bitmap.height)),canvas=document.createElement("canvas");
13
+ canvas.width=Math.round(bitmap.width*scale);canvas.height=Math.round(bitmap.height*scale);
14
+ canvas.getContext("2d").drawImage(bitmap,0,0,canvas.width,canvas.height);
15
+ return await new Promise((ok,no)=>canvas.toBlob(blob=>blob?ok(blob):no(Error("image")),"image/jpeg",.85));
16
+ }finally{bitmap.close?.();}
17
+ }};
supabase/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .temp/
2
+ .branches/
supabase/migrations/20261031133254_math_answer_scans.sql ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Additive answer-scanning schema. Existing classes and solver sessions are untouched.
2
+ begin;
3
+ create table public.math_question_attempts (
4
+ id uuid primary key default gen_random_uuid(),
5
+ user_id uuid not null references auth.users(id) on delete cascade,
6
+ question_id text not null,
7
+ question_version text not null,
8
+ snapshot jsonb not null,
9
+ status text not null default 'draft' check (status in ('draft','received')),
10
+ created_at timestamptz not null default now(),
11
+ unique (id,user_id)
12
+ );
13
+ create index math_attempt_owner_question on public.math_question_attempts(user_id,question_id,question_version,created_at desc);
14
+ create table public.math_answer_scans (
15
+ id uuid primary key default gen_random_uuid(),
16
+ user_id uuid not null references auth.users(id) on delete cascade,
17
+ attempt_id uuid not null,
18
+ generation integer not null,
19
+ state text not null default 'waiting' check (state in ('waiting','confirmed','uploading','received','failed','expired','superseded')),
20
+ expires_at timestamptz not null default (now()+interval '15 minutes'),
21
+ lease_id uuid,
22
+ lease_until timestamptz,
23
+ idempotency_key uuid,
24
+ error text not null default '',
25
+ created_at timestamptz not null default now(),
26
+ foreign key (attempt_id,user_id) references public.math_question_attempts(id,user_id) on delete cascade,
27
+ unique (attempt_id,generation),
28
+ unique (id,attempt_id,user_id)
29
+ );
30
+ create index math_scan_owner on public.math_answer_scans(user_id,created_at desc);
31
+ create unique index math_scan_one_active on public.math_answer_scans(attempt_id)
32
+ where state in ('waiting','confirmed','uploading','failed');
33
+ create table public.math_answer_attachments (
34
+ id uuid primary key default gen_random_uuid(),
35
+ user_id uuid not null references auth.users(id) on delete cascade,
36
+ attempt_id uuid not null unique,
37
+ scan_id uuid not null unique,
38
+ object_key text not null unique,
39
+ media_type text not null check (media_type = 'image/jpeg'),
40
+ byte_size integer not null check (byte_size > 0 and byte_size <= 5000000),
41
+ created_at timestamptz not null default now(),
42
+ foreign key (scan_id,attempt_id,user_id) references public.math_answer_scans(id,attempt_id,user_id) on delete cascade
43
+ );
44
+ create index math_attachment_owner on public.math_answer_attachments(user_id);
45
+ alter table public.math_question_attempts enable row level security;
46
+ alter table public.math_answer_scans enable row level security;
47
+ alter table public.math_answer_attachments enable row level security;
48
+ revoke all on public.math_question_attempts,public.math_answer_scans,public.math_answer_attachments from anon,authenticated;
49
+ grant select on public.math_question_attempts,public.math_answer_scans,public.math_answer_attachments to authenticated;
50
+ grant all on public.math_question_attempts,public.math_answer_scans,public.math_answer_attachments to service_role;
51
+ create policy math_attempt_owner_read on public.math_question_attempts for select to authenticated using ((select auth.uid())=user_id);
52
+ create policy math_scan_owner_read on public.math_answer_scans for select to authenticated using ((select auth.uid())=user_id);
53
+ create policy math_attachment_owner_read on public.math_answer_attachments for select to authenticated using ((select auth.uid())=user_id);
54
+
55
+ -- Invoker, service-role-only RPC. FastAPI supplies a verified user ID.
56
+ -- All scan writers lock the attempt first, serializing renewal and completion.
57
+ create function public.math_answer_action(p_user uuid, p_action text, p_data jsonb default '{}')
58
+ returns jsonb language plpgsql security invoker set search_path = '' as $$
59
+ declare
60
+ a public.math_question_attempts;
61
+ s public.math_answer_scans;
62
+ att public.math_answer_attachments;
63
+ t timestamptz := clock_timestamp();
64
+ sid uuid;
65
+ aid uuid;
66
+ listing jsonb;
67
+ begin
68
+ if p_action='cleanup_candidates' then
69
+ return coalesce((select jsonb_agg(o.name) from storage.objects o
70
+ where o.bucket_id='mathly-answers' and o.created_at < t-interval '24 hours'
71
+ and not exists(select 1 from public.math_answer_attachments x where x.object_key=o.name)), '[]'::jsonb);
72
+ end if;
73
+ if p_user is null then raise exception 'not_found' using errcode='P0002'; end if;
74
+ if p_action='pending' then
75
+ -- Expiry and stale lease recovery are also persisted on discovery.
76
+ update public.math_answer_scans set state='expired',error='expired'
77
+ where user_id=p_user and expires_at<=t and state in ('waiting','confirmed','uploading','failed');
78
+ update public.math_answer_scans set state='failed',error='upload_interrupted'
79
+ where user_id=p_user and state='uploading' and lease_until<=t;
80
+ select jsonb_agg(jsonb_build_object('scan',to_jsonb(x),'attempt',to_jsonb(q)) order by x.created_at desc)
81
+ into listing from public.math_answer_scans x join public.math_question_attempts q on q.id=x.attempt_id
82
+ where x.user_id=p_user and x.state in ('waiting','confirmed','uploading','failed','expired')
83
+ and not exists(select 1 from public.math_answer_scans newer where newer.attempt_id=x.attempt_id and newer.generation>x.generation);
84
+ return coalesce(listing,'[]'::jsonb);
85
+ end if;
86
+ if p_action='resume' then
87
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(p_user::text||(p_data->>'question_id')||(p_data->>'question_version'),0));
88
+ if not coalesce((p_data->>'new')::boolean,false) then
89
+ select * into a from public.math_question_attempts where user_id=p_user
90
+ and question_id=p_data->>'question_id' and question_version=p_data->>'question_version'
91
+ order by created_at desc,id desc limit 1 for update;
92
+ end if;
93
+ if a.id is null then
94
+ insert into public.math_question_attempts(user_id,question_id,question_version,snapshot)
95
+ values(p_user,p_data->>'question_id',p_data->>'question_version',p_data->'snapshot') returning * into a;
96
+ end if;
97
+ else
98
+ if p_data ? 'scan_id' then
99
+ select attempt_id into aid from public.math_answer_scans where id=(p_data->>'scan_id')::uuid and user_id=p_user;
100
+ else aid := (p_data->>'attempt_id')::uuid;
101
+ end if;
102
+ select * into a from public.math_question_attempts where id=aid and user_id=p_user for update;
103
+ if a.id is null then raise exception 'not_found' using errcode='P0002'; end if;
104
+ if p_data ? 'question_id' and a.question_id<>p_data->>'question_id' then
105
+ raise exception 'not_found' using errcode='P0002';
106
+ end if;
107
+ end if;
108
+ select * into s from public.math_answer_scans where attempt_id=a.id order by generation desc limit 1 for update;
109
+ select * into att from public.math_answer_attachments where attempt_id=a.id;
110
+ t := clock_timestamp();
111
+ if p_data ? 'scan_id' and s.id is distinct from (p_data->>'scan_id')::uuid then
112
+ raise exception 'superseded' using errcode='P0001';
113
+ end if;
114
+ if s.id is not null then
115
+ if s.state in ('waiting','confirmed','uploading','failed') and s.expires_at<=t then
116
+ update public.math_answer_scans set state='expired',error='expired' where id=s.id returning * into s;
117
+ elsif s.state='uploading' and s.lease_until<=t then
118
+ update public.math_answer_scans set state='failed',error='upload_interrupted' where id=s.id returning * into s;
119
+ end if;
120
+ end if;
121
+ if p_action='create_scan' then
122
+ if att.id is not null then null;
123
+ elsif s.id is null or coalesce((p_data->>'renew')::boolean,false) then
124
+ if s.state='uploading' then raise exception 'upload_in_progress' using errcode='P0001'; end if;
125
+ if s.id is not null then update public.math_answer_scans set state='superseded' where id=s.id; end if;
126
+ insert into public.math_answer_scans(user_id,attempt_id,generation,expires_at,created_at)
127
+ values(p_user,a.id,coalesce(s.generation,0)+1,t+interval '15 minutes',t) returning * into s;
128
+ end if;
129
+ elsif p_action='confirm' then
130
+ if s.state='waiting' then
131
+ update public.math_answer_scans set state='confirmed' where id=s.id returning * into s;
132
+ end if;
133
+ elsif p_action='begin' then
134
+ if s.state in ('confirmed','failed') then
135
+ update public.math_answer_scans set state='uploading',lease_id=gen_random_uuid(),
136
+ lease_until=least(t+interval '3 minutes',expires_at),idempotency_key=(p_data->>'key')::uuid,error=''
137
+ where id=s.id returning * into s;
138
+ elsif s.state='uploading' and s.idempotency_key is distinct from (p_data->>'key')::uuid then
139
+ raise exception 'upload_in_progress' using errcode='P0001';
140
+ elsif s.state='waiting' then raise exception 'confirmation_required' using errcode='P0001';
141
+ end if;
142
+ elsif p_action='finalize' then
143
+ if att.id is null then
144
+ if s.state<>'uploading' or s.lease_id is distinct from (p_data->>'lease_id')::uuid then
145
+ raise exception 'invalid_upload' using errcode='P0001';
146
+ end if;
147
+ if (p_data->>'object_key') not like p_user::text||'/'||s.id::text||'/'||s.lease_id::text||'/%' then
148
+ raise exception 'invalid_upload' using errcode='P0001';
149
+ end if;
150
+ insert into public.math_answer_attachments(user_id,attempt_id,scan_id,object_key,media_type,byte_size)
151
+ values(p_user,a.id,s.id,p_data->>'object_key','image/jpeg',(p_data->>'byte_size')::integer) returning * into att;
152
+ update public.math_answer_scans set state='received',error='' where id=s.id returning * into s;
153
+ update public.math_question_attempts set status='received' where id=a.id returning * into a;
154
+ end if;
155
+ elsif p_action='fail' then
156
+ if s.state='uploading' and s.lease_id=(p_data->>'lease_id')::uuid then
157
+ update public.math_answer_scans set state='failed',error='upload_failed' where id=s.id returning * into s;
158
+ end if;
159
+ elsif p_action not in ('resume','get') then
160
+ raise exception 'invalid_action';
161
+ end if;
162
+ return jsonb_build_object('attempt',to_jsonb(a),'scan',case when s.id is null then null else to_jsonb(s) end,
163
+ 'attachment',case when att.id is null then null else to_jsonb(att) end);
164
+ end $$;
165
+ revoke all on function public.math_answer_action(uuid,text,jsonb) from public,anon,authenticated;
166
+ grant execute on function public.math_answer_action(uuid,text,jsonb) to service_role;
167
+
168
+ -- Image bytes are accessed only by the backend after verifying attempt ownership.
169
+ insert into storage.buckets(id,name,public,file_size_limit,allowed_mime_types)
170
+ values('mathly-answers','mathly-answers',false,5000000,array['image/jpeg']);
171
+ -- Restrictive policies also block any pre-existing permissive "all buckets" policy.
172
+ create policy math_answer_private on storage.objects as restrictive for all to anon,authenticated
173
+ using (bucket_id <> 'mathly-answers') with check (bucket_id <> 'mathly-answers');
174
+ commit;
tests/scan_support.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real PostgreSQL transactions and a local private-blob stand-in for tests."""
2
+ import json
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from app.answer_scans import AnswerScans, ScanError
9
+
10
+ OWNER = '11111111-1111-4111-8111-111111111111'
11
+ OTHER = '22222222-2222-4222-8222-222222222222'
12
+
13
+ BOOTSTRAP = '''
14
+ create schema auth;
15
+ create table auth.users(id uuid primary key);
16
+ create function auth.uid() returns uuid language sql stable as
17
+ $$select nullif(current_setting('request.jwt.claim.sub',true),'')::uuid$$;
18
+ grant usage on schema public,auth to anon,authenticated,service_role;
19
+ create schema storage;
20
+ create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]);
21
+ create table storage.objects(id uuid primary key default gen_random_uuid(),bucket_id text,name text unique,created_at timestamptz default now());
22
+ alter table storage.objects enable row level security;
23
+ grant usage on schema storage to anon,authenticated,service_role;
24
+ grant all on storage.objects to anon,authenticated,service_role;
25
+ create policy preexisting_permissive on storage.objects for all using(true) with check(true);
26
+ create table public.classes(id int primary key,name text);
27
+ insert into classes values(1,'existing math class');
28
+ '''
29
+
30
+
31
+ class ScanAuth:
32
+ url = 'https://test.invalid'
33
+ key = 'public-test'
34
+ secret = 'server-test'
35
+ teachers = frozenset()
36
+ developers = frozenset()
37
+
38
+ def user(self, token):
39
+ if token not in (OWNER, OTHER):
40
+ return None
41
+ return {'id': token, 'email': token + '@test.invalid'}
42
+
43
+ def classes(self):
44
+ return []
45
+
46
+ def access_for(self, _id, **kwargs):
47
+ return None
48
+
49
+
50
+ class DatabaseAnswers(AnswerScans):
51
+ def __init__(self, uri, files):
52
+ super().__init__(ScanAuth(), enabled=True)
53
+ self.uri, self.files = uri, files
54
+ self.fail_put = self.fail_finalize = False
55
+
56
+ def connect(self):
57
+ import psycopg
58
+ return psycopg.connect(self.uri, autocommit=True)
59
+
60
+ def action(self, user_id, action, **data):
61
+ import psycopg
62
+ if self.fail_finalize and action == 'finalize':
63
+ raise ScanError(503, 'answer_storage_unavailable')
64
+ with self.connect() as c:
65
+ c.execute('set role service_role')
66
+ try:
67
+ return c.execute('select public.math_answer_action(%s,%s,%s::jsonb)',
68
+ (user_id, action, json.dumps(data))).fetchone()[0]
69
+ except psycopg.errors.NoDataFound as exc:
70
+ raise ScanError(404, 'not_found') from exc
71
+ except psycopg.errors.RaiseException as exc:
72
+ raise ScanError(409, str(exc).splitlines()[0]) from exc
73
+
74
+ def put(self, key, data):
75
+ if self.fail_put:
76
+ raise ScanError(503, 'answer_storage_unavailable')
77
+ p = self.files / key
78
+ p.parent.mkdir(parents=True, exist_ok=True)
79
+ p.write_bytes(data)
80
+ with self.connect() as c:
81
+ c.execute("insert into storage.objects(bucket_id,name) values('mathly-answers',%s)", (key,))
82
+
83
+ def read(self, key):
84
+ return (self.files / key).read_bytes()
85
+
86
+ def delete(self, key):
87
+ (self.files / key).unlink(missing_ok=True)
88
+ with self.connect() as c:
89
+ c.execute('delete from storage.objects where name=%s', (key,))
90
+
91
+
92
+ @pytest.fixture(scope='session')
93
+ def postgres_server(tmp_path_factory):
94
+ pgserver = pytest.importorskip('pgserver')
95
+ psycopg = pytest.importorskip('psycopg')
96
+ server = pgserver.get_server(tmp_path_factory.mktemp('postgres') / 'data', cleanup_mode='delete')
97
+ with psycopg.connect(server.get_uri(), autocommit=True) as c:
98
+ c.execute('create role anon; create role authenticated; create role service_role bypassrls;')
99
+ yield server
100
+
101
+
102
+ @pytest.fixture
103
+ def answer_store(postgres_server, tmp_path):
104
+ import psycopg
105
+ database = 'scan_' + uuid.uuid4().hex
106
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
107
+ c.execute('create database ' + database)
108
+ uri = postgres_server.get_uri(database=database)
109
+ store = DatabaseAnswers(uri, tmp_path / 'images')
110
+ with store.connect() as c:
111
+ c.execute(BOOTSTRAP)
112
+ c.execute(Path('supabase/migrations/20261031133254_math_answer_scans.sql').read_text())
113
+ c.execute('insert into auth.users values(%s),(%s)', (OWNER, OTHER))
114
+ yield store
115
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
116
+ c.execute('drop database ' + database + ' with (force)')
tests/test_answer_browser.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Desktop/mobile integration against real PostgreSQL and the production API."""
2
+ import socket
3
+ import threading
4
+ import time
5
+
6
+ import pytest
7
+ import uvicorn
8
+
9
+ from app.main import create_app
10
+ from scan_support import OWNER, OTHER, ScanAuth, answer_store, postgres_server
11
+ from test_answer_scans import png, request
12
+
13
+ playwright = pytest.importorskip('playwright.sync_api')
14
+
15
+
16
+ @pytest.fixture
17
+ def scan_server(answer_store):
18
+ app = create_app(auth=ScanAuth()); app.state.answers = answer_store
19
+ with socket.socket() as sock:
20
+ sock.bind(('127.0.0.1', 0))
21
+ server = uvicorn.Server(uvicorn.Config(app, log_level='error'))
22
+ thread = threading.Thread(target=server.run, kwargs={'sockets': [sock]}, daemon=True)
23
+ thread.start()
24
+ try:
25
+ deadline = time.monotonic() + 5
26
+ while not server.started:
27
+ assert thread.is_alive() and time.monotonic() < deadline
28
+ time.sleep(.01)
29
+ yield f'http://127.0.0.1:{sock.getsockname()[1]}', answer_store
30
+ finally:
31
+ server.should_exit = True; thread.join(timeout=5)
32
+
33
+
34
+ def session(browser, owner=OWNER, mobile=False):
35
+ ctx = browser.new_context(viewport={'width': 390 if mobile else 1280, 'height': 844},
36
+ is_mobile=mobile, has_touch=mobile)
37
+ ctx.route('https://cdn.jsdelivr.net/**', lambda route: route.abort())
38
+ # Stub only the external sign-in SDK; all app APIs and ownership checks run.
39
+ ctx.add_init_script('''window.supabase={createClient:()=>({auth:{
40
+ getSession:async()=>{const id=localStorage.getItem('test.account')||'%s';return {data:{session:{access_token:id,user:{id}}}};},
41
+ onAuthStateChange:cb=>{window.testAuthEvent=cb;return {data:{subscription:{unsubscribe(){}}}};},
42
+ signOut:async()=>window.testAuthEvent('SIGNED_OUT',null)
43
+ }})};''' % owner)
44
+ return ctx
45
+
46
+
47
+ def open_question(page):
48
+ page.locator('.mx-node[data-id="g_triangle_area"]').click()
49
+ playwright.expect(page.locator('.mx-answer')).to_be_visible()
50
+ playwright.expect(page.locator('.mx-choices')).to_have_count(0)
51
+
52
+
53
+ def test_two_device_capture_retake_failure_retry_and_original_attempt(scan_server):
54
+ url, store = scan_server
55
+ with playwright.sync_playwright() as p:
56
+ browser = p.chromium.launch()
57
+ try:
58
+ computer = session(browser).new_page(); errors=[]
59
+ computer.on('pageerror', lambda error: errors.append(str(error)))
60
+ computer.goto(url)
61
+ with computer.expect_request('**/api/math/answer-attempts') as resume:
62
+ open_question(computer)
63
+ assert len(resume.value.post_data_json['question_version'])==64
64
+ computer.locator('[data-request]').click()
65
+ playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ')
66
+ with store.connect() as c:
67
+ attempt_id = str(c.execute('select id from math_question_attempts').fetchone()[0])
68
+ phone = session(browser, mobile=True).new_page()
69
+ phone.on('pageerror', lambda error: errors.append(str(error)))
70
+ request(store, question='another-question')
71
+ phone.goto(url)
72
+ playwright.expect(phone.locator('[data-scan-index]')).to_have_count(2)
73
+ phone.locator('[data-scan-index]').filter(has_text='ืžืฉื•ืœืฉ ื™ืฉืจ').click()
74
+ # Confirmation and cancellation do not invoke capture or upload.
75
+ assert phone.locator('[data-camera]').input_value() == ''
76
+ assert phone.locator('[data-take]').is_hidden()
77
+ phone.locator('[data-close]').click()
78
+ phone.locator('[data-scan-index]').filter(has_text='ืžืฉื•ืœืฉ ื™ืฉืจ').click()
79
+ phone.evaluate("window.cameraClicks=0;document.addEventListener('click',e=>{if(e.target.matches('[data-camera]'))window.cameraClicks++},true)")
80
+ phone.locator('[data-confirm]').click()
81
+ playwright.expect(phone.locator('[data-take]')).to_be_visible()
82
+ phone.locator('[data-camera]').dispatch_event('cancel')
83
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ืœื ื ื™ืชื ื” ื”ืจืฉืื”')
84
+ image = {'name': 'answer.png', 'mimeType': 'image/png', 'buffer': png()}
85
+ assert phone.evaluate('window.cameraClicks')==0
86
+ with phone.expect_file_chooser() as chooser:
87
+ phone.locator('[data-take]').click()
88
+ chooser.value.set_files(image)
89
+ playwright.expect(phone.locator('[data-submit]')).to_be_visible()
90
+ phone.screenshot(path='/tmp/mathly-answer-phone.png', full_page=True, animations='disabled')
91
+ first = phone.locator('[data-preview]').get_attribute('src')
92
+ phone.locator('[data-camera]').set_input_files(image)
93
+ playwright.expect(phone.locator('[data-preview]')).not_to_have_attribute('src', first)
94
+ assert store.action(OWNER, 'get', attempt_id=attempt_id)['attachment'] is None
95
+ store.fail_put = True
96
+ phone.locator('[data-submit]').click()
97
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘')
98
+ playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ื”ื”ืขืœืื” ื ื›ืฉืœื”', timeout=10000)
99
+ assert phone.locator('[data-preview]').is_visible()
100
+ store.fail_put = False
101
+ started, release = threading.Event(), threading.Event()
102
+ original_put = store.put
103
+ def slow_put(key, data):
104
+ started.set(); assert release.wait(timeout=15); original_put(key, data)
105
+ store.put = slow_put
106
+ phone.locator('[data-submit]').click()
107
+ playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืขืœื™ื ืืช ื”ืชืฉื•ื‘ื”โ€ฆ', timeout=10000)
108
+ assert started.is_set()
109
+ computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="g_rectangle"]').click()
110
+ release.set()
111
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”', timeout=10000)
112
+ assert computer.locator('.mx-answer-image').count() == 0
113
+ assert computer.locator('.mx-choices button').count() > 0
114
+ computer.locator('.mx-x').click();open_question(computer)
115
+ playwright.expect(computer.locator('.mx-answer-image')).to_be_visible()
116
+ assert computer.locator('.mx-answer-image').evaluate('img=>img.complete&&img.naturalWidth>0')
117
+ computer.screenshot(path='/tmp/mathly-answer-computer.png', full_page=True, animations='disabled')
118
+ assert computer.evaluate("!JSON.parse(localStorage.getItem('mathly.progress.v1:'+'%s@test.invalid')||'{}').done?.g_triangle_area" % OWNER)
119
+ computer.reload();open_question(computer)
120
+ playwright.expect(computer.locator('.mx-answer-image')).to_be_visible()
121
+ foreign = session(browser, OTHER, mobile=True).new_page();foreign.goto(url)
122
+ playwright.expect(foreign.locator('.mx-scan-inbox')).to_be_empty()
123
+ assert store.action(OWNER, 'get', attempt_id=attempt_id)['attachment']
124
+ assert not errors
125
+ finally:
126
+ browser.close()
127
+
128
+
129
+ def test_expiry_renewal_reconnect_and_account_change(scan_server):
130
+ url, store = scan_server
131
+ with playwright.sync_playwright() as p:
132
+ browser = p.chromium.launch()
133
+ try:
134
+ computer = session(browser).new_page();computer.goto(url);open_question(computer)
135
+ computer.locator('[data-request]').click()
136
+ playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ')
137
+ with store.connect() as c:
138
+ old = str(c.execute('select id from math_answer_scans').fetchone()[0])
139
+ c.execute("update math_answer_scans set expires_at=now()-interval '1 second'")
140
+ computer.route('**/api/math/answer-attempts/*', lambda r:r.abort(), times=1)
141
+ playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”', timeout=15000)
142
+ phone = session(browser, mobile=True).new_page();phone.goto(url)
143
+ phone.locator('[data-scan-index]').click()
144
+ playwright.expect(phone.locator('[data-renew]')).to_be_visible()
145
+ phone.locator('[data-renew]').click()
146
+ playwright.expect(phone.locator('[data-confirm]')).to_be_visible()
147
+ assert store.action(OWNER, 'pending')[0]['scan']['generation'] == 2
148
+ phone.locator('[data-confirm]').click()
149
+ phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()})
150
+ phone.locator('[data-submit]').click()
151
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”')
152
+ playwright.expect(computer.locator('.mx-answer-image')).to_be_visible(timeout=10000)
153
+ # Account change removes the photograph and all old-account continuations.
154
+ computer.evaluate("localStorage.setItem('test.account','%s');window.testAuthEvent('SIGNED_IN',{access_token:'%s',user:{id:'%s'}})" % (OTHER, OTHER, OTHER))
155
+ playwright.expect(computer.locator('.mx-node[data-id="g_triangle_area"]')).to_be_visible()
156
+ assert computer.locator('.mx-answer-image').count() == 0
157
+ open_question(computer)
158
+ playwright.expect(computer.locator('[data-request]')).to_be_enabled()
159
+ assert computer.locator('.mx-answer-image').count() == 0
160
+ finally:
161
+ browser.close()
162
+
163
+
164
+ def test_success_response_loss_is_safe_to_retry(scan_server):
165
+ url, store = scan_server
166
+ with playwright.sync_playwright() as p:
167
+ browser = p.chromium.launch()
168
+ try:
169
+ computer=session(browser).new_page();computer.goto(url);open_question(computer)
170
+ computer.locator('[data-request]').click()
171
+ playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ')
172
+ phone=session(browser,mobile=True).new_page();phone.goto(url)
173
+ phone.locator('[data-scan-index]').click();phone.locator('[data-confirm]').click()
174
+ phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()})
175
+ def lost_response(route):
176
+ response=route.fetch()
177
+ assert response.ok
178
+ route.abort()
179
+ phone.route('**/api/math/answer-scans/*/uploads/*',lost_response,times=1)
180
+ phone.locator('[data-submit]').click()
181
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘')
182
+ playwright.expect(computer.locator('.mx-answer-image')).to_be_visible(timeout=10000)
183
+ phone.locator('[data-submit]').click()
184
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”')
185
+ with store.connect() as c:
186
+ assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1
187
+ computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="g_rectangle"]').click()
188
+ computer.locator('.mx-choices button[data-m=""]').click()
189
+ assert computer.evaluate("!!JSON.parse(localStorage.getItem('mathly.progress.v1:'+'%s@test.invalid')).done.g_rectangle" % OWNER)
190
+ finally:
191
+ browser.close()
tests/test_answer_scans.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import io
3
+ import uuid
4
+ from concurrent.futures import ThreadPoolExecutor
5
+
6
+ import httpx
7
+ import pytest
8
+ from PIL import Image, ImageDraw
9
+
10
+ from app.answer_scans import ScanError, clean_image
11
+ from app.main import create_app
12
+ from scan_support import OWNER, OTHER, ScanAuth, answer_store, postgres_server
13
+
14
+
15
+ def png():
16
+ out = io.BytesIO()
17
+ image = Image.new('RGB', (320, 240), 'white')
18
+ pen = ImageDraw.Draw(image)
19
+ pen.line([(35, 30), (35, 150), (250, 150), (35, 30)], fill='navy', width=3)
20
+ pen.text((12, 80), '6', fill='navy'); pen.text((135, 160), '8', fill='navy')
21
+ pen.text((35, 195), '6 x 8 / 2 = 24 cm2', fill='navy')
22
+ image.save(out, 'PNG')
23
+ return out.getvalue()
24
+
25
+
26
+ def attempt(store, owner=OWNER, version='v1', question='geometry'):
27
+ return store.action(owner, 'resume', question_id=question, question_version=version,
28
+ snapshot={'question': 'Draw your proof', 'type': 'open-ended'})
29
+
30
+
31
+ def request(store, owner=OWNER, **kwargs):
32
+ a = attempt(store, owner, **kwargs)
33
+ return store.action(owner, 'create_scan', attempt_id=a['attempt']['id'])
34
+
35
+
36
+ def begin(store, result, key=None):
37
+ sid = result['scan']['id']
38
+ store.action(OWNER, 'confirm', scan_id=sid)
39
+ return store.action(OWNER, 'begin', scan_id=sid, key=key or str(uuid.uuid4()))
40
+
41
+
42
+ def finish(store, result):
43
+ s = result['scan']
44
+ key = f"{OWNER}/{s['id']}/{s['lease_id']}/{uuid.uuid4()}.jpg"
45
+ store.put(key, clean_image(png(), 'image/png'))
46
+ return store.action(OWNER, 'finalize', scan_id=s['id'], lease_id=s['lease_id'], object_key=key, byte_size=500)
47
+
48
+
49
+ def test_additive_migration_rls_and_service_only_rpc(answer_store):
50
+ import psycopg
51
+ store = answer_store
52
+ mine = request(store)
53
+ finish(store, begin(store, mine))
54
+ request(store, OTHER)
55
+ with store.connect() as c:
56
+ assert c.execute('select name from classes').fetchone()[0] == 'existing math class'
57
+ assert c.execute("select public from storage.buckets where id='mathly-answers'").fetchone()[0] is False
58
+ c.execute('set role authenticated')
59
+ c.execute("select set_config('request.jwt.claim.sub',%s,false)", (OWNER,))
60
+ rows = c.execute('select user_id from math_question_attempts').fetchall()
61
+ assert [str(row[0]) for row in rows] == [OWNER]
62
+ assert len(c.execute('select id from math_answer_scans').fetchall()) == 1
63
+ assert len(c.execute('select id from math_answer_attachments').fetchall()) == 1
64
+ c.execute("select set_config('request.jwt.claim.sub',%s,false)", (OTHER,))
65
+ assert c.execute('select id from math_answer_attachments').fetchall() == []
66
+ c.execute("select set_config('request.jwt.claim.sub',%s,false)", (OWNER,))
67
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
68
+ c.execute("select public.math_answer_action(%s,'get',%s::jsonb)", (OTHER, '{}'))
69
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
70
+ c.execute("update math_answer_scans set state='received'")
71
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
72
+ c.execute("insert into storage.objects(bucket_id,name) values('mathly-answers','foreign')")
73
+ assert c.execute("select * from storage.objects where bucket_id='mathly-answers'").fetchall() == []
74
+ c.execute('set role anon')
75
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
76
+ c.execute('select * from math_answer_scans')
77
+ with pytest.raises(ScanError) as e:
78
+ store.action(OTHER, 'get', attempt_id=mine['attempt']['id'])
79
+ assert e.value.status == 404
80
+
81
+
82
+ def test_attempt_resume_version_and_generation(answer_store):
83
+ store = answer_store
84
+ a = attempt(store)
85
+ assert attempt(store)['attempt']['id'] == a['attempt']['id']
86
+ assert attempt(store, version='v2')['attempt']['id'] != a['attempt']['id']
87
+ r = store.action(OWNER, 'create_scan', attempt_id=a['attempt']['id'])
88
+ assert store.action(OWNER, 'create_scan', attempt_id=a['attempt']['id'])['scan']['id'] == r['scan']['id']
89
+ with pytest.raises(ScanError):
90
+ store.action(OWNER, 'create_scan', attempt_id=a['attempt']['id'], question_id='forged')
91
+ with store.connect() as c:
92
+ c.execute("update math_answer_scans set expires_at=now()-interval '1 second' where id=%s", (r['scan']['id'],))
93
+ assert store.action(OWNER, 'get', scan_id=r['scan']['id'])['scan']['state'] == 'expired'
94
+ new = store.action(OWNER, 'create_scan', attempt_id=a['attempt']['id'], renew=True)
95
+ assert new['scan']['generation'] == 2
96
+ with pytest.raises(ScanError, match='superseded'):
97
+ store.action(OWNER, 'confirm', scan_id=r['scan']['id'])
98
+
99
+
100
+ def test_concurrent_uploads_and_lost_response(answer_store):
101
+ store = answer_store
102
+ r = request(store)
103
+ with pytest.raises(ScanError, match='confirmation_required'):
104
+ store.action(OWNER, 'begin', scan_id=r['scan']['id'], key=str(uuid.uuid4()))
105
+ b = begin(store, r)
106
+ assert store.action(OWNER, 'begin', scan_id=r['scan']['id'], key=b['scan']['idempotency_key'])['scan']['lease_id'] == b['scan']['lease_id']
107
+ with pytest.raises(ScanError, match='upload_in_progress'):
108
+ store.action(OWNER, 'begin', scan_id=r['scan']['id'], key=str(uuid.uuid4()))
109
+ with ThreadPoolExecutor(2) as pool:
110
+ results = list(pool.map(lambda _: finish(store, b), range(2)))
111
+ assert results[0]['attachment']['id'] == results[1]['attachment']['id']
112
+ assert results[0]['attempt']['status'] == 'received'
113
+ with store.connect() as c:
114
+ assert c.execute('select count(*) from math_answer_attachments').fetchone()[0] == 1
115
+ assert store.action(OWNER, 'create_scan', attempt_id=r['attempt']['id'], renew=True)['scan']['id'] == r['scan']['id']
116
+
117
+
118
+ def test_stale_lease_and_renewal_reject_late_completion(answer_store):
119
+ store = answer_store
120
+ b = begin(store, request(store))
121
+ with store.connect() as c:
122
+ c.execute("update math_answer_scans set lease_until=now()-interval '1 second' where id=%s", (b['scan']['id'],))
123
+ assert store.action(OWNER, 'pending')[0]['scan']['state'] == 'failed'
124
+ store.action(OWNER, 'create_scan', attempt_id=b['attempt']['id'], renew=True)
125
+ with pytest.raises(ScanError, match='superseded'):
126
+ finish(store, b)
127
+ assert store.action(OWNER, 'get', attempt_id=b['attempt']['id'])['attachment'] is None
128
+
129
+
130
+ def test_cleanup_retains_committed_images_and_removes_deleted_attempts(answer_store):
131
+ store = answer_store
132
+ done = finish(store, begin(store, request(store)))
133
+ store.put('orphan.jpg', b'orphan')
134
+ with store.connect() as c:
135
+ c.execute("update storage.objects set created_at=now()-interval '25 hours'")
136
+ assert store.cleanup() == 1
137
+ assert store.read(done['attachment']['object_key'])
138
+ with store.connect() as c:
139
+ c.execute('delete from math_question_attempts where id=%s', (done['attempt']['id'],))
140
+ assert store.cleanup() == 1
141
+
142
+
143
+ def test_image_validation_and_exif_removal():
144
+ assert Image.open(io.BytesIO(clean_image(png(), 'image/png'))).format == 'JPEG'
145
+ for data, mime, status in [(b'fake', 'image/jpeg', 415), (b'', 'image/png', 400),
146
+ (b'x' * 5_000_001, 'image/jpeg', 413), (png(), 'image/heic', 415)]:
147
+ with pytest.raises(ScanError) as e:
148
+ clean_image(data, mime)
149
+ assert e.value.status == status
150
+ image = Image.new('RGB', (30, 60))
151
+ exif = Image.Exif(); exif[274] = 6; exif[270] = 'private'
152
+ raw = io.BytesIO(); image.save(raw, 'JPEG', exif=exif)
153
+ result = Image.open(io.BytesIO(clean_image(raw.getvalue(), 'image/jpeg')))
154
+ assert result.size == (60, 30) and not result.getexif()
155
+
156
+
157
+ def test_scan_api_end_to_end_failure_retry_and_restart(answer_store, monkeypatch):
158
+ import copy
159
+ from app.math_skills import algebra9, question_version
160
+ graph = copy.deepcopy(algebra9())
161
+ graph['skills'][0]['sample'] = {'type': 'open-ended', 'question': 'Draw a proof', 'version': 1}
162
+ monkeypatch.setattr('app.answer_routes.algebra9', lambda: graph)
163
+ monkeypatch.delenv('ANTHROPIC_API_KEY', raising=False)
164
+ store = answer_store
165
+ app = create_app(auth=ScanAuth()); app.state.answers = store
166
+ owner = {'Authorization': f'Bearer {OWNER}'}
167
+ other = {'Authorization': f'Bearer {OTHER}'}
168
+ async def check():
169
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test', headers=owner) as c:
170
+ assert (await c.get('/api/me')).json()['answer_scans'] is True
171
+ assert (await c.post('/api/math/answer-attempts', json={'question_id': 'forged'})).status_code == 404
172
+ assert (await c.post('/api/math/answer-attempts', json={'question_id': graph['skills'][0]['id'], 'question_version': 'bad'})).status_code == 409
173
+ assert (await c.post('/api/math/answer-attempts', json={'question_id': graph['skills'][0]['id']})).status_code == 409
174
+ result = (await c.post('/api/math/answer-attempts', json={'question_id': graph['skills'][0]['id'],
175
+ 'question_version': question_version(graph['skills'][0]['sample'])})).json()
176
+ aid = result['attempt']['id']
177
+ result = (await c.post(f'/api/math/answer-attempts/{aid}/scans', json={})).json()
178
+ sid = result['scan']['id']
179
+ assert 'lease_id' not in result['scan']
180
+ assert (await c.get(f'/api/math/answer-scans/{sid}', headers=other)).status_code == 404
181
+ assert (await c.get('/api/math/answer-scans', headers=other)).json() == []
182
+ assert (await c.get(f'/api/math/answer-attempts/{aid}/image', headers=other)).status_code == 404
183
+ assert (await c.post(f'/api/math/answer-scans/{sid}/confirm')).status_code == 200
184
+ async def lease():
185
+ return (await c.post(f'/api/math/answer-scans/{sid}/uploads', json={'key': str(uuid.uuid4())})).json()['lease_id']
186
+ lid = await lease()
187
+ url = f'/api/math/answer-scans/{sid}/uploads/{lid}'
188
+ assert (await c.post(url, files={'photo': ('fake.jpg', b'fake', 'image/jpeg')})).status_code == 415
189
+ assert (await c.get(f'/api/math/answer-scans/{sid}')).json()['scan']['state'] == 'failed'
190
+ for failure in ('fail_put', 'fail_finalize'):
191
+ lid = await lease(); url = f'/api/math/answer-scans/{sid}/uploads/{lid}'
192
+ setattr(store, failure, True)
193
+ assert (await c.post(url, files={'photo': ('answer.png', png(), 'image/png')})).status_code == 503
194
+ setattr(store, failure, False)
195
+ assert (await c.get(f'/api/math/answer-scans/{sid}')).json()['attachment'] is None
196
+ lid = await lease(); url = f'/api/math/answer-scans/{sid}/uploads/{lid}'
197
+ result = (await c.post(url, files={'photo': ('answer.png', png(), 'image/png')})).json()
198
+ assert result['scan']['state'] == 'received' and 'object_key' not in result['attachment']
199
+ assert (await c.post(url, files={'photo': ('answer.png', png(), 'image/png')})).json()['attachment']['id'] == result['attachment']['id']
200
+ image = await c.get(f'/api/math/answer-attempts/{aid}/image')
201
+ assert image.status_code == 200 and image.headers['cache-control'] == 'no-store'
202
+ restarted = create_app(auth=ScanAuth()); restarted.state.answers = store
203
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=restarted), base_url='http://test', headers=owner) as new:
204
+ assert (await new.get(f'/api/math/answer-attempts/{aid}/image')).content == image.content
205
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url='http://test') as guest:
206
+ assert (await guest.get('/api/math/answer-scans')).status_code == 401
207
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as guest:
208
+ assert (await guest.get('/api/math/answer-scans')).status_code == 401
209
+ asyncio.run(check())
210
+
211
+
212
+ def test_expired_scan_rejects_begin_and_submission_and_can_renew(answer_store):
213
+ store = answer_store
214
+ r = request(store)
215
+ b = begin(store, r)
216
+ with store.connect() as c:
217
+ c.execute("update math_answer_scans set expires_at=now()-interval '1 second' where id=%s", (r['scan']['id'],))
218
+ app = create_app(auth=ScanAuth());app.state.answers=store
219
+ async def check():
220
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test', headers={'Authorization':f'Bearer {OWNER}'}) as c:
221
+ sid, lid = b['scan']['id'], b['scan']['lease_id']
222
+ assert (await c.post(f'/api/math/answer-scans/{sid}/confirm')).status_code == 410
223
+ assert (await c.post(f'/api/math/answer-scans/{sid}/uploads', json={'key':str(uuid.uuid4())})).status_code == 410
224
+ assert (await c.post(f'/api/math/answer-scans/{sid}/uploads/{lid}', files={'photo':('a.png',png(),'image/png')})).status_code == 410
225
+ renewed = (await c.post(f"/api/math/answer-attempts/{r['attempt']['id']}/scans",json={'renew':True})).json()
226
+ assert renewed['attempt']['id'] == r['attempt']['id'] and renewed['scan']['generation']==2
227
+ assert (await c.post(f'/api/math/answer-scans/{sid}/confirm')).status_code == 409
228
+ asyncio.run(check())
229
+
230
+
231
+ def test_enable_rollback_preserves_received_answers(answer_store):
232
+ store = answer_store
233
+ done = finish(store, begin(store, request(store)))
234
+ store.enabled = False
235
+ app = create_app(auth=ScanAuth());app.state.answers=store
236
+ async def check():
237
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url='http://test',headers={'Authorization':f'Bearer {OWNER}'}) as c:
238
+ assert (await c.get('/api/me')).json()['answer_scans'] is False
239
+ skills=(await c.get('/api/math/skills')).json()['skills']
240
+ assert len(next(s for s in skills if s['id']=='g_triangle_area')['sample']['choices'])==3
241
+ assert (await c.get('/api/math/answer-scans')).status_code==503
242
+ store.enabled=True
243
+ skills=(await c.get('/api/math/skills')).json()['skills']
244
+ assert next(s for s in skills if s['id']=='g_triangle_area')['sample']['type']=='open-ended'
245
+ assert (await c.get(f"/api/math/answer-attempts/{done['attempt']['id']}/image")).status_code==200
246
+ with store.connect() as c:
247
+ assert c.execute('select count(*) from classes').fetchone()[0]==1
248
+ assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1
249
+ asyncio.run(check())
250
+
251
+
252
+ def test_supabase_storage_http_contract(monkeypatch):
253
+ import json
254
+ from urllib.error import HTTPError
255
+ from app.answer_scans import AnswerScans
256
+ store = AnswerScans(ScanAuth(), enabled=True)
257
+ requests=[]
258
+ class Response:
259
+ def __enter__(self): return self
260
+ def __exit__(self,*args): pass
261
+ def read(self): return b'{}'
262
+ def urlopen(req, timeout):
263
+ requests.append(req)
264
+ return Response()
265
+ monkeypatch.setattr('urllib.request.urlopen',urlopen)
266
+ store.action(OWNER,'get',attempt_id=str(uuid.uuid4()))
267
+ assert requests[-1].full_url.endswith('/rest/v1/rpc/math_answer_action')
268
+ assert json.loads(requests[-1].data)['p_user']==OWNER
269
+ key=f'{OWNER}/scan/lease/image.jpg'
270
+ store.put(key,b'image');assert requests[-1].method=='POST' and requests[-1].full_url.endswith('/storage/v1/object/mathly-answers/'+key)
271
+ assert requests[-1].get_header('Content-type')=='image/jpeg'
272
+ store.read(key);assert requests[-1].full_url.endswith('/storage/v1/object/authenticated/mathly-answers/'+key)
273
+ store.delete(key);assert requests[-1].method=='DELETE' and json.loads(requests[-1].data)=={'prefixes':[key]}
274
+ assert all(r.get_header('Authorization')=='Bearer server-test' for r in requests)
275
+ def denied(req, timeout):
276
+ raise HTTPError(req.full_url,400,'bad',{},io.BytesIO(b'{"code":"P0002","message":"private detail"}'))
277
+ monkeypatch.setattr('urllib.request.urlopen',denied)
278
+ with pytest.raises(ScanError) as e:
279
+ store.action(OTHER,'get',attempt_id=str(uuid.uuid4()))
280
+ assert (e.value.status,e.value.code)==(404,'not_found')
281
+
282
+
283
+ def test_renewal_and_finalization_are_serialized(answer_store):
284
+ store = answer_store
285
+ b = begin(store, request(store))
286
+ def renew():
287
+ try:
288
+ return store.action(OWNER,'create_scan',attempt_id=b['attempt']['id'],renew=True)
289
+ except ScanError as exc:
290
+ assert exc.code=='upload_in_progress'
291
+ with ThreadPoolExecutor(2) as pool:
292
+ finalizing=pool.submit(finish,store,b)
293
+ renewing=pool.submit(renew)
294
+ result=finalizing.result();renewing.result()
295
+ assert result['scan']['state']=='received'
296
+ assert store.action(OWNER,'get',attempt_id=b['attempt']['id'])['attachment']['id']==result['attachment']['id']
297
+ with store.connect() as c:
298
+ assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1
299
+ assert c.execute('select count(*) from math_answer_scans').fetchone()[0]==1
tests/test_math_skills.py CHANGED
@@ -4,7 +4,30 @@ import asyncio
4
  import httpx
5
 
6
  from app.main import create_app
7
- from app.math_skills import algebra9, problems
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
 
10
  def test_the_algebra_graph_is_whole():
@@ -26,7 +49,7 @@ def test_math_is_the_only_page_and_api():
26
  async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url="http://test") as client:
27
  assert "ืžืชืžื˜ื™ืงื” ื‘ื›ื™ืฃ" in (await client.get("/")).text
28
  assert "ืžืชืžื˜ื™ืงื” ื‘ื›ื™ืฃ" in (await client.get("/math")).text
29
- assert (await client.get("/api/math/skills")).json() == algebra9()
30
  assert (await client.get("/api/health")).json() == {"ok": True, "app": "mathly"}
31
  asyncio.run(check())
32
 
@@ -48,3 +71,14 @@ def test_a_photographed_problem_is_solved(monkeypatch):
48
  assert response.json()["answer"] == "$x = ยฑ3$"
49
  assert response.json()["left"] is None
50
  asyncio.run(check())
 
 
 
 
 
 
 
 
 
 
 
 
4
  import httpx
5
 
6
  from app.main import create_app
7
+ from app.math_skills import algebra9, practice_graph, problems, question_version
8
+
9
+
10
+ def test_open_ended_samples_and_content_identity():
11
+ graph = copy.deepcopy(algebra9())
12
+ sample = graph["skills"][0]["sample"] = {"type": "open-ended", "version": 1,
13
+ "question": "Draw a triangle."}
14
+ assert problems(graph) == []
15
+ original = question_version(sample)
16
+ sample["question"] = "Draw a rectangle."
17
+ assert question_version(sample) != original
18
+ sample["choices"] = []
19
+ assert any("cannot have choices" in item for item in problems(graph))
20
+ sample.pop("choices")
21
+ sample["version"] = True
22
+ assert any("version" in item for item in problems(graph))
23
+ sample["type"] = "unknown"
24
+ assert any("unknown question type" in item for item in problems(graph))
25
+
26
+
27
+ def test_choice_samples_still_require_one_correct_choice():
28
+ graph = copy.deepcopy(algebra9())
29
+ graph["skills"][0]["sample"]["choices"] = []
30
+ assert any("exactly one correct choice" in item for item in problems(graph))
31
 
32
 
33
  def test_the_algebra_graph_is_whole():
 
49
  async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url="http://test") as client:
50
  assert "ืžืชืžื˜ื™ืงื” ื‘ื›ื™ืฃ" in (await client.get("/")).text
51
  assert "ืžืชืžื˜ื™ืงื” ื‘ื›ื™ืฃ" in (await client.get("/math")).text
52
+ assert (await client.get("/api/math/skills")).json() == practice_graph(False)
53
  assert (await client.get("/api/health")).json() == {"ok": True, "app": "mathly"}
54
  asyncio.run(check())
55
 
 
71
  assert response.json()["answer"] == "$x = ยฑ3$"
72
  assert response.json()["left"] is None
73
  asyncio.run(check())
74
+
75
+
76
+ def test_rollout_and_rollback_question_variants():
77
+ enabled, disabled = practice_graph(True), practice_graph(False)
78
+ assert problems(enabled) == problems(disabled) == []
79
+ opened = next(s for s in enabled['skills'] if s['id']=='g_triangle_area')
80
+ fallback = next(s for s in disabled['skills'] if s['id']=='g_triangle_area')
81
+ assert opened['sample']['type']=='open-ended' and 'choices' not in opened['sample']
82
+ assert opened['question_version']==question_version(opened['sample'])
83
+ assert len(fallback['sample']['choices']) == 3
84
+ assert all('fallback_sample' not in s for s in enabled['skills'])