Commit graph

32 commits

Author SHA1 Message Date
Barely Removable
3510f637ba Stop telling the model to give up on corners/edges just for being in a holder
The framing caveat added earlier told the model to find the card's real
edge inside each mis-cut crop, then immediately offered 'say cannot_assess
if the close-up doesn't clearly contain it' — wrapped in alarming language
about cases. On a Fleer Ultra Jordan shot in a clear toploader it took the
escape hatch for corners AND edges, returning three of four categories as
'can't tell from photo' on a card whose corners are plainly visible through
the plastic. That was a regression I introduced, not a limit of the photo.

A toploader is transparent: corner sharpness, corner whitening, edge
chipping and edge whitening all read through it, and the card's cut line is
visible as a distinct boundary inside the holder's (confirmed in the pixel
profile — the card's own edge shows as a clear step ~75px inside the
detected holder boundary). Those categories now get judged normally, with
cannot_assess reserved for genuinely hidden geometry.

Surface keeps the escape hatch, because there it's correct: scratches, dust
and glare on a holder sit directly over the card's surface and can't be
separated from it in a photo.
2026-08-25 08:30:39 -07:00
Barely Removable
811c16f334 Correct PSA 10 centering tolerance; encode the 5% leeway rule properly
Verified the encoded rubric against psacard.com/gradingstandards directly
(the page 403s automated fetchers, so read via a real browser session,
clicking through all 14 grade-definition slides).

The app said '55/45 to 60/40 allows a 10'. PSA publishes a flat 55/45 for
a 10 -- and the app's very next line already said 60/40 allows a 9, so the
range was internally contradictory as well as over-lenient: a 58/42 card
would read as 10-eligible when PSA would cap it at 9.

The range appears to have been a garbled memory of a real rule the app
otherwise didn't encode at all: PSA grants a 5% leeway on front centering
minimums for cards grading 7 or better. The old text applied that leeway to
the 10 only and to no other grade. Now the published tolerances stand as
written and the leeway is stated once, for the grades it actually covers,
as grader discretion keyed to eye appeal rather than a hard second
tolerance -- so a borderline card gets a range instead of a confident
single grade.

Everything else checked out exactly: 9=60/40, 8=65/35, 7=70/30, 6=80/20,
5/4=85/15, 3/2=90/10, and back 75/25 for a 10 / 90/10 from 9 down.
PSA's 'Altered Authentic' designation also confirms the trimming guidance
added earlier (alterations -> no numeric grade).
2026-08-25 08:19:00 -07:00
Barely Removable
0cbeb26665 Audit round 2: aspect measured the case too; defensive image decode; own-key state reset
4. aspect_profile had the same root cause as edges/centering but no guard.
   On the cased photo it reported 5.8% off standard -- the CASE's
   proportions -- and the UI would surface that as a trimming hint about a
   card it never measured. This one is more directly load-bearing than the
   other two, since the measurement IS the detected box. Now refuses, with
   both consumers (prompt block and UI hint) checking reliable.

5. _decode_images could KeyError/raise out of a plain read on a malformed
   row, and separately b64decode returns b'' rather than raising on some
   corrupt input -- which would have fed a zero-byte 'photo' into grading
   to fail confusingly deep in the pipeline. Both now degrade to the
   existing re-pick path.

6. _last_used_own_key is instance state on a handler that serves every
   request on a keep-alive connection, so it outlives the request that set
   it. Currently safe (every logging path assigns first), but a future path
   that logged without reaching the assignment would bill the previous
   request's payer. Cleared up-front now.

Full regression suite re-run: die-cut exclusion, uniform cards, centering,
aspect, cased-photo refusals, and the store layer all still behave.
2026-08-25 08:05:33 -07:00
Barely Removable
6c612c1f4a Audit fixes: mis-framed crops, GPT cost 2.2x low, camera button losing photos
1. detail_crops had no equivalent of the margin guard the measurements got.
   When box detection fails (card still in a case), every crop is cut
   relative to the wrong rectangle -- verified the 'TOP-LEFT CORNER'
   close-up of the cased Ohtani is actually the CASE's corner bracket. The
   measurements refuse and explain; the crops kept being produced and
   captioned authoritatively, and the prompt tells the model to judge
   corners/edges/surface *from* them. Now surfaces a framing caveat telling
   the model to locate the real card edge inside each crop and say
   cannot_assess rather than grade the holder.

2. gpt-5.6-sol's approx_image_tokens was a pre-launch guess (1500) that
   advertised ~2.2x under true cost across all 9 real calls. Recalibrated
   to 3560 against median real usage, and added a per-model output estimate
   since GPT writes ~1.2k tokens of verdict vs Sonnet's ~0.9k. Both models
   now advertise within ~3% of observed cost.

3. 'Take photo' didn't reset after a completed grade, unlike 'Choose from
   library'. The result view has no photo strip, so new photos piled up
   invisibly behind the old verdict, silently, to the 6-photo cap. Also
   fixed the cap itself being a silent no-op with no explanation.
2026-08-25 08:00:48 -07:00
Barely Removable
deabe74593 Replace History-row authenticity dot with a visible badge
A 8px dot next to the card name only rewards someone who already suspects
a card and goes looking for it -- the badge needs to be the thing that
catches your eye scanning the home-screen list itself. Same red/amber
tiers as the detail-view banner, using the existing .pill shape so it sits
naturally next to the photo-count meta line rather than needing new
layout.
2026-08-23 11:55:29 -07:00
Barely Removable
3cacf2f292 Cap self-reported 'high' confidence when neither centering nor edge whitening was actually measured
Confidence was entirely the model's own self-report, based only on photo
sharpness and category coverage -- it had no reliable way to account for
whether centering/edge whitening came from a real pixel measurement or from
its own eye, even though the app treats that distinction as a big deal
everywhere else (it's the whole reason cardimage.py exists). A sharp,
well-lit photo of a foil or die-cut card could claim 'high' while actually
running on eye-only judgment for two of PSA's four categories.

Enforced in code rather than left to the prompt alone -- this is an
objective, checkable fact (did centering_profile/edge_wear_profile return a
reliable result or not), exactly the kind of thing that shouldn't depend on
the model correctly weighing one more instruction among many. Only caps
high->medium when NEITHER measurement is available; one of two still
measured is left to the model's own judgment, since that's a real
legitimate basis for confidence. Also updated the prompt itself so the
model's own reasoning stays consistent with what gets displayed, rather
than silently contradicting its own prose.

Tested all five branches (neither/both/one/one-unreliable measured, and
confirmed no spurious limitation text when the model already said
medium).
2026-08-23 11:43:36 -07:00
Barely Removable
2b3bbaf40f Add a structured authenticity signal with a distinct banner, instead of burying it in card_note
The Charizard grade that prompted this had the model writing 'possible
non-standard/proxy print' directly into card_note -- the only free-text
field available -- since there was nowhere else for that observation to go.
That's why it got no visual treatment: the UI renders card_note as a plain
grey subtitle, identical to any ordinary card name.

Added authenticity as its own top-level field (flag: none/worth_checking/
likely_not_genuine, plus an observation) alongside the four grading
categories but explicitly separate from them -- this is about whether the
card is a genuine product at all, not its condition, and PSA authenticates
before it grades, so a flagged card's estimated_grade goes to null rather
than a number.

Prompted with concrete tells for both classes of concern: fan-made/proxy
prints (fictional sets, non-existent number combos, home-printer texture,
'proxy' watermarks) and counterfeits of real cards (colour/font/holo
mismatches against genuine copies). Trimming (already covered under CORNERS
from an earlier fix) now also sets this field when flagged, so the UI has
one place to check regardless of which specific issue triggered it.

UI: a full banner (not a hint line) above the slab -- critical red for
likely_not_genuine, warning amber for worth_checking -- plus a matching dot
next to the card name in History so it's visible without opening the card.
Both colours reuse --critical/--warning rather than the brand red, since
this is exactly the 'distrust this' signal those are already reserved for.

Migration tested against the live schema shape; existing rows get
authenticity=None and render with no banner, as before.
2026-08-23 08:19:58 -07:00
Barely Removable
d026cce91a Refuse edge/centering measurement when box detection found no real margin
A card photographed still inside a black display case exposed this: the
case filled the entire frame with no background anywhere, so
_detect_card_box returned a box at essentially zero margin from the photo's
own edges (0px left, 1px right on a 986px-wide photo). Every downstream
measurement band -- a few percent of the card's own short dimension, by
design, since real wear lives in the outermost sliver -- then sampled
entirely within the case's embossed plastic texture, never reaching the
actual card. That texture read as 98.9% edge whitening on two sides.

The per-edge material-consistency check added for the earlier die-cut fix
didn't catch this: the case is uniformly dark on all four sides at the
shallow sampling depth used, so no edge disagreed with the others -- the
false signal came from local texture noise within one uniform (wrong)
material, not a mismatch between materials. This needed a different,
earlier check: whether box detection could plausibly have found the real
card boundary at all, gated on margin as a fraction of the photo before any
per-edge analysis runs.

Also fixed a related bug in centering_profile's existing die-cut check: it
compared each side against the median of the other three and returned on
the FIRST hit, so a genuine 2-vs-2 split (both left and right reading the
case, both top and bottom reading the real card) blamed a single side and
never even examined whether the second was equally wrong. Replaced with a
best-single-exclusion search that correctly distinguishes a true one-side
outlier from an unexplainable split.

Verified against the actual photo that exposed this (card in a scalloped
black case) -- both now correctly refuse instead of measuring the case.
Regression-tested: normal photos with reasonable margin, and the earlier
die-cut single-outlier case, are both unaffected.
2026-08-23 08:05:51 -07:00
Barely Removable
034761e145 Add OpenAI (GPT-5.6 Sol) as a second vision provider; gate server settings to a named admin
vision.py now dispatches per-model to _call_anthropic or _call_openai --
same prompt, same schema, same cardimage.py measurements either way, only
the request/response shape differs. Confirmed the existing GRADING_SCHEMA
already satisfies OpenAI's strict-mode requirement (every property listed
in required, additionalProperties:false at every level) with no changes.

Settings gained a second axis: which server key applies now depends on the
selected model's provider, and friends' personal keys are stored per
provider (with a one-time migration from the old single-key localStorage
slot) since a Claude key and an OpenAI key aren't interchangeable.

CARD_GRADER_ADMIN_USER names one username (read from the proxy's forwarded
basic-auth header) who alone may write server settings; everyone else keeps
the same read-only view CARD_GRADER_LOCK used to give everyone, while still
being able to set their own personal key. Deployed here as ninja_hippo.
CARD_GRADER_LOCK remains the fallback when no admin is named.
2026-08-23 07:39:03 -07:00
Barely Removable
9d5c68b093 Track grading spend per user
Attribution comes from the proxy's basic-auth username, which nginx already
forwards. Recorded as a ledger of billed calls rather than a column on the
grade: a regrade overwrites the grade row, so a per-row cost would forget
what earlier runs cost, and deleting a card would erase that it was ever
paid for. Splits server-key spend (money the host actually owes) from
own-key spend (billed to the visitor) — the two must never be summed.
Attribution only, never authorization: the app is reachable directly on the
LAN, so these headers are not trustworthy for access control.
2026-08-22 17:17:14 -07:00
Barely Removable
762b394c8a Make Sonnet's expiring intro pricing date-aware
The table hardcoded $2/$10 with a comment noting it lapses 2026-08-31.
From Sept 1 that would have under-reported every grade by a third — both
the per-grade figure in the UI and the cost recorded against each result.
2026-08-22 13:10:08 -07:00
Barely Removable
602bce8ce3 Expire stored source photos after 7 days, keeping grades forever
Storing originals for Regrade grew the database ~16MB per grade at the
upload cap. Photos now expire on a configurable window (default 7 days,
counted from last grade so regrading resets it) and the file is VACUUMed so
the space is actually returned — clearing the column alone only moves pages
to the freelist, which would have made the whole feature a no-op on disk.
Grades, thumbnails and measurements are never pruned; a pruned card's
Regrade falls back to the existing re-pick path, now with a banner saying
why.
2026-08-22 13:07:09 -07:00
Barely Removable
633cb98462 Fix severity pill contrast — both failed WCAG AA on their own tinted background 2026-08-22 13:01:46 -07:00
Barely Removable
3270a9fe69 Harden: cap request body before reading, WAL for concurrent writes, socket timeout, surface unmeasured edges
- _body() read the full declared Content-Length into memory before any size
  check, so MAX_UPLOAD_BYTES could only reject an upload already held in
  RAM. nginx caps this on the proxied path, but the container also listens
  on the LAN, so the app now enforces its own 20MB ceiling and closes the
  connection rather than reading.
- SQLite ran with the default rollback journal and 5s lock timeout, chosen
  when a row was a few KB; rows now carry the original photos, so two
  people grading at once could block each other's History. WAL + 30s.
- No socket timeout meant a stalled keep-alive connection held a worker
  thread indefinitely.
- An edge excluded as a different material was dropped from the UI with no
  explanation, presenting three sides as though they were all four.
2026-08-22 12:56:58 -07:00
Barely Removable
f8db3df605 Full PSA palette: light silver ground, brand red accent, dark text 2026-08-22 12:41:53 -07:00
Barely Removable
2abb568c49 Slab label: promote range to the focal line when the card name is suppressed 2026-08-22 12:32:28 -07:00
Barely Removable
f910a603c3 PSA-slab theme: render the verdict as a slab label, matching grade chips in history 2026-08-22 12:31:15 -07:00
Barely Removable
3c98373ad6 Refine slab mark proportions; stack header+banner instead of magic top offset 2026-08-22 12:26:11 -07:00
Barely Removable
527558bef2 UI: slab brand mark replacing checkmark, header aligned to page column, flatter controls 2026-08-22 12:20:56 -07:00
Barely Removable
fa5467e55d Refuse centering measurement on die-cut/clear-window cards
Same failure family as the edge-whitening fix: centering_profile assumed a
uniform printed border, so on an E-X/SPx-style die-cut the side midpoint
samples landed on clear acetate and the border-width walk measured two
different materials, producing a confident 70/30 the model then treated as
authoritative. Now the four side samples must read as the same material or
the measurement refuses with the reason; the prompt tells the model to judge
die-cut centering as design-to-cut registration instead.
2026-08-22 11:34:36 -07:00
Barely Removable
bb5d50502c Docker hardening (non-root, healthcheck, .dockerignore, log caps) + UI table/typography polish 2026-08-22 11:25:52 -07:00
Barely Removable
e586bc92e7 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.
2026-08-22 11:17:06 -07:00
Barely Removable
552f862f4c Fix false edge-whitening on die-cut/mixed-material cards (e.g. SPx clear acetate window)
The pooled baseline that scores edge whitening was global across all four
edges, so a card that's normal printed border on three sides and clear
acetate on the fourth had the three normal edges anchor a baseline that made
the clear side read as extreme whitening -- correctly detecting a real pixel
difference, just the wrong one. Now excludes an edge from both the pool and
its own scoring when its median brightness/saturation reads as a
fundamentally different material, and surfaces why in the prompt instead of
silently dropping it.
2026-08-22 11:09:44 -07:00
Barely Removable
f464faca43 Add aspect-ratio measurement as a soft trimming signal, reported against every standard card size 2026-08-22 11:03:54 -07:00
Barely Removable
b28305dd25 Grading: distinguish miscut/trimming from ordinary corner wear; fix surface over-hedging and over-calling 2026-08-22 09:59:47 -07:00
Barely Removable
5ef97ad4ad Drop duplicated card name from the slab inside the history modal 2026-08-22 09:51:30 -07:00
Barely Removable
cc104e7e35 Pin modal close button top-right when the card name wraps 2026-08-22 09:49:53 -07:00
Barely Removable
7f62e4ad7c Redesign: collector-grade dark theme, slab promoted to hero element 2026-08-22 09:48:26 -07:00
Barely Removable
27a6b49b37 Surface install-prompt failures instead of failing silently 2026-08-22 09:40:43 -07:00
Barely Removable
6b247794db Add a dedicated Take Photo button alongside the library picker 2026-08-22 09:32:10 -07:00
Barely Removable
e74bcee35f Document actual Unraid/NPM deployment topology in docker-compose.yml 2026-08-22 09:23:15 -07:00
Barely Removable
c7bd71a3e1 Initial commit: Card Grader deployed to hippofam.com/cards
PWA card-grading app, deployed behind Nginx Proxy Manager on Unraid with
basic auth. Includes CARD_GRADER_BASE_PATH support for running under a
sub-path, and Docker/compose config for the Unraid deployment.
2026-08-22 09:22:41 -07:00