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

145
store.py
View file

@ -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