173 lines
10 KiB
Markdown
173 lines
10 KiB
Markdown
# Card Grader
|
|
|
|
Self-hosted web app that estimates PSA trading card grades from photos, using
|
|
vision-capable LLMs (Claude and/or GPT) as the grading engine. Runs as a
|
|
single Docker container on the user's Unraid home server, exposed at
|
|
`hippofam.com/cards` through Nginx Proxy Manager.
|
|
|
|
No framework: the whole backend is Python stdlib
|
|
(`http.server.ThreadingHTTPServer`) plus `sqlite3`. Optional third-party
|
|
packages are `anthropic`, `openai`, and `Pillow` (image processing only —
|
|
never required for the server to boot).
|
|
|
|
## Architecture
|
|
|
|
- **`app.py`** — HTTP routing/handlers. Reads Basic Auth username forwarded
|
|
by the reverse proxy for *attribution only* (who graded what), never for
|
|
access control — auth itself is enforced by Nginx Proxy Manager in front
|
|
of the container via htpasswd. Admin-only settings are gated by
|
|
`ADMIN_USERNAME` (`CARD_GRADER_ADMIN_USER` env var). Request bodies are
|
|
capped (`MAX_BODY_BYTES`); oversized/malformed bodies get a 413 and the
|
|
connection is closed rather than trusting a client-supplied length.
|
|
- **`vision.py`** — Provider abstraction over Anthropic and OpenAI vision
|
|
APIs (`_call_anthropic` / `_call_openai`, dispatched from a common
|
|
entrypoint). `MODELS` holds per-model pricing/capabilities.
|
|
`GRADING_SYSTEM` is the large system prompt encoding PSA's actual grading
|
|
rubric (centering tolerances, corner/edge/surface criteria, qualifiers —
|
|
MC/OC/PD/ST/MK, half-grades, Altered Authentic) — verified against
|
|
psacard.com, beckett.com, and cgccards.com, not guessed. Self-reported
|
|
"high" confidence is capped to "medium" when neither centering nor edge
|
|
whitening was independently measurable from the image geometry.
|
|
- **`cardimage.py`** — Pillow-based image pipeline: card boundary detection,
|
|
corner/edge/surface crop generation, centering/edge-whitening/aspect-ratio
|
|
measurement from pixels (not just left to the model's eye). Includes
|
|
holder/toploader-aware detection (`detect_card_box`): a combinatorial
|
|
search over candidate edge positions constrained to standard card aspect
|
|
ratios, so the app can locate the actual card inside a slab/toploader
|
|
rather than just detecting the holder's outline. When a holder is
|
|
detected, edge-whitening measurement is refused (the holder's own inner
|
|
edge corrupts that specific measurement) but centering still runs
|
|
(border-width geometry, unaffected by clear plastic), and aspect-ratio
|
|
measurement deliberately uses the *unrefined* box to avoid circularity
|
|
(refinement selects for standard-ratio rectangles, so measuring the
|
|
refined box's ratio would trivially always look "normal").
|
|
- **`store.py`** — SQLite (WAL mode) persistence. `grades` and
|
|
`grade_events` tables, JSON columns for structured data, schema
|
|
migrations via idempotent `ALTER TABLE` in `store.init()`. List/detail
|
|
API responses explicitly exclude `source_images_json` to keep payloads
|
|
small. DB path overridable via `CARD_GRADER_DB_PATH`.
|
|
- **`static/`** — Vanilla JS/CSS/HTML frontend, PWA-enabled (manifest,
|
|
service worker). "PSA slab" visual theme. `__BASE__` is templated at
|
|
request time so the app works correctly when served from a sub-path
|
|
(`/cards`) behind the proxy.
|
|
|
|
## Local development
|
|
|
|
```bash
|
|
python3 app.py
|
|
```
|
|
|
|
Needs `ANTHROPIC_API_KEY` and/or `OPENAI_API_KEY` in the environment to
|
|
actually grade; the server itself has no required third-party deps. A local
|
|
`grades.db` is gitignored and is scratch/test data only — it is **not** the
|
|
production database (that lives on the server, see below).
|
|
|
|
## Deployment
|
|
|
|
**Target:** Unraid server, container path `/mnt/user/appdata/card-grader`,
|
|
built from `Dockerfile` / `docker-compose.yml` in this repo. Non-root
|
|
container user (`99:100` / `nobody:users`), healthcheck, log rotation,
|
|
`init: true`. Reverse-proxied by Nginx Proxy Manager at `hippofam.com/cards`
|
|
with per-user HTTP Basic Auth.
|
|
|
|
**Current deploy mechanism (live as of 2026-08-25): Forgejo push-to-deploy.**
|
|
Self-hosted Forgejo (soft-fork of Gitea) runs as its own container on the
|
|
same Unraid box (`/mnt/user/appdata/forgejo`), proxied at
|
|
`https://git.hippofam.com` via Nginx Proxy Manager (wildcard Let's Encrypt
|
|
cert issued via Porkbun DNS-01 challenge — Cox blocks inbound port 80, so
|
|
the default HTTP-01 challenge doesn't work on this network; DNS challenge
|
|
sidesteps that entirely). Forgejo Actions is enabled instance-wide, with a
|
|
self-hosted runner (`/mnt/user/appdata/forgejo-runner`, container name
|
|
`forgejo-runner`) registered **globally** (not repo-scoped — see tradeoff
|
|
below).
|
|
|
|
This repo has a `forgejo` remote (`https://git.hippofam.com/ninja_hippo/card-grader.git`,
|
|
HTTPS not SSH — see note below) and pushing to `main` triggers
|
|
`.forgejo/workflows/deploy.yml`, which rsyncs the checkout into
|
|
`/mnt/user/appdata/card-grader` (excluding `.git`, `.env`, `data/` — those
|
|
stay server-side only) and runs `docker compose up -d --build`. Typical
|
|
deploy completes in well under a minute.
|
|
|
|
**Why HTTPS, not SSH, for git:** Forgejo's git-SSH listens on host port
|
|
`2222` (port 22 was already taken by Unraid's own sshd), but the router
|
|
only forwards 80/443 to this box — 2222 was never opened externally, and
|
|
setting that up requires the user's own router admin access, which Claude
|
|
doesn't have. Git auth instead uses a Forgejo access token stored in macOS
|
|
Keychain via `git config --global credential.helper osxkeychain` — pushing
|
|
just works, no per-push prompt.
|
|
|
|
**Runner internals, only useful if the pipeline breaks:** the runner's job
|
|
containers are the `catthehacker/ubuntu:act-latest` image (has git/Node
|
|
bundled for the `actions/checkout` JS action, but *not* rsync — installed
|
|
as an explicit workflow step each run) with the host Docker socket
|
|
automounted (`container.docker_host: automount` in the runner's
|
|
`config.yaml`) and `/mnt/user/appdata` bind-mounted into every job
|
|
container at the identical path (`container.options`), which also required
|
|
adding `/mnt/user/appdata` to `container.valid_volumes` (empty by default —
|
|
Forgejo silently drops any bind mount not explicitly allow-listed there,
|
|
with only a debug-log line, no visible job failure). The identical-path
|
|
mount trick matters because `docker compose`'s relative volume paths are
|
|
resolved by the *host* daemon (Docker-outside-of-Docker via the automounted
|
|
socket), so a mismatched path silently bind-mounts the wrong location.
|
|
|
|
**Known tradeoff, accepted, not yet resolved:** the runner is registered
|
|
globally rather than scoped per-repo, and it has full Docker socket access
|
|
— a workflow file pushed to *any* repo on this Forgejo instance can control
|
|
any container on the box, not just its own. The access boundary is "who
|
|
can push a workflow file to a repo on this instance," not anything
|
|
narrower. This runner also now serves three other projects on the same
|
|
box — `lunch-notifier`, `n64-tracker`, `arr-summary` — each with the
|
|
identical deploy pattern (see their own `CLAUDE.md`s). Revisit with a
|
|
`docker-socket-proxy` (scoped per compose project) if this needs tightening
|
|
later; not done yet because the basic pipeline mattered more first.
|
|
|
|
**Superseded, fully torn down: git-shell restricted deploy.** A
|
|
`carddeploy` service account (login shell `git-shell`, one bare repo, a
|
|
`post-receive` hook triggering a single root-owned deploy script via one
|
|
narrowly-scoped `sudo` rule) was built, tested, and verified working
|
|
end-to-end — then explicitly torn down in favor of Forgejo. Nothing from it
|
|
remains: account, sudoers rule, bare repo, `/boot/config/go` persistence
|
|
block, and the `sshd_config` `AllowUsers` addition were all removed and
|
|
verified reverted. If this pattern (restricted git-shell account +
|
|
sudo-scoped deploy script) is useful as a reference for scoping *other*
|
|
access requests later, it's a solid model — abandoned for tooling reasons
|
|
(moving to a real git host), not because it didn't work.
|
|
|
|
All server access for this migration was narrowly scoped and time-boxed: a
|
|
temporary root SSH key was generated solely to do the Forgejo/runner/NPM
|
|
setup work above, then removed from the server's `authorized_keys` and
|
|
deleted locally once the pipeline was verified end-to-end working — checked
|
|
by confirming the key no longer authenticates. Treat that as the standing
|
|
expectation for any future server-access ask on this project — default to
|
|
the smallest scope that does the job, time-box it, and confirm
|
|
teardown/persistence explicitly rather than leaving it open-ended.
|
|
|
|
## Conventions and constraints established for this project
|
|
|
|
- PSA grading rules must be verified against a real source (PSA's own site,
|
|
Beckett, CGC) before being encoded into `GRADING_SYSTEM` — this rubric has
|
|
had real accuracy bugs (e.g. a self-contradictory centering tolerance, a
|
|
missing 5% front-centering leeway rule for grades 7+) found and fixed
|
|
through actual verification, not assumption.
|
|
- The vision prompt's `cannot_assess` escape hatch is deliberately narrow:
|
|
surface condition genuinely can't be separated from dust/scratches on a
|
|
toploader/slab's plastic, so it keeps the escape hatch. Corner and edge
|
|
geometry (sharpness, whitening, chipping) **does** read through clear
|
|
plastic and must be judged normally — an earlier prompt version gave the
|
|
model an escape hatch for corners/edges too, which caused false
|
|
"cannot tell from photo" results on cards that were plainly visible
|
|
through the holder. Don't reintroduce that.
|
|
- No pre-flight/"precheck" step before a paid grading call. This was tried
|
|
and explicitly rejected by the user — their workflow is screenshots that
|
|
can't be pulled out of the toploader, so a pre-check step doesn't fit and
|
|
was reverted.
|
|
- No browser automation (Playwright or otherwise) to scrape eBay listings.
|
|
Tried a plain `curl`-based fetch once (not Playwright) and got an
|
|
immediate 403; iterating on headers/fingerprinting to get past that would
|
|
be bot-detection evasion regardless of which tool performs it, and is
|
|
out of scope. This feature is paused; if revisited, the legitimate path
|
|
is a client-side bookmarklet/extension that uses the *user's own*
|
|
authenticated browser session rather than a server-side fetch.
|
|
- Never trust the reverse proxy's forwarded auth header for anything beyond
|
|
attribution (whose name to log against a grade) — access control is the
|
|
proxy's job, not the app's.
|