diff --git a/app.py b/app.py
index 46834d6..2d8e631 100644
--- a/app.py
+++ b/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."""
diff --git a/static/app.js b/static/app.js
index 3dbf6c7..08124f1 100644
--- a/static/app.js
+++ b/static/app.js
@@ -363,7 +363,11 @@ function renderHistory() {
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}
diff --git a/store.py b/store.py
index 17c8634..a46c8f0 100644
--- a/store.py
+++ b/store.py
@@ -5,6 +5,7 @@ does exactly one thing (estimate a PSA grade from photos) and remembers what
it told you, so you can look back at a card without re-running the estimate.
"""
+import base64
import json
import os
import sqlite3
@@ -42,7 +43,10 @@ CREATE TABLE IF NOT EXISTS grades (
limitations_json TEXT,
note TEXT,
estimated_cost REAL,
- usage_json TEXT
+ usage_json TEXT,
+ source_images_json TEXT -- the original photo(s), for Regrade; NOT sent
+ -- to the browser in list/detail responses —
+ -- see get_grade_images vs get_grade/list_grades
);
CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC);
@@ -70,10 +74,14 @@ def init():
# so a column added after cards were already graded needs its own
# migration — guarded because re-running this against a database
# that already has the column would otherwise error every startup.
- try:
- conn.execute("ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT")
- except sqlite3.OperationalError:
- pass
+ for statement in (
+ "ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT",
+ "ALTER TABLE grades ADD COLUMN source_images_json TEXT",
+ ):
+ try:
+ conn.execute(statement)
+ except sqlite3.OperationalError:
+ pass
for key, value in DEFAULT_SETTINGS.items():
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
@@ -126,8 +134,35 @@ def save_settings(updates):
# ------------------------------------------------------------------ grades
-def save_grade(grade, thumbnail=None, label=None):
- """Persist one grading result. Returns the new row's id."""
+def _encode_images(images):
+ """(bytes, filename) pairs -> the JSON text stored in source_images_json."""
+ if not images:
+ return None
+ return json.dumps([
+ {"filename": name, "image_base64": base64.standard_b64encode(b).decode("ascii")}
+ for b, name in images
+ ])
+
+
+def _decode_images(raw):
+ if not raw:
+ return None
+ try:
+ items = json.loads(raw)
+ except (ValueError, TypeError):
+ return None
+ return [(base64.standard_b64decode(item["image_base64"]), item.get("filename") or "upload")
+ for item in items]
+
+
+def save_grade(grade, thumbnail=None, label=None, source_images=None):
+ """Persist one grading result. Returns the new row's id.
+
+ `source_images` are the original (bytes, filename) pairs that produced
+ this grade, kept so Regrade can re-run without asking for the photo(s)
+ again. Optional — a caller that skips this still gets everything else;
+ Regrade just falls back to prompting for photos on that row.
+ """
conn = connect()
try:
cur = conn.execute(
@@ -137,8 +172,9 @@ def save_grade(grade, thumbnail=None, label=None):
" grade_low, grade_high, confidence, categories_json, "
" edge_measurements_json, centering_measurement_json, "
" aspect_measurement_json, "
- " limitations_json, note, estimated_cost, usage_json) "
- "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ " limitations_json, note, estimated_cost, usage_json, "
+ " source_images_json) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
now(), label, grade.get("card_type"), grade.get("card_note"),
grade.get("image_count"), thumbnail,
@@ -152,6 +188,7 @@ def save_grade(grade, thumbnail=None, label=None):
json.dumps(grade.get("limitations")),
grade.get("note"), grade.get("estimated_cost"),
json.dumps(grade.get("usage")),
+ _encode_images(source_images),
),
)
conn.commit()
@@ -160,6 +197,69 @@ def save_grade(grade, thumbnail=None, label=None):
conn.close()
+def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
+ """Overwrite a grade's result in place (Regrade) — label is untouched.
+
+ created_at is bumped to now, so the card floats back to the top of
+ History as the most recently-active one, same as if it were freshly
+ graded. `source_images` only overwrites the stored photo(s) when the
+ caller actually supplies new ones (the re-pick fallback path); passing
+ None leaves whatever was already stored for this row alone.
+ """
+ conn = connect()
+ try:
+ params = [
+ now(), grade.get("card_type"), grade.get("card_note"),
+ grade.get("image_count"),
+ (grade.get("usage") or {}).get("model"),
+ grade.get("estimated_grade"), grade.get("grade_low"),
+ grade.get("grade_high"), grade.get("confidence"),
+ json.dumps(grade.get("categories")),
+ json.dumps(grade.get("edge_measurements")),
+ json.dumps(grade.get("centering_measurement")),
+ json.dumps(grade.get("aspect_measurement")),
+ json.dumps(grade.get("limitations")),
+ grade.get("note"), grade.get("estimated_cost"),
+ json.dumps(grade.get("usage")),
+ ]
+ sql = (
+ "UPDATE grades SET created_at=?, card_type=?, card_note=?, "
+ "image_count=?, model=?, estimated_grade=?, grade_low=?, "
+ "grade_high=?, confidence=?, categories_json=?, "
+ "edge_measurements_json=?, centering_measurement_json=?, "
+ "aspect_measurement_json=?, limitations_json=?, note=?, "
+ "estimated_cost=?, usage_json=?"
+ )
+ if thumbnail is not None:
+ sql += ", thumbnail=?"
+ params.append(thumbnail)
+ if source_images is not None:
+ sql += ", source_images_json=?"
+ params.append(_encode_images(source_images))
+ sql += " WHERE id=?"
+ params.append(grade_id)
+ conn.execute(sql, params)
+ conn.commit()
+ finally:
+ conn.close()
+ return get_grade(grade_id)
+
+
+def get_grade_images(grade_id):
+ """The original (bytes, filename) pairs for Regrade, or None if this
+ grade never had them stored (a card graded before this feature existed,
+ or one saved without the images path). Server-side use only — never
+ sent to the browser, unlike everything get_grade/list_grades return."""
+ conn = connect()
+ try:
+ row = conn.execute(
+ "SELECT source_images_json FROM grades WHERE id = ?", (grade_id,)
+ ).fetchone()
+ finally:
+ conn.close()
+ return _decode_images(row["source_images_json"]) if row else None
+
+
def _row_to_grade(row):
d = dict(row)
for key in ("categories_json", "edge_measurements_json",
@@ -171,14 +271,34 @@ def _row_to_grade(row):
d[out_key] = json.loads(raw) if raw else None
except (ValueError, TypeError):
d[out_key] = None
+ # The raw photo(s) are only ever for the regrade endpoint to read
+ # server-side (see get_grade_images) — swapping this for a boolean here
+ # keeps every browser-facing response light, the same reason thumbnails
+ # are pre-shrunk rather than sending the original photo for display.
+ d["has_source_images"] = bool(d.pop("source_images_json", None))
return d
+# Every column except source_images_json — that one is only ever read
+# through get_grade_images, so a plain SELECT * here would pull the full
+# original photo(s) off disk for every row just to discard them a moment
+# later in _row_to_grade, silently defeating the whole point of keeping
+# list/detail responses light.
+_LIST_COLUMNS = (
+ "id, created_at, label, card_type, card_note, image_count, thumbnail, "
+ "model, estimated_grade, grade_low, grade_high, confidence, "
+ "categories_json, edge_measurements_json, centering_measurement_json, "
+ "aspect_measurement_json, limitations_json, note, estimated_cost, "
+ "usage_json, (source_images_json IS NOT NULL) AS source_images_json"
+)
+
+
def list_grades(limit=200):
conn = connect()
try:
rows = conn.execute(
- "SELECT * FROM grades ORDER BY created_at DESC, id DESC LIMIT ?",
+ "SELECT {} FROM grades ORDER BY created_at DESC, id DESC LIMIT ?"
+ .format(_LIST_COLUMNS),
(limit,),
).fetchall()
finally:
@@ -189,7 +309,10 @@ def list_grades(limit=200):
def get_grade(grade_id):
conn = connect()
try:
- row = conn.execute("SELECT * FROM grades WHERE id = ?", (grade_id,)).fetchone()
+ row = conn.execute(
+ "SELECT {} FROM grades WHERE id = ?".format(_LIST_COLUMNS),
+ (grade_id,),
+ ).fetchone()
finally:
conn.close()
return _row_to_grade(row) if row else None