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.
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).
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.
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.
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.
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.
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.
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.