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."""
|
||||
|
|
|
|||
|
|
@ -363,7 +363,11 @@ function renderHistory() {
|
|||
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(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('');
|
||||
}
|
||||
|
||||
|
|
@ -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">
|
||||
<button class="btn btn-quiet btn-sm" id="entry-save-label">Save name</button>
|
||||
<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>
|
||||
</div>`;
|
||||
$('#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();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@
|
|||
<div id="grade-review"></div>
|
||||
</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 -->
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
|
|
|
|||
145
store.py
145
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue