From e586bc92e71b3b61e8a22215ea82529489ca97ec Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sat, 22 Aug 2026 11:17:06 -0700 Subject: [PATCH] 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. --- app.py | 114 +++++++++++++++++++++++++++--------- static/app.js | 77 +++++++++++++++++++++++- static/index.html | 5 ++ store.py | 145 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 302 insertions(+), 39 deletions(-) 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}`} ${esc(g.confidence || '')} ${esc(fmtWhen(g.created_at))} - + + + + `).join(''); } @@ -380,6 +384,8 @@ function renderEntryModal(g) { + `; $('#entry-modal').hidden = false; @@ -391,6 +397,64 @@ function closeEntryModal() { $('#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) => { const del = e.target.closest('[data-history-delete]'); if (del) { @@ -399,6 +463,12 @@ $('#history-body').addEventListener('click', async (e) => { await loadHistory(); 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]'); if (row) { try { @@ -419,6 +489,11 @@ $('#entry-modal').addEventListener('click', async (e) => { await loadHistory(); 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')) { const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete; const label = $('#entry-label').value.trim(); diff --git a/static/index.html b/static/index.html index 431deac..8692748 100644 --- a/static/index.html +++ b/static/index.html @@ -61,6 +61,11 @@
+ + +
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