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

102
app.py
View file

@ -260,6 +260,9 @@ class Handler(BaseHTTPRequestHandler):
return self._json(_public_settings()) return self._json(_public_settings())
if route == "/api/grade": if route == "/api/grade":
return self._grade_card(body) 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) return self._error("Not found", 404)
except (ValueError, IndexError): except (ValueError, IndexError):
return self._error("Bad request", 400) return self._error("Bad request", 400)
@ -296,24 +299,18 @@ class Handler(BaseHTTPRequestHandler):
# ------------------------------------------------------------- grade # ------------------------------------------------------------- grade
def _grade_card(self, body): def _estimate(self, images, body, settings):
"""Estimate the PSA grade for a card, from uploaded photo(s). """Run vision.grade_card and shape its result into a storable dict.
Always returns the estimate. Saves it to history too, unless the Shared by fresh grading and Regrade the only difference between
caller explicitly opts out with save=false a one-off sanity check those two call sites is where `images` comes from (a fresh upload vs.
before you've decided a card is worth logging. 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") 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 # 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( print("[grade] calling vision model={} on {} image(s){}".format(
model, len(images), " (caller key)" if caller_key else ""), flush=True) model, len(images), " (caller key)" if caller_key else ""), flush=True)
t0 = time.time() t0 = time.time()
try:
result = vision.grade_card( result = vision.grade_card(
images, images,
caller_key or settings.get("anthropic_api_key") or None, caller_key or settings.get("anthropic_api_key") or None,
model=model, model=model,
effort=settings.get("vision_effort"), effort=settings.get("vision_effort"),
) )
except vision.VisionError as exc:
return self._error(str(exc), 502)
print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True) print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True)
grade = { return {
"image_count": len(images), "image_count": len(images),
"closeups": result["closeups"], "closeups": result["closeups"],
"card_type": result["card_type"], "card_type": result["card_type"],
@ -355,13 +349,79 @@ class Handler(BaseHTTPRequestHandler):
"estimated_cost": vision.estimate_cost(result["usage"]), "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 grade_id = None
if body.get("save", True): if body.get("save", True):
thumbnail = _make_thumbnail(images[0][0]) 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) 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(): def lan_ip():
"""Best-effort LAN address, so a phone can reach this.""" """Best-effort LAN address, so a phone can reach this."""

View file

@ -363,7 +363,11 @@ function renderHistory() {
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td> g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
<td class="cardcell-meta">${esc(g.confidence || '')}</td> <td class="cardcell-meta">${esc(g.confidence || '')}</td>
<td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td> <td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td>
<td class="num"><button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button></td> <td class="num">
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
<button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button>
</td>
</tr>`).join(''); </tr>`).join('');
} }
@ -380,6 +384,8 @@ function renderEntryModal(g) {
<input class="input" id="entry-label" placeholder="Rename this card…" value="${esc(g.label || '')}" style="flex:1 1 200px"> <input class="input" id="entry-label" placeholder="Rename this card…" value="${esc(g.label || '')}" style="flex:1 1 200px">
<button class="btn btn-quiet btn-sm" id="entry-save-label">Save name</button> <button class="btn btn-quiet btn-sm" id="entry-save-label">Save name</button>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
<button class="btn btn-danger btn-sm" data-history-delete="${g.id}">Delete</button> <button class="btn btn-danger btn-sm" data-history-delete="${g.id}">Delete</button>
</div>`; </div>`;
$('#entry-modal').hidden = false; $('#entry-modal').hidden = false;
@ -391,6 +397,64 @@ function closeEntryModal() {
$('#entry-scrim').hidden = true; $('#entry-scrim').hidden = true;
} }
/* --------------------------------------------------------------- regrade */
//
// Instant when the card's original photo(s) were saved (everything graded
// from now on); otherwise falls back to picking the photo(s) again, same as
// grading fresh, just updating this row instead of creating a new one.
let regradeTargetId = null;
function applyRegradeResult(g) {
const idx = state.history.findIndex((row) => row.id === g.id);
if (idx === -1) state.history.unshift(g); else state.history[idx] = g;
// created_at was bumped server-side to reflect the just-finished regrade,
// so re-sort to match — otherwise the card sits wherever it used to be
// until the next full reload.
state.history.sort((a, b) =>
(b.created_at || '').localeCompare(a.created_at || '') || b.id - a.id);
renderHistory();
const openDelete = $('#entry-modal').querySelector('[data-history-delete]');
if (!$('#entry-modal').hidden && openDelete && Number(openDelete.dataset.historyDelete) === g.id) {
renderEntryModal(g);
}
}
async function runRegrade(id, body) {
banner('Regrading…');
try {
const data = await api(`/api/history/${id}/regrade`, { method: 'POST', body });
applyRegradeResult(data.grade);
banner('Regraded.');
} catch (err) {
banner(`Regrade failed: ${err.message}`, true);
}
}
function startRegrade(id, hasImages) {
if (hasImages) {
runRegrade(id, {});
} else {
regradeTargetId = id;
$('#regrade-file').click();
}
}
$('#regrade-file').addEventListener('change', async (e) => {
const id = regradeTargetId;
regradeTargetId = null;
if (!id) return;
let picked;
try {
picked = await readPickedImages(e.target, 6);
} catch (err) {
banner(err.message, true);
return;
}
if (!picked.length) return;
await runRegrade(id, { images: pickedToPayload(picked) });
});
$('#history-body').addEventListener('click', async (e) => { $('#history-body').addEventListener('click', async (e) => {
const del = e.target.closest('[data-history-delete]'); const del = e.target.closest('[data-history-delete]');
if (del) { if (del) {
@ -399,6 +463,12 @@ $('#history-body').addEventListener('click', async (e) => {
await loadHistory(); await loadHistory();
return; return;
} }
const regrade = e.target.closest('[data-history-regrade]');
if (regrade) {
e.stopPropagation();
startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1');
return;
}
const row = e.target.closest('[data-history-row]'); const row = e.target.closest('[data-history-row]');
if (row) { if (row) {
try { try {
@ -419,6 +489,11 @@ $('#entry-modal').addEventListener('click', async (e) => {
await loadHistory(); await loadHistory();
return; return;
} }
const regrade = e.target.closest('[data-history-regrade]');
if (regrade) {
startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1');
return;
}
if (e.target.closest('#entry-save-label')) { if (e.target.closest('#entry-save-label')) {
const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete; const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete;
const label = $('#entry-label').value.trim(); const label = $('#entry-label').value.trim();

View file

@ -61,6 +61,11 @@
<div id="grade-review"></div> <div id="grade-review"></div>
</section> </section>
<!-- Fallback photo picker for Regrade on a card saved before this feature
existed (or otherwise missing its stored photo) — regradeTargetId in
app.js tracks which row this fires for. -->
<input id="regrade-file" type="file" accept="image/*" multiple hidden>
<!-- ----------------------------------------------------------- history --> <!-- ----------------------------------------------------------- history -->
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">

139
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. it told you, so you can look back at a card without re-running the estimate.
""" """
import base64
import json import json
import os import os
import sqlite3 import sqlite3
@ -42,7 +43,10 @@ CREATE TABLE IF NOT EXISTS grades (
limitations_json TEXT, limitations_json TEXT,
note TEXT, note TEXT,
estimated_cost REAL, 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); CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC);
@ -70,8 +74,12 @@ def init():
# so a column added after cards were already graded needs its own # so a column added after cards were already graded needs its own
# migration — guarded because re-running this against a database # migration — guarded because re-running this against a database
# that already has the column would otherwise error every startup. # that already has the column would otherwise error every startup.
for statement in (
"ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT",
"ALTER TABLE grades ADD COLUMN source_images_json TEXT",
):
try: try:
conn.execute("ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT") conn.execute(statement)
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
for key, value in DEFAULT_SETTINGS.items(): for key, value in DEFAULT_SETTINGS.items():
@ -126,8 +134,35 @@ def save_settings(updates):
# ------------------------------------------------------------------ grades # ------------------------------------------------------------------ grades
def save_grade(grade, thumbnail=None, label=None): def _encode_images(images):
"""Persist one grading result. Returns the new row's id.""" """(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() conn = connect()
try: try:
cur = conn.execute( cur = conn.execute(
@ -137,8 +172,9 @@ def save_grade(grade, thumbnail=None, label=None):
" grade_low, grade_high, confidence, categories_json, " " grade_low, grade_high, confidence, categories_json, "
" edge_measurements_json, centering_measurement_json, " " edge_measurements_json, centering_measurement_json, "
" aspect_measurement_json, " " aspect_measurement_json, "
" limitations_json, note, estimated_cost, usage_json) " " limitations_json, note, estimated_cost, usage_json, "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", " source_images_json) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
( (
now(), label, grade.get("card_type"), grade.get("card_note"), now(), label, grade.get("card_type"), grade.get("card_note"),
grade.get("image_count"), thumbnail, grade.get("image_count"), thumbnail,
@ -152,6 +188,7 @@ def save_grade(grade, thumbnail=None, label=None):
json.dumps(grade.get("limitations")), json.dumps(grade.get("limitations")),
grade.get("note"), grade.get("estimated_cost"), grade.get("note"), grade.get("estimated_cost"),
json.dumps(grade.get("usage")), json.dumps(grade.get("usage")),
_encode_images(source_images),
), ),
) )
conn.commit() conn.commit()
@ -160,6 +197,69 @@ def save_grade(grade, thumbnail=None, label=None):
conn.close() 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): def _row_to_grade(row):
d = dict(row) d = dict(row)
for key in ("categories_json", "edge_measurements_json", 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 d[out_key] = json.loads(raw) if raw else None
except (ValueError, TypeError): except (ValueError, TypeError):
d[out_key] = None 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 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): def list_grades(limit=200):
conn = connect() conn = connect()
try: try:
rows = conn.execute( 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,), (limit,),
).fetchall() ).fetchall()
finally: finally:
@ -189,7 +309,10 @@ def list_grades(limit=200):
def get_grade(grade_id): def get_grade(grade_id):
conn = connect() conn = connect()
try: 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: finally:
conn.close() conn.close()
return _row_to_grade(row) if row else None return _row_to_grade(row) if row else None