diff --git a/app.py b/app.py index 793a37d..f6cea30 100644 --- a/app.py +++ b/app.py @@ -453,6 +453,7 @@ class Handler(BaseHTTPRequestHandler): "closeups": result["closeups"], "card_type": result["card_type"], "card_note": result["card_note"], + "authenticity": result["authenticity"], "edge_measurements": result["edge_measurements"], "centering_measurement": result["centering_measurement"], "aspect_measurement": result["aspect_measurement"], diff --git a/static/app.js b/static/app.js index 53010db..522d18e 100644 --- a/static/app.js +++ b/static/app.js @@ -182,7 +182,16 @@ function renderGradeBlock(g, opts = {}) { `; }).join(''); + const auth = g.authenticity || null; + const authBanner = (auth && auth.flag !== 'none') ? ` +
+
${auth.flag === 'likely_not_genuine' + ? 'Possibly not a genuine card' : 'Worth double-checking'}
+ ${auth.observation ? `
${esc(auth.observation)}
` : ''} +
` : ''; + return ` + ${authBanner}
A photo can't show surface scratches, print lines, or light edge wear the way a grader's raking light does — and a seller's listing photo is often lit to hide them. Treat this as a rough screen, not a prediction of what it comes back as.
@@ -398,13 +407,21 @@ function renderHistory() { const rows = state.history; $('#history-count').textContent = rows.length ? `${rows.length} graded` : ''; $('#history-empty').hidden = rows.length > 0; - $('#history-body').innerHTML = rows.map((g) => ` + $('#history-body').innerHTML = rows.map((g) => { + const auth = g.authenticity; + // A dot rather than repeating the full banner text — the row is + // already tight, and opening the card gives the real explanation. + const authDot = (auth && auth.flag !== 'none') + ? `` + : ''; + return `
${g.thumbnail ? `` : ''}
-
${esc(g.label || g.card_note || 'Untitled card')}
+
${authDot}${esc(g.label || g.card_note || 'Untitled card')}
${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)
@@ -420,7 +437,8 @@ function renderHistory() { - `).join(''); + `; + }).join(''); } /* ----------------------------------------------------------------- spend */ diff --git a/static/style.css b/static/style.css index b3f6ef7..a472f91 100644 --- a/static/style.css +++ b/static/style.css @@ -422,6 +422,43 @@ textarea { resize: vertical; min-height: 64px; } margin-top: 0; margin-bottom: 14px; } +/* --------------------------------------------------------- authenticity */ +/* A concern about whether the card is what it claims to be, not its + condition — deliberately styled as a full banner rather than a hint line + like every other measurement caveat, so it reads as "stop and look at + this" before anything about the grade itself. Two tiers, both using + --critical rather than --brand: this is exactly the "distrust this" + signal that colour is reserved for elsewhere in the app, and the brand + red would blur into decoration here of all places. */ +.authenticity-banner { + border: 1px solid color-mix(in srgb, var(--critical) 45%, transparent); + background: color-mix(in srgb, var(--critical) 10%, var(--surface-1)); + border-radius: 8px; + padding: 11px 14px; + margin-bottom: 14px; +} +.authenticity-check { + border-color: color-mix(in srgb, var(--warning) 55%, transparent); + background: color-mix(in srgb, var(--warning) 12%, var(--surface-1)); +} +.authenticity-title { + font-weight: 700; font-size: 13.5px; + color: var(--critical); +} +.authenticity-check .authenticity-title { color: var(--warning); } +.authenticity-detail { + font-size: 12.5px; color: var(--ink-2); margin-top: 3px; line-height: 1.5; +} + +/* History-row version: a plain dot, since the row has no room for the full + banner text and opening the card gives the real explanation. */ +.authenticity-dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + margin-right: 6px; vertical-align: middle; + background: var(--critical); +} +.authenticity-dot.authenticity-check { background: var(--warning); } + /* -------------------------------------------------------------- banner */ .banner { diff --git a/store.py b/store.py index 2098a00..7e0643a 100644 --- a/store.py +++ b/store.py @@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS grades ( grade_high INTEGER, confidence TEXT, categories_json TEXT, + authenticity_json TEXT, edge_measurements_json TEXT, centering_measurement_json TEXT, aspect_measurement_json TEXT, @@ -126,6 +127,7 @@ def init(): for statement in ( "ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT", "ALTER TABLE grades ADD COLUMN source_images_json TEXT", + "ALTER TABLE grades ADD COLUMN authenticity_json TEXT", ): try: conn.execute(statement) @@ -219,11 +221,12 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None): "(created_at, label, card_type, card_note, image_count, thumbnail, " " model, estimated_grade, " " grade_low, grade_high, confidence, categories_json, " + " authenticity_json, " " edge_measurements_json, centering_measurement_json, " " aspect_measurement_json, " " limitations_json, note, estimated_cost, usage_json, " " source_images_json) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( now(), label, grade.get("card_type"), grade.get("card_note"), grade.get("image_count"), thumbnail, @@ -231,6 +234,7 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None): grade.get("estimated_grade"), grade.get("grade_low"), grade.get("grade_high"), grade.get("confidence"), json.dumps(grade.get("categories")), + json.dumps(grade.get("authenticity")), json.dumps(grade.get("edge_measurements")), json.dumps(grade.get("centering_measurement")), json.dumps(grade.get("aspect_measurement")), @@ -264,6 +268,7 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None): grade.get("estimated_grade"), grade.get("grade_low"), grade.get("grade_high"), grade.get("confidence"), json.dumps(grade.get("categories")), + json.dumps(grade.get("authenticity")), json.dumps(grade.get("edge_measurements")), json.dumps(grade.get("centering_measurement")), json.dumps(grade.get("aspect_measurement")), @@ -274,7 +279,7 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None): sql = ( "UPDATE grades SET created_at=?, card_type=?, card_note=?, " "image_count=?, model=?, estimated_grade=?, grade_low=?, " - "grade_high=?, confidence=?, categories_json=?, " + "grade_high=?, confidence=?, categories_json=?, authenticity_json=?, " "edge_measurements_json=?, centering_measurement_json=?, " "aspect_measurement_json=?, limitations_json=?, note=?, " "estimated_cost=?, usage_json=?" @@ -404,7 +409,7 @@ def get_grade_images(grade_id): def _row_to_grade(row): d = dict(row) - for key in ("categories_json", "edge_measurements_json", + for key in ("categories_json", "authenticity_json", "edge_measurements_json", "centering_measurement_json", "aspect_measurement_json", "limitations_json", "usage_json"): out_key = key[:-len("_json")] @@ -429,9 +434,10 @@ def _row_to_grade(row): _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" + "categories_json, authenticity_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" ) diff --git a/vision.py b/vision.py index e93693c..f02dec0 100644 --- a/vision.py +++ b/vision.py @@ -521,6 +521,51 @@ the untouched crop too. On chrome and refractor stock, expect fine scratching; it is the norm rather \ than the exception, and its absence is what is notable. +AUTHENTICITY — separate from all four categories above, and separate from \ +condition entirely. A pristine card that isn't a genuine product doesn't get \ +a PSA grade at all; PSA authenticates before it grades, and a card that fails \ +that step never reaches the 1-10 scale regardless of how clean it looks. \ +Judge this from what's actually visible, the same discipline as everything \ +else here — flag it when you see a real, specific tell, not as a hedge. + Signs the card may not be an official print at all: a set, era, or \ +name/number combination that doesn't correspond to any real printing you \ +recognize; card text in a font, layout, or language inconsistent with the \ +publisher and era it's claiming to be; artwork that reads as fan-made or \ +AI-generated rather than the actual official illustration for that card; \ +visible printing quality far below what mass-produced card stock looks like \ +— soft/pixelated detail, visible inkjet or laser-printer banding, a paper \ +stock that looks like cardstock from a home printer rather than the glossy, \ +consistent stock real trading cards use; or the word "proxy," "custom," \ +"fan art," or similar printed anywhere on the card itself. + Signs a real card might be a counterfeit of a genuine printing: colour \ +saturation, font weight, or line quality that doesn't match known genuine \ +copies of that exact card; a holo or foil pattern that looks wrong for the \ +set (wrong pattern entirely, or applied where the real printing doesn't have \ +one); a cardback design, layout, or copyright text that doesn't match the \ +genuine card. Weigh these against your own uncertainty — you're comparing \ +against a memory of what genuine copies look like, not a reference image, so \ +reserve likely_not_genuine for cases where something is clearly, specifically \ +wrong, not just "this looks a little different than I'd expect." + TRIMMING is the physical-alteration version of this same concern, already \ +covered under CORNERS above — a genuine card that's been cut down to fake \ +sharper edges than it actually has. When you flag trimming there, also set \ +this field (worth_checking is usually right, since a photo can't confirm it \ +the way calipers can) so it surfaces in one place rather than only inside a \ +corners observation. + flag "none" is the ordinary case and should be the overwhelming majority \ +of cards — most cards people photograph for grading are exactly what they \ +appear to be, and reaching for suspicion without a specific tell is its own \ +kind of dishonesty, the same failure as inventing wear that isn't there. Use \ +"worth_checking" when you see something specific but can't be confident from \ +a photo alone — say what you saw. Use "likely_not_genuine" only when you're \ +reasonably confident, not just uneasy — a fictional set name, visible \ +"proxy" text, or artwork that plainly isn't the real official print are the \ +kind of thing that earns this level; a card that's merely unfamiliar to you \ +does not. + If you flag likely_not_genuine, say so in note and reflect it in the grade: \ +estimated_grade should be null (this isn't a card PSA would assign a number \ +to at all), with the reasoning in note rather than in a numeric range. + HOW PSA COMBINES THE FOUR The grade is capped by the WORST attribute, not averaged across them. Three \ @@ -670,6 +715,25 @@ GRADE_CATEGORY_SCHEMA = { "additionalProperties": False, } +AUTHENTICITY_SCHEMA = { + "type": "object", + "properties": { + "flag": { + "type": "string", + "enum": ["none", "worth_checking", "likely_not_genuine"], + "description": "Whether anything about this card's legitimacy, " + "not its condition, is in question.", + }, + "observation": { + "type": "string", + "description": "The specific thing that prompted the flag — " + "empty string when flag is 'none'.", + }, + }, + "required": ["flag", "observation"], + "additionalProperties": False, +} + GRADING_SCHEMA = { "type": "object", "properties": { @@ -682,6 +746,7 @@ GRADING_SCHEMA = { "type": "string", "description": "One line naming the card if legible (player/name, set, year) — for the history list.", }, + "authenticity": AUTHENTICITY_SCHEMA, "estimated_grade": { "type": ["integer", "null"], "description": "Best single estimate, whole number 1-10, or null if ungradeable from these photos.", @@ -699,9 +764,9 @@ GRADING_SCHEMA = { }, "note": {"type": "string", "description": "Short overall summary."}, }, - "required": ["card_type", "card_note", "estimated_grade", "grade_low", - "grade_high", "confidence", "centering", "corners", "edges", - "surface", "limitations", "note"], + "required": ["card_type", "card_note", "authenticity", "estimated_grade", + "grade_low", "grade_high", "confidence", "centering", "corners", + "edges", "surface", "limitations", "note"], "additionalProperties": False, } @@ -720,6 +785,14 @@ def _clean_category(raw): return {"severity": severity, "observation": raw.get("observation") or ""} +def _clean_authenticity(raw): + raw = raw if isinstance(raw, dict) else {} + flag = raw.get("flag") + if flag not in ("none", "worth_checking", "likely_not_genuine"): + flag = "none" + return {"flag": flag, "observation": raw.get("observation") or ""} + + def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True): """Estimate the PSA grade a card would likely receive, from photo(s). @@ -924,6 +997,7 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) "closeups": len(crops), "card_type": card_type, "card_note": (parsed.get("card_note") or "").strip(), + "authenticity": _clean_authenticity(parsed.get("authenticity")), "edge_measurements": measured, "centering_measurement": centering, "aspect_measurement": aspect,