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:
parent
552f862f4c
commit
e586bc92e7
4 changed files with 302 additions and 39 deletions
114
app.py
114
app.py
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue