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