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 +2 -0
- README.md +29 -0
- app/answer_cleanup.py +21 -0
- app/answer_routes.py +125 -0
- app/answer_scans.py +132 -0
- app/main.py +7 -3
- app/math_algebra9.json +14 -2
- app/math_skills.py +39 -2
- docs/answer-scan-verification.md +42 -0
- openspec/changes/add-open-question-phone-scan/.openspec.yaml +2 -0
- openspec/changes/add-open-question-phone-scan/design.md +63 -0
- openspec/changes/add-open-question-phone-scan/proposal.md +33 -0
- openspec/changes/add-open-question-phone-scan/specs/open-question-answers/spec.md +57 -0
- openspec/changes/add-open-question-phone-scan/specs/phone-answer-scans/spec.md +90 -0
- openspec/changes/add-open-question-phone-scan/tasks.md +36 -0
- openspec/config.yaml +1 -0
- requirements-dev.txt +4 -0
- requirements.txt +1 -0
- static/answer-scans.js +190 -0
- static/index.html +2 -0
- static/math.css +9 -0
- static/math.js +17 -12
- static/phone.html +1 -1
- static/phone.js +1 -16
- static/photo-image.js +17 -0
- supabase/.gitignore +2 -0
- supabase/migrations/20261031133254_math_answer_scans.sql +174 -0
- tests/scan_support.py +116 -0
- tests/test_answer_browser.py +191 -0
- tests/test_answer_scans.py +299 -0
- tests/test_math_skills.py +36 -2
.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, "
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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),
|
| 114 |
-
const
|
|
|
|
| 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){
|
| 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 |
-
|
| 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() ==
|
| 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'])
|