Add Regrade: instant re-run when photos are stored, fallback re-pick otherwise

Stores the original photo(s) for every card graded from now on, so an
existing history row can be re-graded in place without re-uploading -- most
useful right after a grading-logic fix like the last few commits. Cards
graded before this feature (or saved without images) fall back to the same
photo picker as a fresh grade, then start getting instant regrades from then
on. list_grades/get_grade now select an explicit column list rather than
SELECT * so the stored images are never pulled into memory just to be
discarded for the browser-facing response.
This commit is contained in:
Barely Removable 2026-08-22 11:17:06 -07:00
parent 552f862f4c
commit e586bc92e7
4 changed files with 302 additions and 39 deletions

114
app.py
View file

@ -260,6 +260,9 @@ class Handler(BaseHTTPRequestHandler):
return self._json(_public_settings())
if route == "/api/grade":
return self._grade_card(body)
if route.startswith("/api/history/") and route.endswith("/regrade"):
grade_id = int(route.split("/")[3])
return self._regrade_card(grade_id, body)
return self._error("Not found", 404)
except (ValueError, IndexError):
return self._error("Bad request", 400)
@ -296,24 +299,18 @@ class Handler(BaseHTTPRequestHandler):
# ------------------------------------------------------------- grade
def _grade_card(self, body):
"""Estimate the PSA grade for a card, from uploaded photo(s).
def _estimate(self, images, body, settings):
"""Run vision.grade_card and shape its result into a storable dict.
Always returns the estimate. Saves it to history too, unless the
caller explicitly opts out with save=false a one-off sanity check
before you've decided a card is worth logging.
Shared by fresh grading and Regrade the only difference between
those two call sites is where `images` comes from (a fresh upload vs.
photos already on file) and what happens to the result afterward
(insert a new row vs. overwrite an existing one), not how the
estimate itself gets produced. Raises vision.VisionError on failure;
callers turn that into an HTTP response themselves, since a 502 from
a first grade and a 502 from a regrade warrant slightly different
wording.
"""
settings = store.get_settings()
raw_images = body.get("images") or []
if not isinstance(raw_images, list) or not raw_images:
return self._error("Send at least one image.", 400)
if len(raw_images) > MAX_GRADE_IMAGES:
return self._error("Send at most {} images.".format(MAX_GRADE_IMAGES), 400)
images, error = _decode_uploaded_images(raw_images)
if error:
return self._error(error, 400)
model = body.get("model") if body.get("model") in vision.MODELS else settings.get("vision_model")
# A key sent with the request wins over the server's own. That is what
@ -325,18 +322,15 @@ class Handler(BaseHTTPRequestHandler):
print("[grade] calling vision model={} on {} image(s){}".format(
model, len(images), " (caller key)" if caller_key else ""), flush=True)
t0 = time.time()
try:
result = vision.grade_card(
images,
caller_key or settings.get("anthropic_api_key") or None,
model=model,
effort=settings.get("vision_effort"),
)
except vision.VisionError as exc:
return self._error(str(exc), 502)
result = vision.grade_card(
images,
caller_key or settings.get("anthropic_api_key") or None,
model=model,
effort=settings.get("vision_effort"),
)
print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True)
grade = {
return {
"image_count": len(images),
"closeups": result["closeups"],
"card_type": result["card_type"],
@ -355,13 +349,79 @@ class Handler(BaseHTTPRequestHandler):
"estimated_cost": vision.estimate_cost(result["usage"]),
}
def _grade_card(self, body):
"""Estimate the PSA grade for a card, from uploaded photo(s).
Always returns the estimate. Saves it to history too, unless the
caller explicitly opts out with save=false a one-off sanity check
before you've decided a card is worth logging.
"""
settings = store.get_settings()
raw_images = body.get("images") or []
if not isinstance(raw_images, list) or not raw_images:
return self._error("Send at least one image.", 400)
if len(raw_images) > MAX_GRADE_IMAGES:
return self._error("Send at most {} images.".format(MAX_GRADE_IMAGES), 400)
images, error = _decode_uploaded_images(raw_images)
if error:
return self._error(error, 400)
try:
grade = self._estimate(images, body, settings)
except vision.VisionError as exc:
return self._error(str(exc), 502)
grade_id = None
if body.get("save", True):
thumbnail = _make_thumbnail(images[0][0])
grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label"))
grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label"),
source_images=images)
return self._json({"grade": grade, "grade_id": grade_id}, 201)
def _regrade_card(self, grade_id, body):
"""Re-run grading on an existing history row, in place.
Uses the photo(s) already stored for this card unless the caller
supplies fresh ones which happens for a card graded before this
feature existed (or saved without images some other way), where
there's nothing on file to re-run with. Either way the row is
updated rather than duplicated, and freshly-supplied photos get
stored too, so the NEXT regrade on this card is instant.
"""
existing = store.get_grade(grade_id)
if not existing:
return self._error("No such grade", 404)
raw_images = body.get("images")
newly_supplied = bool(raw_images)
if newly_supplied:
if len(raw_images) > MAX_GRADE_IMAGES:
return self._error("Send at most {} images.".format(MAX_GRADE_IMAGES), 400)
images, error = _decode_uploaded_images(raw_images)
if error:
return self._error(error, 400)
else:
images = store.get_grade_images(grade_id)
if not images:
return self._error(
"No photos were saved for this card, so it can't be "
"regraded automatically — pick the photo(s) again.", 409)
settings = store.get_settings()
try:
grade = self._estimate(images, body, settings)
except vision.VisionError as exc:
return self._error(str(exc), 502)
thumbnail = _make_thumbnail(images[0][0])
grade = store.update_grade_result(
grade_id, grade, thumbnail=thumbnail,
source_images=images if newly_supplied else None)
return self._json({"grade": grade}, 200)
def lan_ip():
"""Best-effort LAN address, so a phone can reach this."""