From ec5ca4e46a36bfec51adf5c097bd0c7871fae413 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Fri, 15 May 2026 21:57:45 +0530 Subject: [PATCH 1/3] feat: tilt correction, centering ratios, v3 demo data - Card detection returns 4 corner points, calculates tilt angle, rotates to straighten - Centering prompts now output lr/tb ratios (e.g. 55/45, 52/48) - gradeSubgrade passes through lr/tb fields to response - Demo data upgraded to v3 (8 subgrades, centering ratios on all 10 graded items) - 215 unit tests (tilt angle, straight card, extreme tilt cap) --- .claude/commands/practices.md | 13 ++++--- lib/cards/demo.js | 20 +++++------ lib/grading/grading.js | 17 +++++---- lib/grading/preprocessing.js | 66 +++++++++++++++++++++++++++-------- test/unit-test.js | 37 +++++++++++++++++--- 5 files changed, 113 insertions(+), 40 deletions(-) diff --git a/.claude/commands/practices.md b/.claude/commands/practices.md index d1d073f..b7ed788 100644 --- a/.claude/commands/practices.md +++ b/.claude/commands/practices.md @@ -47,7 +47,9 @@ raw = (frontOverall * 0.60) + (backOverall * 0.40) overall = roundGrade(min(raw, lowestSubgrade + 1)) ``` -- Card detection uses Sonnet (reliable vision), subgrades use configured model +- Card detection uses Together AI (GLM-4.6V-Flash) when `TOGETHER_API_KEY` set, falls back to Claude Sonnet +- Subgrades use configured model (default Claude Sonnet) +- SSRF protection: `validateImageUrl()` in preprocessing.js — DNS resolution, private IP blocking, blocked hosts - `gradeSubgrade` receives pre-built image blocks (not URLs) — use `imageBlockFromUrl()` or `imageBlockFromBase64()` - `cropCorners()` accepts URL or Buffer - Back-only subgrades skipped when no back image — front score substituted @@ -73,7 +75,7 @@ overall = roundGrade(min(raw, lowestSubgrade + 1)) ```javascript if (req.query.demo === "true") { - // return canned data from lib/data/demo.js + // return canned data from lib/cards/demo.js return res.json({ ...demoData, _demo: true }); } // ... live data path @@ -92,7 +94,7 @@ if (req.query.demo === "true") { ## Testing pattern -Unit tests (test/unit-test.js, ~172 tests): +Unit tests (test/unit-test.js, ~212 tests): ```javascript test("descriptive name", () => { eq(actualValue, expectedValue); @@ -100,7 +102,7 @@ test("descriptive name", () => { ``` - Sync test harness, no async support (use dynamic import for modules needing env setup) - Group with `console.log("\n\x1b[1m=== section ===\x1b[0m")` -- Sections: parseGradeJSON, buildEbaySearchQuery, detectLanguage, tokenizeQuery, extractPokemonName, normalizeListingLanguage, parseListingLanguagesFromInput, filterByLanguage, titleLooksGradedSlab, titleMatchesSlabListing, parseSellerSlabFromConditionText, filterByListingFormat, filterRelevantResults, querySeeksJapaneseMarket, filterToLikelyTcgCards, demo data, detectCondition, filterByCondition, flagPriceOutliers, parseCardIdentity, resolveCardIdToQuery, findDemoByNumber, demo multi-source + sold dates, cornerCropsToImageBlocks, demo image resolution, demo grade confidence, alert email, portfolio, portfolio history + gainers/losers, csvEscape + csvRow, isGradedCard, card database, price trend, JWT auth, findCardByCardId, roundGrade, image block helpers, subgrade prompt keys, computePriceTrend edge cases +- Sections: parseGradeJSON, buildEbaySearchQuery, detectLanguage, tokenizeQuery, extractPokemonName, normalizeListingLanguage, parseListingLanguagesFromInput, filterByLanguage, titleLooksGradedSlab, titleMatchesSlabListing, parseSellerSlabFromConditionText, filterByListingFormat, filterRelevantResults, querySeeksJapaneseMarket, filterToLikelyTcgCards, demo data, detectCondition, filterByCondition, flagPriceOutliers, parseCardIdentity, resolveCardIdToQuery, findDemoByNumber, demo multi-source + sold dates, cornerCropsToImageBlocks, demo image resolution, demo grade confidence, alert email, portfolio, portfolio history + gainers/losers, csvEscape + csvRow, isGradedCard, card database, price trend, JWT auth, findCardByCardId, roundGrade, image block helpers, subgrade prompt keys, computePriceTrend edge cases, API response parsing (mock), card detection bounds, validateAndShape, buildSignal, deriveEra, v3 formula edge cases API tests (test/api-test.js, ~130 tests): ```javascript @@ -111,6 +113,9 @@ await test("GET /api/endpoint returns expected", async () => { ``` - `json()` for auth'd requests, `jsonNoAuth()` for public - Auth tests accept both success and 401 (local dev disables auth) +- Mock pattern: extract response parsers as pure exported functions, test with sample payloads (no framework needed) + - `parseAnthropicResponse(data)` / `parseTogetherResponse(data)` in preprocessing.js + - `validateAndShape(provider, mode, o, raw)`, `buildSignal({...})`, `deriveEra(setCode)` — exported for testing - Sections: health, drops, webhooks, comps, search, sold, psa, grade, auth, admin keys, condition, card, arbitrage, price-history, track-prices, errors, demo data, portfolio, portfolio/history, portfolio/export, grading-opportunities, card/view, set browser, price trend, collection tracking, google oauth, upload url, developer self-serve, analytics, autocomplete, set detail, grading dataset, grade validation ## Naming conventions diff --git a/lib/cards/demo.js b/lib/cards/demo.js index 78d19fe..f9122e3 100644 --- a/lib/cards/demo.js +++ b/lib/cards/demo.js @@ -14,7 +14,7 @@ const DEMO_CARDS = { condition: "A — Mint", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg", additionalImages: [{ imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/434c07ea-98b5-480d-baef-ff4dcae38e7e/980404.jpeg" }], - grade: { overall: 8, centering: 8, corners: 8, edges: 8, surface: 8, confidence: 0.55, mode: "llm-detailed", notes: "Grade limiter: centering — Front centering shows a noticeable shift with the left border appearing wider than the right, while top and bottom are fairly even; mild camera tilt slightly limits precision.", limitations: "", subgradeDetails: { centering: { score: 8, confidence: 0.6, detail: "Front centering shows a noticeable shift with the left border appearing wider than the right, while top and bottom are fairly even; mild camera tilt slightly limits precision." }, corners: { score: 8, confidence: 0.6, detail: "Minor whitening visible on the top-left and bottom-right corners against the rainbow holo border, but all corners retain sharp points." }, edges: { score: 8, confidence: 0.6, detail: "Minor whitening visible along the top and right edges of the front, with the dark holographic border making slight wear more apparent; bottom and left edges appear clean." }, surface: { score: 8, confidence: 0.4, detail: "Heavy holo glare and rainbow foil pattern obscure surface detail, but no obvious scratches or print lines visible; light wear possible on reflective areas limits confident higher grade." } } }, + grade: { overall: 7.5, frontOverall: 8, backOverall: 7.5, centering: 7, corners: 7, edges: 7.5, surface: 7.5, confidence: 0.55, mode: "llm-detailed-v3", notes: "Grade limiter: centering_back — Front centering shows a noticeable shift with the left border appearing wider than the right, while top and bottom are fairly even; mild camera tilt slightly limits precision.", limitations: "", subgradeDetails: { centering_front: { score: 8, confidence: 0.6, detail: "Front centering shows a noticeable shift with the left border appearing wider than the right, while top and bottom are fairly even; mild camera tilt slightly limits precision.", lr: "58/42", tb: "52/48" }, centering_back: { score: 7, confidence: 0.5, detail: "Back centering within tolerance.", lr: "55/45", tb: "52/48" }, corners_front: { score: 8, confidence: 0.6, detail: "Minor whitening visible on the top-left and bottom-right corners against the rainbow holo border, but all corners retain sharp points." }, corners_back: { score: 7, confidence: 0.5, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8, confidence: 0.6, detail: "Minor whitening visible along the top and right edges of the front, with the dark holographic border making slight wear more apparent; bottom and left edges appear clean." }, edges_back: { score: 7.5, confidence: 0.5, detail: "Light whitening on back edges." }, surface_front: { score: 8, confidence: 0.4, detail: "Heavy holo glare and rainbow foil pattern obscure surface detail, but no obvious scratches or print lines visible; light wear possible on reflective areas limits confident higher grade." }, surface_back: { score: 7.5, confidence: 0.3, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, { itemId: "01KR0F73BG7H5THN8HZCG7VWXJ", @@ -24,7 +24,7 @@ const DEMO_CARDS = { condition: "A — Mint", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/3ef68064-4269-44d7-b7fd-795cd293cb9c/4939452.jpeg", additionalImages: [{ imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/3492d757-4c5f-4a5c-838d-951afe1d860d/4939452.jpeg" }], - grade: { overall: 8, centering: 9, corners: 8, edges: 8.5, surface: 9, confidence: 0.53, mode: "llm-detailed", notes: "Grade limiter: corners — Slight whitening appears visible on the bottom-left and bottom-right corners of this dark-bordered card, while the top corners look cleaner; image resolution limits close inspection.", limitations: "", subgradeDetails: { centering: { score: 9, confidence: 0.6, detail: "Card shows slight perspective tilt, but borders appear close to even with a very slight shift toward the top compared to the bottom; left/right look balanced." }, corners: { score: 8, confidence: 0.4, detail: "Slight whitening appears visible on the bottom-left and bottom-right corners of this dark-bordered card, while the top corners look cleaner; image resolution limits close inspection." }, edges: { score: 8.5, confidence: 0.6, detail: "Edges appear clean overall with possible very minor whitening on the bottom edge of the back, while front edges look sharp under the case." }, surface: { score: 9, confidence: 0.5, detail: "Holo SAR surface appears clean with no obvious scratches or print lines, but heavy reflective glare across the textured foil limits confident detection of fine surface defects." } } }, + grade: { overall: 8, frontOverall: 8.5, backOverall: 8, centering: 8, corners: 7, edges: 8, surface: 8.5, confidence: 0.53, mode: "llm-detailed-v3", notes: "Grade limiter: corners_back — Slight whitening appears visible on the bottom-left and bottom-right corners of this dark-bordered card, while the top corners look cleaner; image resolution limits close inspection.", limitations: "", subgradeDetails: { centering_front: { score: 9, confidence: 0.6, detail: "Card shows slight perspective tilt, but borders appear close to even with a very slight shift toward the top compared to the bottom; left/right look balanced.", lr: "52/48", tb: "53/47" }, centering_back: { score: 8, confidence: 0.5, detail: "Back centering within tolerance.", lr: "54/46", tb: "51/49" }, corners_front: { score: 8, confidence: 0.4, detail: "Slight whitening appears visible on the bottom-left and bottom-right corners of this dark-bordered card, while the top corners look cleaner; image resolution limits close inspection." }, corners_back: { score: 7, confidence: 0.3, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8.5, confidence: 0.6, detail: "Edges appear clean overall with possible very minor whitening on the bottom edge of the back, while front edges look sharp under the case." }, edges_back: { score: 8, confidence: 0.5, detail: "Light whitening on back edges." }, surface_front: { score: 9, confidence: 0.5, detail: "Holo SAR surface appears clean with no obvious scratches or print lines, but heavy reflective glare across the textured foil limits confident detection of fine surface defects." }, surface_back: { score: 8.5, confidence: 0.4, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, { itemId: "01KR0XJ2VGEPX95JF0N7FEQC8T", @@ -37,7 +37,7 @@ const DEMO_CARDS = { { imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/9fe2f9fb-7c3c-458b-9362-d015078a20a7/8486557.jpeg" }, { imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/0fc1780a-c7a8-4903-a639-6de2c8f23f90/8486557.jpeg" }, ], - grade: { overall: 8, centering: 8, corners: 8, edges: 8, surface: 8.5, confidence: 0.45, mode: "llm-detailed", notes: "Grade limiter: centering — Card appears slightly tilted in photo, but front shows a noticeably wider left border than right, with top and bottom borders looking roughly even; angle limits precision.", limitations: "", subgradeDetails: { centering: { score: 8, confidence: 0.5, detail: "Card appears slightly tilted in photo, but front shows a noticeably wider left border than right, with top and bottom borders looking roughly even; angle limits precision." }, corners: { score: 8, confidence: 0.4, detail: "Minor whitening appears visible on the bottom-left and bottom-right corners of this dark-bordered card, though image resolution limits detailed assessment of all four corners." }, edges: { score: 8, confidence: 0.5, detail: "Minor whitening visible along the top and right edges of the front, with the other edges appearing clean; image resolution limits detailed inspection." }, surface: { score: 8.5, confidence: 0.4, detail: "Holographic SAR card with significant glare and rainbow foil reflections that obscure surface details; no obvious scratches, print lines, or contamination visible, but holo pattern and photo quality limit confident assessment." } } }, + grade: { overall: 8, frontOverall: 8, backOverall: 7.5, centering: 7, corners: 7, edges: 7.5, surface: 8, confidence: 0.45, mode: "llm-detailed-v3", notes: "Grade limiter: centering_back — Card appears slightly tilted in photo, but front shows a noticeably wider left border than right, with top and bottom borders looking roughly even; angle limits precision.", limitations: "", subgradeDetails: { centering_front: { score: 8, confidence: 0.5, detail: "Card appears slightly tilted in photo, but front shows a noticeably wider left border than right, with top and bottom borders looking roughly even; angle limits precision.", lr: "60/40", tb: "51/49" }, centering_back: { score: 7, confidence: 0.4, detail: "Back centering within tolerance.", lr: "58/42", tb: "53/47" }, corners_front: { score: 8, confidence: 0.4, detail: "Minor whitening appears visible on the bottom-left and bottom-right corners of this dark-bordered card, though image resolution limits detailed assessment of all four corners." }, corners_back: { score: 7, confidence: 0.3, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8, confidence: 0.5, detail: "Minor whitening visible along the top and right edges of the front, with the other edges appearing clean; image resolution limits detailed inspection." }, edges_back: { score: 7.5, confidence: 0.4, detail: "Light whitening on back edges." }, surface_front: { score: 8.5, confidence: 0.4, detail: "Holographic SAR card with significant glare and rainbow foil reflections that obscure surface details; no obvious scratches, print lines, or contamination visible, but holo pattern and photo quality limit confident assessment." }, surface_back: { score: 8, confidence: 0.3, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, { itemId: "01KR13CDR08H86QTVEHDRENNX2", @@ -50,7 +50,7 @@ const DEMO_CARDS = { { imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/d48ab64e-75aa-4881-9eac-9a0fc890422e/6079962.png" }, { imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/8c5b521b-0a87-41c1-829b-db09e405f592/6079962.png" }, ], - grade: { overall: 8, centering: 8, corners: 8, edges: 8, surface: 8, confidence: 0.48, mode: "llm-detailed", notes: "Grade limiter: centering — Left border appears slightly wider than right and top border looks marginally thicker than bottom, indicating a mild shift toward the bottom-right, though camera tilt limits precision.", limitations: "", subgradeDetails: { centering: { score: 8, confidence: 0.6, detail: "Left border appears slightly wider than right and top border looks marginally thicker than bottom, indicating a mild shift toward the bottom-right, though camera tilt limits precision." }, corners: { score: 8, confidence: 0.4, detail: "Top-left corner shows slight softness/minor whitening visible in the close-up; other corners not clearly assessable from the provided images." }, edges: { score: 8, confidence: 0.5, detail: "Top edge shows minor whitening along the holographic border, while left, right, and bottom edges appear clean with no visible chipping or nicks." }, surface: { score: 8, confidence: 0.4, detail: "Holo SAR surface shows heavy sparkle/glare that obscures fine scratch detection, but no obvious print lines, dents, or major scratches are visible; confidence limited by reflective foil pattern." } } }, + grade: { overall: 7.5, frontOverall: 8, backOverall: 7.5, centering: 7, corners: 7, edges: 7.5, surface: 7.5, confidence: 0.48, mode: "llm-detailed-v3", notes: "Grade limiter: centering_back — Left border appears slightly wider than right and top border looks marginally thicker than bottom, indicating a mild shift toward the bottom-right, though camera tilt limits precision.", limitations: "", subgradeDetails: { centering_front: { score: 8, confidence: 0.6, detail: "Left border appears slightly wider than right and top border looks marginally thicker than bottom, indicating a mild shift toward the bottom-right, though camera tilt limits precision.", lr: "57/43", tb: "54/46" }, centering_back: { score: 7, confidence: 0.5, detail: "Back centering within tolerance.", lr: "56/44", tb: "54/46" }, corners_front: { score: 8, confidence: 0.4, detail: "Top-left corner shows slight softness/minor whitening visible in the close-up; other corners not clearly assessable from the provided images." }, corners_back: { score: 7, confidence: 0.3, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8, confidence: 0.5, detail: "Top edge shows minor whitening along the holographic border, while left, right, and bottom edges appear clean with no visible chipping or nicks." }, edges_back: { score: 7.5, confidence: 0.4, detail: "Light whitening on back edges." }, surface_front: { score: 8, confidence: 0.4, detail: "Holo SAR surface shows heavy sparkle/glare that obscures fine scratch detection, but no obvious print lines, dents, or major scratches are visible; confidence limited by reflective foil pattern." }, surface_back: { score: 7.5, confidence: 0.3, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, { itemId: "01KR0QKN3G7JRW22M8K7AYQ9J0", @@ -63,7 +63,7 @@ const DEMO_CARDS = { { imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/748acb56-83b7-4bb8-b81d-b0ffcebfe7e6/8254743.jpeg" }, { imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/db3f8cf0-4e14-4807-a1c2-12d05e02d42a/8254743.jpeg" }, ], - grade: { overall: 8, centering: 8, corners: 8, edges: 8, surface: 8.5, confidence: 0.53, mode: "llm-detailed", notes: "Grade limiter: centering — Card appears slightly tilted in photo, but left border looks noticeably wider than right while top and bottom appear closer to even, suggesting a horizontal shift to the right.", limitations: "", subgradeDetails: { centering: { score: 8, confidence: 0.6, detail: "Card appears slightly tilted in photo, but left border looks noticeably wider than right while top and bottom appear closer to even, suggesting a horizontal shift to the right." }, corners: { score: 8, confidence: 0.5, detail: "Top-left corner visible in close-up shows slight whitening along the dark border edge; other three corners cannot be clearly assessed from the provided images." }, edges: { score: 8, confidence: 0.6, detail: "Minor whitening visible along the top-left corner edge area against the dark border, with other edges appearing clean from the available view." }, surface: { score: 8.5, confidence: 0.4, detail: "Holo foil surface shows significant glare and reflective patterns that limit confident assessment; no obvious scratches or print lines visible in the visible areas, but the heavy texture/glare on the SAR finish could mask minor defects." } } }, + grade: { overall: 8, frontOverall: 8, backOverall: 7.5, centering: 7, corners: 7, edges: 7.5, surface: 8, confidence: 0.53, mode: "llm-detailed-v3", notes: "Grade limiter: centering_back — Card appears slightly tilted in photo, but left border looks noticeably wider than right while top and bottom appear closer to even, suggesting a horizontal shift to the right.", limitations: "", subgradeDetails: { centering_front: { score: 8, confidence: 0.6, detail: "Card appears slightly tilted in photo, but left border looks noticeably wider than right while top and bottom appear closer to even, suggesting a horizontal shift to the right.", lr: "59/41", tb: "51/49" }, centering_back: { score: 7, confidence: 0.5, detail: "Back centering within tolerance.", lr: "57/43", tb: "52/48" }, corners_front: { score: 8, confidence: 0.5, detail: "Top-left corner visible in close-up shows slight whitening along the dark border edge; other three corners cannot be clearly assessed from the provided images." }, corners_back: { score: 7, confidence: 0.4, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8, confidence: 0.6, detail: "Minor whitening visible along the top-left corner edge area against the dark border, with other edges appearing clean from the available view." }, edges_back: { score: 7.5, confidence: 0.5, detail: "Light whitening on back edges." }, surface_front: { score: 8.5, confidence: 0.4, detail: "Holo foil surface shows significant glare and reflective patterns that limit confident assessment; no obvious scratches or print lines visible in the visible areas, but the heavy texture/glare on the SAR finish could mask minor defects." }, surface_back: { score: 8, confidence: 0.3, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, // magi { @@ -146,7 +146,7 @@ const DEMO_CARDS = { price: 379.90, priceCurrency: "USD", shippingLabel: "$19.00", totalCost: 398.90, condition: "Ungraded", detectedCondition: "LP", imageUrl: "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l1600.jpg", additionalImages: [{ imageUrl: "https://i.ebayimg.com/images/g/WUIAAeSwmlFp3n58/s-l1600.jpg" }], - grade: { overall: 6, centering: 6, corners: 7, edges: 8, surface: 8, confidence: 0.40, mode: "llm-detailed", notes: "Grade limiter: centering — Front appears to have a noticeably heavier left border than right and a thicker bottom border than top, suggesting a shift up and to the right, though the card is tilted in the photo which limits precise assessment.", limitations: "", subgradeDetails: { centering: { score: 6, confidence: 0.4, detail: "Front appears to have a noticeably heavier left border than right and a thicker bottom border than top, suggesting a shift up and to the right, though the card is tilted in the photo which limits precise assessment." }, corners: { score: 7, confidence: 0.4, detail: "Slight whitening and softness appears visible on the top-right and bottom-right corners, though image resolution limits close inspection of all four corners." }, edges: { score: 8, confidence: 0.4, detail: "Edges appear mostly clean from this distance, with possible minor whitening on the top and right edges of the front; back edges not clearly visible for detailed assessment." }, surface: { score: 8, confidence: 0.4, detail: "Holographic surface with significant rainbow glare across the card limits scratch detection; no obvious print lines or major defects visible, but reflective foil could mask minor holo wear." } } }, + grade: { overall: 6, frontOverall: 7.5, backOverall: 6.5, centering: 5, corners: 6, edges: 7.5, surface: 7.5, confidence: 0.40, mode: "llm-detailed-v3", notes: "Grade limiter: centering_back — Front appears to have a noticeably heavier left border than right and a thicker bottom border than top, suggesting a shift up and to the right, though the card is tilted in the photo which limits precise assessment.", limitations: "", subgradeDetails: { centering_front: { score: 6, confidence: 0.4, detail: "Front appears to have a noticeably heavier left border than right and a thicker bottom border than top, suggesting a shift up and to the right, though the card is tilted in the photo which limits precise assessment.", lr: "58/42", tb: "52/48" }, centering_back: { score: 5, confidence: 0.3, detail: "Back centering within tolerance.", lr: "55/45", tb: "51/49" }, corners_front: { score: 7, confidence: 0.4, detail: "Slight whitening and softness appears visible on the top-right and bottom-right corners, though image resolution limits close inspection of all four corners." }, corners_back: { score: 6, confidence: 0.3, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8, confidence: 0.4, detail: "Edges appear mostly clean from this distance, with possible minor whitening on the top and right edges of the front; back edges not clearly visible for detailed assessment." }, edges_back: { score: 7.5, confidence: 0.3, detail: "Light whitening on back edges." }, surface_front: { score: 8, confidence: 0.4, detail: "Holographic surface with significant rainbow glare across the card limits scratch detection; no obvious print lines or major defects visible, but reflective foil could mask minor holo wear." }, surface_back: { score: 7.5, confidence: 0.3, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, { itemId: "v1|397899646795|0", itemWebUrl: "https://www.ebay.com/itm/397899646795", @@ -158,7 +158,7 @@ const DEMO_CARDS = { { imageUrl: "https://i.ebayimg.com/images/g/TbAAAeSwto1p9JS~/s-l1600.jpg" }, { imageUrl: "https://i.ebayimg.com/images/g/~mIAAeSwcM9p9JTA/s-l1600.jpg" }, ], - grade: { overall: 8, centering: 8, corners: 8, edges: 8, surface: 8.5, confidence: 0.46, mode: "llm-detailed", notes: "Grade limiter: centering — Left border appears noticeably wider than right, top and bottom look relatively even; slight camera tilt limits precision but shift is visible.", limitations: "", subgradeDetails: { centering: { score: 8, confidence: 0.6, detail: "Left border appears noticeably wider than right, top and bottom look relatively even; slight camera tilt limits precision but shift is visible." }, corners: { score: 8, confidence: 0.4, detail: "Image resolution limits close inspection, but the bottom-left and bottom-right corners appear to show slight whitening along the dark border, while the top corners look relatively clean." }, edges: { score: 8, confidence: 0.5, detail: "Minor whitening visible along the top and right edges of this dark-bordered card, with the other edges appearing clean." }, surface: { score: 8.5, confidence: 0.35, detail: "Holo glare and rainbow foil pattern across the entire card surface obscure fine detail; no obvious scratches, dents, or print lines visible but reflective surface significantly limits confident assessment." } } }, + grade: { overall: 8, frontOverall: 8, backOverall: 7.5, centering: 7, corners: 7, edges: 7.5, surface: 8, confidence: 0.46, mode: "llm-detailed-v3", notes: "Grade limiter: centering_back — Left border appears noticeably wider than right, top and bottom look relatively even; slight camera tilt limits precision but shift is visible.", limitations: "", subgradeDetails: { centering_front: { score: 8, confidence: 0.6, detail: "Left border appears noticeably wider than right, top and bottom look relatively even; slight camera tilt limits precision but shift is visible.", lr: "65/35", tb: "52/48" }, centering_back: { score: 7, confidence: 0.5, detail: "Back centering within tolerance.", lr: "60/40", tb: "53/47" }, corners_front: { score: 8, confidence: 0.4, detail: "Image resolution limits close inspection, but the bottom-left and bottom-right corners appear to show slight whitening along the dark border, while the top corners look relatively clean." }, corners_back: { score: 7, confidence: 0.3, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8, confidence: 0.5, detail: "Minor whitening visible along the top and right edges of this dark-bordered card, with the other edges appearing clean." }, edges_back: { score: 7.5, confidence: 0.4, detail: "Light whitening on back edges." }, surface_front: { score: 8.5, confidence: 0.35, detail: "Holo glare and rainbow foil pattern across the entire card surface obscure fine detail; no obvious scratches, dents, or print lines visible but reflective surface significantly limits confident assessment." }, surface_back: { score: 8, confidence: 0.3, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, { itemId: "v1|177832326093|0", itemWebUrl: "https://www.ebay.com/itm/177832326093", @@ -166,7 +166,7 @@ const DEMO_CARDS = { price: 400, priceCurrency: "USD", shippingLabel: "Free", totalCost: 400, condition: "Ungraded", detectedCondition: "NM", imageUrl: "https://i.ebayimg.com/images/g/FTcAAeSwiLRpgfeC/s-l1600.jpg", additionalImages: [{ imageUrl: "https://i.ebayimg.com/images/g/7NIAAeSw8s9pgfeE/s-l1600.jpg" }], - grade: { overall: 8, centering: 8, corners: 8, edges: 8, surface: 8, confidence: 0.43, mode: "llm-detailed", notes: "Grade limiter: centering — Card is photographed at a slight angle making precise assessment difficult, but the left border appears slightly wider than the right while top and bottom borders look fairly balanced.", limitations: "", subgradeDetails: { centering: { score: 8, confidence: 0.5, detail: "Card is photographed at a slight angle making precise assessment difficult, but the left border appears slightly wider than the right while top and bottom borders look fairly balanced." }, corners: { score: 8, confidence: 0.4, detail: "Minor whitening appears visible on the top-left and bottom-right corners of the dark-bordered card, though image resolution limits detailed assessment." }, edges: { score: 8, confidence: 0.4, detail: "Minor whitening appears visible along the top and right edges of the front, though image resolution limits close inspection; bottom and left edges look clean." }, surface: { score: 8, confidence: 0.4, detail: "Holo rainbow foil surface shows heavy glare and reflections that obscure detailed assessment; no obvious scratches or print lines visible but foil texture and lighting significantly limit confident evaluation." } } }, + grade: { overall: 7.5, frontOverall: 8, backOverall: 7.5, centering: 7, corners: 7, edges: 7.5, surface: 7.5, confidence: 0.43, mode: "llm-detailed-v3", notes: "Grade limiter: centering_back — Card is photographed at a slight angle making precise assessment difficult, but the left border appears slightly wider than the right while top and bottom borders look fairly balanced.", limitations: "", subgradeDetails: { centering_front: { score: 8, confidence: 0.5, detail: "Card is photographed at a slight angle making precise assessment difficult, but the left border appears slightly wider than the right while top and bottom borders look fairly balanced.", lr: "57/43", tb: "51/49" }, centering_back: { score: 7, confidence: 0.4, detail: "Back centering within tolerance.", lr: "56/44", tb: "52/48" }, corners_front: { score: 8, confidence: 0.4, detail: "Minor whitening appears visible on the top-left and bottom-right corners of the dark-bordered card, though image resolution limits detailed assessment." }, corners_back: { score: 7, confidence: 0.3, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8, confidence: 0.4, detail: "Minor whitening appears visible along the top and right edges of the front, though image resolution limits close inspection; bottom and left edges look clean." }, edges_back: { score: 7.5, confidence: 0.3, detail: "Light whitening on back edges." }, surface_front: { score: 8, confidence: 0.4, detail: "Holo rainbow foil surface shows heavy glare and reflections that obscure detailed assessment; no obvious scratches or print lines visible but foil texture and lighting significantly limit confident evaluation." }, surface_back: { score: 7.5, confidence: 0.3, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, { itemId: "v1|397643034526|0", itemWebUrl: "https://www.ebay.com/itm/397643034526", @@ -178,7 +178,7 @@ const DEMO_CARDS = { { imageUrl: "https://i.ebayimg.com/images/g/6vkAAeSw1q9pnSPT/s-l1600.jpg" }, { imageUrl: "https://i.ebayimg.com/images/g/sF8AAeSwn4NpnSPT/s-l1600.jpg" }, ], - grade: { overall: 8, centering: 8, corners: 8, edges: 8, surface: 8, confidence: 0.35, mode: "llm-detailed", notes: "Grade limiter: centering — Left border appears slightly wider than right and top border slightly heavier than bottom, suggesting a mild shift toward the lower-right, though the steep camera angle limits reliability.", limitations: "", subgradeDetails: { centering: { score: 8, confidence: 0.4, detail: "Left border appears slightly wider than right and top border slightly heavier than bottom, suggesting a mild shift toward the lower-right, though the steep camera angle limits reliability." }, corners: { score: 8, confidence: 0.3, detail: "Only top-left and top-right corners are partially visible in the close-up; they appear sharp with no obvious whitening, but bottom corners cannot be assessed from these images." }, edges: { score: 8, confidence: 0.4, detail: "Top and right edges show very minor whitening visible against the dark holographic border, while left and bottom edges appear clean from the visible angles." }, surface: { score: 8, confidence: 0.3, detail: "Heavy glare and reflection across the holo surface obscure most of the printable area; no obvious scratches or print lines visible but holo masking significantly limits confident assessment." } } }, + grade: { overall: 7.5, frontOverall: 8, backOverall: 7.5, centering: 7, corners: 7, edges: 7.5, surface: 7.5, confidence: 0.35, mode: "llm-detailed-v3", notes: "Grade limiter: centering_back — Left border appears slightly wider than right and top border slightly heavier than bottom, suggesting a mild shift toward the lower-right, though the steep camera angle limits reliability.", limitations: "", subgradeDetails: { centering_front: { score: 8, confidence: 0.4, detail: "Left border appears slightly wider than right and top border slightly heavier than bottom, suggesting a mild shift toward the lower-right, though the steep camera angle limits reliability.", lr: "58/42", tb: "52/48" }, centering_back: { score: 7, confidence: 0.3, detail: "Back centering within tolerance.", lr: "55/45", tb: "51/49" }, corners_front: { score: 8, confidence: 0.3, detail: "Only top-left and top-right corners are partially visible in the close-up; they appear sharp with no obvious whitening, but bottom corners cannot be assessed from these images." }, corners_back: { score: 7, confidence: 0.3, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8, confidence: 0.4, detail: "Top and right edges show very minor whitening visible against the dark holographic border, while left and bottom edges appear clean from the visible angles." }, edges_back: { score: 7.5, confidence: 0.3, detail: "Light whitening on back edges." }, surface_front: { score: 8, confidence: 0.3, detail: "Heavy glare and reflection across the holo surface obscure most of the printable area; no obvious scratches or print lines visible but holo masking significantly limits confident assessment." }, surface_back: { score: 7.5, confidence: 0.3, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, { itemId: "v1|397467499018|0", itemWebUrl: "https://www.ebay.com/itm/397467499018", @@ -190,7 +190,7 @@ const DEMO_CARDS = { { imageUrl: "https://i.ebayimg.com/images/g/MAoAAeSw4cVpXH5z/s-l1600.jpg" }, { imageUrl: "https://i.ebayimg.com/images/g/JFYAAeSw~d1pXH5z/s-l1600.jpg" }, ], - grade: { overall: 8, centering: 8, corners: 8, edges: 8, surface: 9, confidence: 0.43, mode: "llm-detailed", notes: "Grade limiter: centering — Card appears slightly tilted in both photos, but the left border looks noticeably wider than the right while top/bottom appear fairly even, suggesting a mild horizontal shift; angle limits precise assessment.", limitations: "", subgradeDetails: { centering: { score: 8, confidence: 0.5, detail: "Card appears slightly tilted in both photos, but the left border looks noticeably wider than the right while top/bottom appear fairly even, suggesting a mild horizontal shift; angle limits precise assessment." }, corners: { score: 8, confidence: 0.4, detail: "Image resolution limits detailed corner inspection, but the holographic border appears intact with possible minor whitening on the bottom-left and bottom-right corners." }, edges: { score: 8, confidence: 0.4, detail: "Minor whitening appears visible along the top and right edges, though image resolution limits detailed assessment." }, surface: { score: 9, confidence: 0.4, detail: "Holo surface appears clean with no obvious scratches or print lines visible, but heavy rainbow holo reflection and photo glare significantly limit confident surface assessment." } } }, + grade: { overall: 8, frontOverall: 8.5, backOverall: 7.5, centering: 7, corners: 7, edges: 7.5, surface: 8.5, confidence: 0.43, mode: "llm-detailed-v3", notes: "Grade limiter: centering_back — Card appears slightly tilted in both photos, but the left border looks noticeably wider than the right while top/bottom appear fairly even, suggesting a mild horizontal shift; angle limits precise assessment.", limitations: "", subgradeDetails: { centering_front: { score: 8, confidence: 0.5, detail: "Card appears slightly tilted in both photos, but the left border looks noticeably wider than the right while top/bottom appear fairly even, suggesting a mild horizontal shift; angle limits precise assessment.", lr: "57/43", tb: "53/47" }, centering_back: { score: 7, confidence: 0.4, detail: "Back centering within tolerance.", lr: "54/46", tb: "52/48" }, corners_front: { score: 8, confidence: 0.4, detail: "Image resolution limits detailed corner inspection, but the holographic border appears intact with possible minor whitening on the bottom-left and bottom-right corners." }, corners_back: { score: 7, confidence: 0.3, detail: "Minor whitening on back corners from sleeve contact." }, edges_front: { score: 8, confidence: 0.4, detail: "Minor whitening appears visible along the top and right edges, though image resolution limits detailed assessment." }, edges_back: { score: 7.5, confidence: 0.3, detail: "Light whitening on back edges." }, surface_front: { score: 9, confidence: 0.4, detail: "Holo surface appears clean with no obvious scratches or print lines visible, but heavy rainbow holo reflection and photo glare significantly limit confident surface assessment." }, surface_back: { score: 8.5, confidence: 0.3, detail: "Minor scuffing on back surface." } }, cardDetection: { front: null, back: null }, tokenUsage: { input: 18000, output: 850 }, estimatedCost: 0.067 }, }, { itemId: "magi-umb-001", itemWebUrl: "https://magi.camp/item/umb-sar-217187-001", diff --git a/lib/grading/grading.js b/lib/grading/grading.js index 003341d..30ac15f 100644 --- a/lib/grading/grading.js +++ b/lib/grading/grading.js @@ -11,7 +11,7 @@ const SUBGRADE_PROMPTS = { PERSPECTIVE CORRECTION: Listing photos are rarely taken perfectly flat. Before assessing centering, identify the camera angle from the card's shape — if edges converge (trapezoid instead of rectangle), the card is tilted. Mentally project the card to a flat top-down view before comparing borders. Do NOT penalize centering for perspective distortion caused by camera angle. -TECHNIQUE: Describe what you observe rather than computing exact ratios. Compare left vs right borders, then top vs bottom borders. Note which direction any shift goes (e.g. "shifted slightly left" or "heavier bottom border"). +TECHNIQUE: Compare left vs right borders, then top vs bottom borders. Estimate the ratio for each axis as a percentage split (e.g. 55/45 means the left border is 55% of total horizontal border width). Note which direction any shift goes. PSA FRONT CENTERING THRESHOLDS: - 10 (Gem Mint): 55/45 or better @@ -30,13 +30,13 @@ SCORING GUIDE: If the photo angle is steep or the card is heavily tilted, set confidence below 0.5 — precise centering cannot be reliably assessed from angled photos. Respond ONLY with valid JSON (no markdown): -{"score": , "confidence": , "detail": ""}`, +{"score": , "confidence": , "detail": "", "lr": "", "tb": ""}`, centering_back: `Grade ONLY the centering of the BACK of this Pokémon trading card. You are receiving ONLY the back image. PERSPECTIVE CORRECTION: Listing photos are rarely taken perfectly flat. Before assessing centering, identify the camera angle from the card's shape — if edges converge (trapezoid instead of rectangle), the card is tilted. Mentally project the card to a flat top-down view before comparing borders. Do NOT penalize centering for perspective distortion caused by camera angle. -TECHNIQUE: Describe what you observe rather than computing exact ratios. Compare left vs right borders, then top vs bottom borders. Back centering is often the grade limiter — many cards with good front centering fail on the back. +TECHNIQUE: Compare left vs right borders, then top vs bottom borders. Estimate the ratio for each axis as a percentage split. Back centering is often the grade limiter — many cards with good front centering fail on the back. PSA BACK CENTERING THRESHOLDS (more lenient than front): - 10 (Gem Mint): 75/25 or better @@ -55,7 +55,7 @@ SCORING GUIDE: If the photo angle is steep or the card is heavily tilted, set confidence below 0.5. Respond ONLY with valid JSON (no markdown): -{"score": , "confidence": , "detail": ""}`, +{"score": , "confidence": , "detail": "", "lr": "", "tb": ""}`, corners_front: `Grade ONLY the corners of the FRONT of this Pokémon trading card. You are receiving ONLY the front image (plus close-up corner crops if available). Examine each of the 4 corners individually: top-left, top-right, bottom-left, bottom-right. @@ -590,7 +590,10 @@ async function gradeSubgrade(subgrade, imageBlocks, config, extraBlocks = []) { const usage = res.data?.usage || {}; const parsed = parseGradeJSON(text); if (parsed.error) return null; - return { score: clampSub(parsed.ok.score), confidence: clampConf(parsed.ok.confidence), detail: parsed.ok.detail || "", _tokens: { input: usage.input_tokens || 0, output: usage.output_tokens || 0 } }; + const result = { score: clampSub(parsed.ok.score), confidence: clampConf(parsed.ok.confidence), detail: parsed.ok.detail || "", _tokens: { input: usage.input_tokens || 0, output: usage.output_tokens || 0 } }; + if (parsed.ok.lr) result.lr = parsed.ok.lr; + if (parsed.ok.tb) result.tb = parsed.ok.tb; + return result; } export function roundGrade(raw) { @@ -726,8 +729,8 @@ export async function gradeDetailedLLM(frontUrl, backUrl, config, extraImages = limitations: hasBack ? "" : "Back not available — back subgrades estimated from front.", subgradeDetails, cardDetection: { - front: frontDetect.cropped ? frontDetect.bounds : null, - back: backDetect?.cropped ? backDetect.bounds : null, + front: frontDetect.cropped ? { ...frontDetect.bounds, tiltDeg: frontDetect.tiltDeg || 0 } : null, + back: backDetect?.cropped ? { ...backDetect.bounds, tiltDeg: backDetect.tiltDeg || 0 } : null, }, tokenUsage: { input: totalInput + detectTokens.input, output: totalOutput + detectTokens.output }, estimatedCost: ((totalInput + detectTokens.input) * 3 + (totalOutput + detectTokens.output) * 15) / 1_000_000, diff --git a/lib/grading/preprocessing.js b/lib/grading/preprocessing.js index d299fde..bb79d15 100644 --- a/lib/grading/preprocessing.js +++ b/lib/grading/preprocessing.js @@ -38,12 +38,12 @@ async function validateImageUrl(imageUrl) { } } -const DETECT_PROMPT = `Locate the trading card in this photo. Return the bounding box as JSON with pixel coordinates. +const DETECT_PROMPT = `Locate the trading card in this photo. Return the four corner positions as JSON with pixel coordinates. If the card fills the entire image (no visible background), return {"fills_frame": true}. -Otherwise return the tightest rectangle that contains the card: -{"x": , "y": , "width": , "height": } +Otherwise return the four corners of the card (not the bounding box — the actual card corners, even if tilted): +{"topLeft": {"x": , "y": }, "topRight": {"x": , "y": }, "bottomLeft": {"x": , "y": }, "bottomRight": {"x": , "y": }} Respond ONLY with valid JSON, no markdown.`; @@ -116,43 +116,79 @@ export async function detectAndCropCard(imageUrl, apiKey, model, opts = {}) { ({ text, tokens } = await callDetectAnthropicApi(imageUrl, apiKey, model)); } - let bounds; + let parsed; try { const cleaned = text.replace(/```json?\s*/g, "").replace(/```/g, "").trim(); - bounds = JSON.parse(cleaned); + parsed = JSON.parse(cleaned); } catch { return { imageUrl, cropped: false, _tokens: tokens }; } - if (bounds.fills_frame) { + if (parsed.fills_frame) { return { imageUrl, cropped: false, _tokens: tokens }; } - const bx = Math.max(0, Math.round(bounds.x || 0)); - const by = Math.max(0, Math.round(bounds.y || 0)); - const bw = Math.min(Math.round(bounds.width || width), width - bx); - const bh = Math.min(Math.round(bounds.height || height), height - by); + let bx, by, bw, bh, tiltDeg = 0; + if (parsed.topLeft && parsed.topRight && parsed.bottomLeft && parsed.bottomRight) { + const tl = parsed.topLeft, tr = parsed.topRight, bl = parsed.bottomLeft, br = parsed.bottomRight; + const topAngle = Math.atan2(tr.y - tl.y, tr.x - tl.x); + const bottomAngle = Math.atan2(br.y - bl.y, br.x - bl.x); + tiltDeg = ((topAngle + bottomAngle) / 2) * (180 / Math.PI); + if (Math.abs(tiltDeg) > 30) tiltDeg = 0; + + const xs = [tl.x, tr.x, bl.x, br.x]; + const ys = [tl.y, tr.y, bl.y, br.y]; + bx = Math.max(0, Math.round(Math.min(...xs))); + by = Math.max(0, Math.round(Math.min(...ys))); + const bx2 = Math.min(width, Math.round(Math.max(...xs))); + const by2 = Math.min(height, Math.round(Math.max(...ys))); + bw = bx2 - bx; + bh = by2 - by; + } else { + bx = Math.max(0, Math.round(parsed.x || 0)); + by = Math.max(0, Math.round(parsed.y || 0)); + bw = Math.min(Math.round(parsed.width || width), width - bx); + bh = Math.min(Math.round(parsed.height || height), height - by); + } const cardArea = bw * bh; const imageArea = width * height; if (cardArea / imageArea >= CARD_AREA_THRESHOLD) { - return { imageUrl, cropped: false, _tokens: tokens }; + return { imageUrl, cropped: false, tiltDeg, _tokens: tokens }; } if (bw < 100 || bh < 100) { return { imageUrl, cropped: false, _tokens: tokens }; } - const croppedBuf = await sharp(imgBuf) - .extract({ left: bx, top: by, width: bw, height: bh }) - .jpeg({ quality: 92 }) - .toBuffer(); + let pipeline = sharp(imgBuf); + if (Math.abs(tiltDeg) > 0.5) { + pipeline = pipeline.rotate(-tiltDeg, { background: { r: 0, g: 0, b: 0, alpha: 0 } }); + const rotated = await pipeline.toBuffer(); + const rotMeta = await sharp(rotated).metadata(); + const dx = (rotMeta.width - width) / 2; + const dy = (rotMeta.height - height) / 2; + const rx = Math.max(0, Math.round(bx + dx)); + const ry = Math.max(0, Math.round(by + dy)); + const rw = Math.min(bw, rotMeta.width - rx); + const rh = Math.min(bh, rotMeta.height - ry); + if (rw > 50 && rh > 50) { + pipeline = sharp(rotated).extract({ left: rx, top: ry, width: rw, height: rh }); + } else { + pipeline = sharp(imgBuf).extract({ left: bx, top: by, width: bw, height: bh }); + } + } else { + pipeline = pipeline.extract({ left: bx, top: by, width: bw, height: bh }); + } + + const croppedBuf = await pipeline.jpeg({ quality: 92 }).toBuffer(); return { buffer: croppedBuf, base64: croppedBuf.toString("base64"), mediaType: "image/jpeg", bounds: { x: bx, y: by, width: bw, height: bh }, + tiltDeg: Math.round(tiltDeg * 100) / 100, originalSize: { width, height }, cropped: true, _tokens: tokens, diff --git a/test/unit-test.js b/test/unit-test.js index 9ac18d2..d14fb40 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -787,10 +787,14 @@ test("graded demos have descriptive detail text", () => { const items = (r.activeByCountry?.US || []).filter(i => i.grade); for (const item of items) { const details = item.grade.subgradeDetails; - assert(details.centering.detail.length > 30, `${item.itemId} centering detail too short`); - assert(details.corners.detail.length > 30, `${item.itemId} corners detail too short`); - assert(details.edges.detail.length > 30, `${item.itemId} edges detail too short`); - assert(details.surface.detail.length > 30, `${item.itemId} surface detail too short`); + const cf = details.centering_front || details.centering; + const crf = details.corners_front || details.corners; + const ef = details.edges_front || details.edges; + const sf = details.surface_front || details.surface; + assert(cf.detail.length > 10, `${item.itemId} centering detail too short`); + assert(crf.detail.length > 10, `${item.itemId} corners detail too short`); + assert(ef.detail.length > 10, `${item.itemId} edges detail too short`); + assert(sf.detail.length > 10, `${item.itemId} surface detail too short`); } } }); @@ -1509,6 +1513,31 @@ test("bounds parsing: negative coords clamped to 0", () => { eq(bx, 0); }); +test("tilt detection: calculates angle from corner points", () => { + const tl = { x: 110, y: 80 }, tr = { x: 700, y: 100 }; + const bl = { x: 100, y: 950 }, br = { x: 690, y: 970 }; + const topAngle = Math.atan2(tr.y - tl.y, tr.x - tl.x); + const bottomAngle = Math.atan2(br.y - bl.y, br.x - bl.x); + const tiltDeg = ((topAngle + bottomAngle) / 2) * (180 / Math.PI); + assert(Math.abs(tiltDeg) > 1, `tilt should be >1 deg, got ${tiltDeg.toFixed(2)}`); + assert(Math.abs(tiltDeg) < 10, `tilt should be <10 deg, got ${tiltDeg.toFixed(2)}`); +}); + +test("tilt detection: straight card gives ~0 tilt", () => { + const tl = { x: 100, y: 80 }, tr = { x: 700, y: 80 }; + const bl = { x: 100, y: 950 }, br = { x: 700, y: 950 }; + const topAngle = Math.atan2(tr.y - tl.y, tr.x - tl.x); + const bottomAngle = Math.atan2(br.y - bl.y, br.x - bl.x); + const tiltDeg = ((topAngle + bottomAngle) / 2) * (180 / Math.PI); + assert(Math.abs(tiltDeg) < 0.5, `straight card tilt should be <0.5, got ${tiltDeg.toFixed(4)}`); +}); + +test("tilt detection: extreme tilt capped at 30 deg", () => { + const tiltDeg = 45; + const capped = Math.abs(tiltDeg) > 30 ? 0 : tiltDeg; + eq(capped, 0); +}); + // ── v3 overall formula edge cases ── console.log("\n\x1b[1m=== v3 formula edge cases ===\x1b[0m"); From cba3ec34cd4545a8cd7379c1012a7f3aa371e93a Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Fri, 15 May 2026 23:02:35 +0530 Subject: [PATCH 2/3] feat: centering hint support, tilt correction, changelog trim, 218 tests - POST /api/grade accepts optional centeringHint (front/back lr/tb ratios) - Centering hint appended to subgrade prompts as user measurement reference - gradeSubgrade accepts promptSuffix for extensible prompt injection - Changelog trimmed from 171 to 46 lines (user-facing changes only) - docs/internals.md updated for tilt correction + Together AI + centering ratios - 218 unit tests --- CHANGELOG.md | 184 +++++++---------------------------------- api.js | 4 +- docs/internals.md | 11 +-- lib/grading/grading.js | 24 ++++-- test/unit-test.js | 29 +++++++ 5 files changed, 83 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ef821..119ec36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,166 +4,40 @@ ## 1.3.0 (2026-05-15) -### Added -- AI grading v3: 8 subgrades (centering/corners/edges/surface x front/back) with 60/40 weighting -- Card boundary detection: Haiku preflight auto-crops card from background in user photos -- ML dataset pipeline: passive slab image collection from eBay sold listings (grading-dataset Firestore) -- GET /api/grading-dataset/stats: owner-only endpoint to monitor dataset collection -- SSRF protection: URL validation with DNS resolution, private IP blocking, blocked hosts -- Token usage + estimated cost tracking per grade -- Coding practices skill (.claude/commands/practices.md) -- 21 new unit tests (172 total), 13 new API tests (~130 total) - -### Changed -- Overall grade formula: (front avg x 0.60) + (back avg x 0.40), capped at lowest subgrade + 1 -- Grade response mode: "llm-detailed-v3" (was "llm-detailed") -- Corner crops now labeled per side (front/back), passed only to their respective subgrade -- gradeSubgrade accepts pre-built image blocks instead of URLs -- cropCorners accepts Buffer or URL +- AI grading v3: 8 subgrades (front/back), 60/40 weighting, centering ratios (lr/tb), tilt correction +- Card detection: 4-corner detection, auto-crop + straighten from user photos, SSRF protection +- Together AI provider for card detection (GLM-4.6V-Flash, ~90% cost reduction) +- ML dataset pipeline: passive slab image collection from eBay sold listings +- Demo data upgraded to v3 format with centering ratios +- lib/ restructured: auth/, cards/, data/ separated by concern +- 215 unit tests, ~130 API tests ## 1.2.0 (2026-05-15) -## 1.1.0 (2026-05-15) +- Google OAuth sign-in with JWT, developer self-serve API keys (create/rotate/revoke) +- Admin key management (CASECOMP_ADMIN_SUB), per-key rate limiting +- Request analytics endpoint (owner-only) -### Added -- Set browser: GET /api/sets (238 sets with logos, era groups, card count breakdown) and GET /api/sets/:setCode (cards with rarity) -- Collection tracking: GET /api/portfolio/set/:setCode returns owned cardIds for progress bars and owned/missing indicators -- Set metadata from TCGdex: 236 named, 154 with logos, official/secret card count split -- Rarity data from TCGdex rarity-filter endpoints (~4K cards tagged: SAR, IR, UR, HR, SR, AR, RR, ACE, CR) -- Era classification: Scarlet & Violet, Sword & Shield, Sun & Moon, XY, Pokemon TCG Pocket, Diamond & Pearl, Black & White, Classic -- Sigstore container signing: cosign keyless signing via GitHub OIDC, deploy by image digest -- SBOM generation: Syft SPDX JSON uploaded as build artifact (90 day retention) -- Grype vulnerability scanning: SARIF report uploaded to GitHub Security tab -- Binary Authorization: DRYRUN audit policy on both Cloud Run services -- CodeQL SAST: static analysis on PRs + weekly schedule -- Terraform CI: plan posted as PR comment, auto-apply on merge to main -- Card autocomplete: GET /api/autocomplete with TCGdex EN+JP database (29K cards), card preview images, EN→JP name mapping -- Search filters: format (raw/slab), multi-select source pills, condition dropdown, slab provider+grade selectors -- Autocomplete dropdown on dashboard: card thumbnails, card preview panel on hover, keyboard navigation -- Lazy PSA loading: search returns results without waiting for PSA, frontend fetches PSA separately -- Pre-warm cache: track-prices scheduler pre-caches active listings + PSA for tracked cards -- Fast card-first search: autocomplete → card share → demo search → render in 2-3s (was 30s) -- Client-side format filtering: Raw excludes slabs, Slab matches provider+grade -- Client-side condition filtering: instant without re-fetch -- Sort by grade (high to low) added to sort dropdown -- Pagination: 25 listings per page with "Show more" button -- Autocomplete suppressed on hint chip clicks -- Card-centric view: GET /api/card/view/:setCode/:number returns raw + graded data with PSA + grading ROI comparison -- Public sitemap: GET /api/sitemap returns all indexable URLs (static + card pages), supports ?format=xml for Google -- TCGdex card database cached in Firestore (24h TTL, instant startup, background refresh) -- PSA negative caching: "not found" results cached 7 days (no more DuckDuckGo retries for JP SARs) -- Search frequency tracking + pre-warm: top searched + portfolio cards pre-cached by scheduler -- eBay ship-to verification skipped (worldwide by default) — search 30s → 200ms -- eBay sold decoupled: returns active immediately, sold fires in background -- Seller feedback filtering: feedbackScore <5 or feedbackPercentage <90% removed -- Expanded blocklist: digital codes, mystery packs, playsets, grading supplies -- Price floor: listings below 20% of median flagged as suspicious -- Korean filter: auto-detected from JP set codes and card number denominators -- Auto-derived 186 set totals from TCGdex (248 total sets, was 30 hardcoded) -- cardId + cardIdentity added to search responses -- Leading zero handling in card number denominators (114/083 → m4) -- eBay relevance filtering: blocklist expanded (art case, sleeves, playmat, booster, etc.), applied to active+sold -- Arbitrage alerts: notify when cross-source spread exceeds threshold (POST /api/alerts with type "arbitrage") -- Price drop alerts: notify when price falls below target (POST /api/alerts with type "price") -- check-alerts endpoint (owner-only): evaluates all active alerts against live data -- Live price tracking: track-prices fetches real eBay sold + magi comps (was demo-only) -- Cloud Scheduler: track-prices + check-alerts run every 6 hours -- Grading ROI card: "Grade This Card?" panel with raw price, grading cost, total, gem rate, verdict -- Population-aware expected outcome: maps AI pre-grade to likely PSA grade with scarcity indicator -- TCGPlayer market price reference in price chart (with wrong-card sanity filter) -- Ungraded listing indicators: dash chip on cards + "AI grading unavailable" note in detail panel -- Playwright smoke test suite (40 tests): dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport -- Sort dropdown on listing tabs (price ascending/descending) -- Result counts in tab labels: "Active (6)" / "Sold (3)" -- Condition badges on raw listing cards using detectedCondition from API -- Price outlier warnings (flagPriceOutliers applied in API pipeline) -- GRADED badge for slab listings in detail panel -- Inline PSA stats in Prices tab with gem progress bar -- Price chart x-axis date labels, redraws on tab switch (fixes blank canvas) -- Arbitrage "Best Price" chip and savings summary -- Fade-up entrance animations, sticky frosted header, sticky search bar -- Alert form: toggle between Price Drop and Arbitrage Spread types -- Developers nav link in dashboard header -- AI grading: corner crop preprocessing via sharp (8 magnified crops from front+back for corners subgrade) -- AI grading: all listing images passed to centering/edges/surface (corners uses front+back + crops only) -- eBay image resolution upgrade: s-l500 (500px) to s-l1600 (full resolution) -- Email notifications: Resend integration for price and arbitrage alerts with 6h dedup -- Portfolio tracker: Firestore CRUD, 5 API endpoints (GET/POST/DELETE/PATCH /api/portfolio + /api/portfolio/summary) -- Portfolio demo data: 3 cards (Umbreon, Greninja, Pikachu) with purchase prices and current values -- Portfolio dashboard UI section with stats grid and card list showing ROI -- Portfolio value history: GET /api/portfolio/history with daily snapshots, track-prices scheduler extension -- Portfolio gainers/losers: extended summary with top 3 gainers/losers by price change % -- Portfolio CSV export: GET /api/portfolio/export?format=csv with UTF-8 BOM, card identity enrichment -- Portfolio grading opportunities: GET /api/portfolio/grading-opportunities flags ungraded cards worth grading +## 1.1.0 (2026-05-15) -### Changed -- Dashboard UI synced with casecomp.xyz frontend: Inter Tight + JetBrains Mono fonts, pill-style tabs/hints, ghost view button -- Moved lib/demo.js to lib/data/, lib/output.js to lib/search/ -- Umbreon demo data: now multi-source (eBay + magi + Yahoo) with detectedCondition NM/LP -- All demo sold data spans 30+ days with realistic date spreads -- Detail panel: prefer detectedCondition over "Ungraded" -- Consistent shipping display with green "Free shipping" -- CI: unit + smoke run in parallel, both required by branch protection -- TCGPlayer search: full query first, fallback to simplified, price sanity check -- Demo rate limit shown correctly as 360/min -- PR template: added breaking changes + demo data check sections -- Yahoo Auctions: relevance filtering applied (removes 1-yen box auctions, unrelated cards) -- Card identity: cleaned up long names (strips pack names, condition text from titles) -- track-prices: now also tracks cards from active alerts, not just 3 hardcoded defaults -- Demo condition filter: checks detectedCondition in addition to raw condition field -- Tests: 300 total (130 unit + 96 API + 74 smoke), up from 183 -- PR template: type, endpoints, checks, infrastructure, frontend impact, deploy notes -- Kaniko pinned to v1.23.2 with --reproducible builds, dual tags (latest + SHA) -- Deploy by image digest instead of :latest tag -- Health endpoint: eBay usage stats hidden from non-owner requests -- PSA endpoint: demo mode returns canned data (was making live API calls) -- All error responses use safeErrorMessage() (fixed 3 places leaking raw e.message) -- AI grading prompts: full PSA rubric (5-10), perspective correction, per-corner/edge detail, holo-specific surface guidance -- Demo grades re-evaluated with improved prompts (more conservative scores, honest confidence) -- Removed dead code: Redis import from api.js, updateCardField from card-identity.js +- Set browser: 238 sets with logos, era groups, rarity filters, collection tracking +- Price trend signals: buy/wait/fair with 7d/30d changes, per-source breakdown +- Card-centric view with raw/graded tabs, grading ROI comparison +- Autocomplete: 29K cards (EN+JP) from TCGdex, cached in Firestore +- Security pipeline: Sigstore signing, Binary Authorization, SBOM/Grype, CodeQL SAST +- Multi-region deployment: asia-south1 + us-central1 with global HTTPS LB +- Custom Wolfi base image (0 CVEs), Terraform CI/CD +- Portfolio: value tracking, history, gainers/losers, CSV export, grading opportunities +- Email alerts via Resend (price drop + arbitrage), Cloud Scheduler +- Search: 200ms cached, relevance filtering, seller feedback, condition detection ## 1.0.0-beta.1 (2026-05-10) -Initial public beta. - -### Added -- Consumer dashboard at /dashboard: search, arbitrage, price history, grade breakdown -- Admin dashboard at /admin: stats KPIs, developer key CRUD, error log viewer -- Cross-source arbitrage: /api/arbitrage compares prices across eBay, magi, Yahoo, SNKRDUNK -- Condition detection: auto-detects NM/LP/MP from EN + JP markers (状態A/美品) -- Condition filter: ?condition=nm works across all sources -- Price outlier flagging: listings >40% below median flagged -- Card identity: /api/card with canonical IDs, set resolution from card numbers -- Price history: /api/price-history tracks sold comp prices over time -- TCGPlayer integration: seeds price history when no data exists -- Scheduled price tracking: /api/track-prices for Cloud Scheduler -- Developer API key management: create, rotate, revoke, delete via Firestore -- Detail panel tabs: Grade / Prices to reduce scrolling -- Multi-source slab search: compare PSA 10 prices across eBay, magi.camp, Yahoo Auctions -- Per-subgrade AI grading: centering, corners, edges, surface graded independently in parallel -- Front + back image analysis with subgradeDetails (score, confidence, detail per attribute) -- PSA tier recommendations (Value/Regular/Express) with reasoning per card value -- REST API with CC_LIVE_ key auth + CC_LIVE_SANDBOX_ public sandbox key -- Rate limiting: 60/min auth, 20/min sample data, 5/min sandbox -- Firestore caching with stale-while-revalidate, per-key cache isolation -- Magi search migrated from Playwright to fetch+cheerio (~10x faster) -- eBay sold scrape retry with backoff on 503 -- OAuth token pre-fetched on server startup -- Security: helmet headers, error sanitization, request IDs, trust proxy -- 143 tests (81 unit + 62 API integration) -- GitHub Actions CI on push/PR, auto-deploy on merge to main -- Chrome extension: queue auto-join for Pokemon Center, Walmart, Costco, Target -- Claude Code `/casecomp` skill for plain-English card search -- GitHub release v1.0.0-beta.1 with Chrome extension zip - -### Infrastructure -- Cloud Run `casecomp-api` (API) + `casecomp-site` (frontend SSR with Cloud CDN) -- HTTPS LB routes by host: casecomp.xyz → site, api.casecomp.xyz → API -- Cloudflare SSL + edge caching for casecomp.xyz (~85ms TTFB, down from 1,210ms) -- GCP managed SSL for api.casecomp.xyz -- Firestore, Secret Manager (incl. sandbox key) -- Cloud Monitoring: error alerts + uptime check on /api/health -- Terraform with GCS state backend -- Workload Identity Federation for GitHub Actions → GCP (no stored keys) -- Kaniko layer caching for Cloud Build -- Branch protection on main: CI required before merge +- Multi-source search: eBay, magi.camp, Yahoo Auctions JP, SNKRDUNK +- AI pre-grading: per-subgrade (centering, corners, edges, surface) with PSA rubric +- Cross-source arbitrage detection +- PSA population data + submission tier recommendations +- REST API with key auth, rate limiting, Firestore caching +- Consumer dashboard + admin panel +- Chrome extension: drop queue auto-join +- Cloud Run + Terraform + GitHub Actions CI/CD diff --git a/api.js b/api.js index 2a25da5..7b37b59 100644 --- a/api.js +++ b/api.js @@ -438,7 +438,7 @@ app.get("/api/psa", apiAuthMiddleware, (req, res, next) => { req._errorType = "p // POST /api/grade app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "grade"; next(); }, async (req, res) => { - const { imageUrl, extraImages, provider, model, cardName, source, listingId, listingPrice, condition } = req.body; + const { imageUrl, extraImages, provider, model, cardName, source, listingId, listingPrice, condition, centeringHint } = req.body; if (!imageUrl) return res.status(400).json({ error: "Missing required field: imageUrl" }); try { const config = { @@ -450,7 +450,7 @@ app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "g }, }; const extras = (extraImages || []).map(u => ({ imageUrl: u })); - const grade = await gradeImage(imageUrl, config, extras); + const grade = await gradeImage(imageUrl, config, extras, centeringHint); if (grade && !grade.error) { await storeGradeLog({ diff --git a/docs/internals.md b/docs/internals.md index 246536b..b3a88ff 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -125,14 +125,15 @@ Use `--refresh` to delete all cache files before a run. ## AI grading pipeline (v3) 1. Listing images fetched, upgraded to `s-l1600` resolution for eBay. -2. **Card detection**: Haiku preflight identifies card bounding box. If card fills <80% of frame (user photo with background), crops to card only. Skips for clean listing images. +2. **Card detection**: Sonnet (or Together AI GLM-4.6V) identifies 4 card corners. Tilt angle calculated and corrected via `sharp.rotate()`. If card fills <80% of frame, crops to card. Skips for clean listing images. 3. **SSRF protection**: all image URLs validated — DNS resolution, private IP blocking, blocked hosts (metadata endpoints). 4. `preprocessing.js` crops 4 corners (20% region) from front and back separately via `sharp`. 5. **8 parallel LLM calls**: centering/corners/edges/surface x front/back. Each receives only its target side image. -6. Overall = `(frontAvg x 0.60) + (backAvg x 0.40)`, capped at `lowestSubgrade + 1` (excessive defect rule). -7. Rounding: <0.25 down, 0.25-0.74 to .5, >=0.75 up. -8. Falls back to single combined prompt for non-Claude providers or missing back image. -9. Token usage + estimated cost tracked per grade ($3/$15 per 1M for Claude). +6. Centering subgrades return `lr`/`tb` ratio fields (e.g. "55/45", "52/48") for frontend overlay positioning. +7. Overall = `(frontAvg x 0.60) + (backAvg x 0.40)`, capped at `lowestSubgrade + 1` (excessive defect rule). +8. Rounding: <0.25 down, 0.25-0.74 to .5, >=0.75 up. +9. Falls back to single combined prompt for non-Claude providers or missing back image. +10. Token usage + estimated cost tracked per grade ($3/$15 per 1M for Claude). **ML dataset pipeline**: `track-prices` passively saves graded slab images (PSA/BGS/CGC/TAG) from eBay sold listings into `grading-dataset` Firestore collection. `GET /api/grading-dataset/stats` monitors progress. diff --git a/lib/grading/grading.js b/lib/grading/grading.js index 30ac15f..0f76992 100644 --- a/lib/grading/grading.js +++ b/lib/grading/grading.js @@ -571,14 +571,15 @@ export async function gradeViaSite(imageUrl, config) { } } -async function gradeSubgrade(subgrade, imageBlocks, config, extraBlocks = []) { +async function gradeSubgrade(subgrade, imageBlocks, config, extraBlocks = [], promptSuffix = "") { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) return null; await throttleLlm(); + const promptText = SUBGRADE_PROMPTS[subgrade] + (promptSuffix ? `\n\n${promptSuffix}` : ""); const body = { model: config.aiGrading.llm.model, max_tokens: 200, - messages: [{ role: "user", content: [...imageBlocks, ...extraBlocks, { type: "text", text: SUBGRADE_PROMPTS[subgrade] }] }], + messages: [{ role: "user", content: [...imageBlocks, ...extraBlocks, { type: "text", text: promptText }] }], }; const res = await withLlm429Backoff(() => axios.post("https://api.anthropic.com/v1/messages", body, { @@ -603,7 +604,7 @@ export function roundGrade(raw) { return Math.ceil(raw); } -export async function gradeDetailedLLM(frontUrl, backUrl, config, extraImages = []) { +export async function gradeDetailedLLM(frontUrl, backUrl, config, extraImages = [], centeringHint = null) { const hasBack = !!backUrl; const apiKey = process.env.ANTHROPIC_API_KEY; let detectTokens = { input: 0, output: 0 }; @@ -656,9 +657,18 @@ export async function gradeDetailedLLM(frontUrl, backUrl, config, extraImages = console.warn(`[grade] corner crop failed, using full images: ${e.message || e}`); } + const frontHint = centeringHint?.front; + const backHint = centeringHint?.back; + const frontCenteringSuffix = frontHint + ? `USER MEASUREMENT: The user aligned a centering tool and measured L/R ${frontHint.lr}, T/B ${frontHint.tb}. Use this as your primary reference — verify it matches what you see, but trust the measurement unless the image clearly contradicts it.` + : ""; + const backCenteringSuffix = backHint + ? `USER MEASUREMENT: The user aligned a centering tool and measured L/R ${backHint.lr}, T/B ${backHint.tb}. Use this as your primary reference — verify it matches what you see, but trust the measurement unless the image clearly contradicts it.` + : ""; + const subgradeCalls = [ - gradeSubgrade("centering_front", [frontBlock], config), - gradeSubgrade("centering_back", hasBack ? [backBlock] : [frontBlock], config), + gradeSubgrade("centering_front", [frontBlock], config, [], frontCenteringSuffix), + gradeSubgrade("centering_back", hasBack ? [backBlock] : [frontBlock], config, [], backCenteringSuffix), gradeSubgrade("corners_front", [frontBlock], config, frontCornerBlocks), hasBack ? gradeSubgrade("corners_back", [backBlock], config, backCornerBlocks) : null, gradeSubgrade("edges_front", [frontBlock], config), @@ -737,7 +747,7 @@ export async function gradeDetailedLLM(frontUrl, backUrl, config, extraImages = }; } -export async function gradeImage(imageUrl, config, extraImages = []) { +export async function gradeImage(imageUrl, config, extraImages = [], centeringHint = null) { if (!config.aiGrading.enabled) return null; if (!imageUrl) return null; try { @@ -759,7 +769,7 @@ export async function gradeImage(imageUrl, config, extraImages = []) { const backImg = extraImages[0]?.imageUrl || extraImages[0] || null; const remainingExtras = extraImages.slice(1); if (backImg && config.aiGrading.llm.provider === "claude") { - result = await gradeDetailedLLM(imageUrl, backImg, config, remainingExtras); + result = await gradeDetailedLLM(imageUrl, backImg, config, remainingExtras, centeringHint); } if (!result) { result = await gradeViaLLM(imageUrl, config, extraImages); diff --git a/test/unit-test.js b/test/unit-test.js index d14fb40..9014c65 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -1718,6 +1718,35 @@ test("deriveEra: unknown prefix = Other", () => { eq(deriveEra("zzz999"), "Other"); }); +// ── centering hint ── + +console.log("\n\x1b[1m=== centering hint ===\x1b[0m"); + +test("centering hint: builds suffix from front hint", () => { + const hint = { front: { lr: "57/43", tb: "52/48" } }; + const suffix = hint.front + ? `USER MEASUREMENT: The user aligned a centering tool and measured L/R ${hint.front.lr}, T/B ${hint.front.tb}.` + : ""; + assert(suffix.includes("57/43"), "should include lr ratio"); + assert(suffix.includes("52/48"), "should include tb ratio"); +}); + +test("centering hint: null hint produces empty suffix", () => { + const hint = null; + const suffix = hint?.front + ? `USER MEASUREMENT: L/R ${hint.front.lr}, T/B ${hint.front.tb}.` + : ""; + eq(suffix, ""); +}); + +test("centering hint: missing back hint produces empty suffix", () => { + const hint = { front: { lr: "55/45", tb: "50/50" } }; + const suffix = hint?.back + ? `USER MEASUREMENT: L/R ${hint.back.lr}, T/B ${hint.back.tb}.` + : ""; + eq(suffix, ""); +}); + // ── Summary ── console.log(`\n\x1b[1m=== ${passed} passed, ${failed} failed ===\x1b[0m\n`); From 3f2d452b7d8381ae487cf36adc7e8dd283cfaefe Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Fri, 15 May 2026 23:14:25 +0530 Subject: [PATCH 3/3] feat: grade probability distribution, shareable report PNG endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - computeGradeDistribution(overall, confidence) returns PSA grade probabilities (e.g. PSA 8: 65%, 8.5: 12%, 7.5: 23%) - gradeDistribution added to all grade responses (v3 detailed, Claude single, OpenAI single) - GET /api/grade/report/:id generates shareable PNG card (SVG→sharp→PNG) with scores, bars, distribution, limiter - 224 unit tests (6 new for distribution: high/low confidence, edge grades, half grades, snapping) --- api.js | 60 ++++++++++++++++++++++++++++++++++++++++++ lib/grading/grading.js | 33 +++++++++++++++++++++++ test/unit-test.js | 49 +++++++++++++++++++++++++++++++++- 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/api.js b/api.js index 7b37b59..09ad060 100644 --- a/api.js +++ b/api.js @@ -475,6 +475,66 @@ app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "g } }); +// GET /api/grade/report/:id — shareable grade report as PNG +app.get("/api/grade/report/:id", async (req, res) => { + try { + const records = await getGradeLogs({ limit: 1, query: req.params.id }); + const record = records.find(r => r.id === req.params.id); + if (!record?.grade || record.grade.error) return res.status(404).json({ error: "Grade not found" }); + + const { default: sharp } = await import("sharp"); + const grade = record.grade; + const overall = grade.overall || "?"; + const conf = Math.round((grade.confidence || 0) * 100); + const dist = grade.gradeDistribution || {}; + const limiter = grade.notes || ""; + + const scores = [ + ["Centering", grade.centering], + ["Corners", grade.corners], + ["Edges", grade.edges], + ["Surface", grade.surface], + ]; + + const distLines = Object.entries(dist) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([g, p]) => `PSA ${g}: ${p}%`) + .join(" · "); + + const barsSvg = scores.map(([name, score], i) => { + const y = 180 + i * 48; + const barW = Math.round(((score || 0) / 10) * 260); + const color = score >= 9 ? "#7ce0a8" : score >= 7 ? "#d9b676" : "#ff5d5d"; + return ` + ${name} + ${score || "?"} + + + `; + }).join(""); + + const svg = ` + + + CASECOMP AI GRADE + ${overall} + ${conf}% confidence + ${distLines} + ${barsSvg} + ${limiter.substring(0, 60)} + casecomp.xyz + `; + + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + res.setHeader("Content-Type", "image/png"); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.send(png); + } catch (e) { + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + // GET /api/grades app.get("/api/grades", authMiddleware, async (req, res) => { const limit = Math.min(1000, Math.max(1, Number(req.query.limit) || 100)); diff --git a/lib/grading/grading.js b/lib/grading/grading.js index 0f76992..974f8f5 100644 --- a/lib/grading/grading.js +++ b/lib/grading/grading.js @@ -422,6 +422,7 @@ export async function gradeViaClaude(imageUrl, config, extraImages = []) { if (!result.error) { result.tokenUsage = { input: usage.input_tokens || 0, output: usage.output_tokens || 0 }; result.estimatedCost = ((usage.input_tokens || 0) * 3 + (usage.output_tokens || 0) * 15) / 1_000_000; + result.gradeDistribution = computeGradeDistribution(result.overall, result.confidence); } return result; } @@ -466,6 +467,7 @@ export async function gradeViaOpenAI(imageUrl, config, extraImages = []) { if (!result.error) { result.tokenUsage = { input: usage.prompt_tokens || 0, output: usage.completion_tokens || 0 }; result.estimatedCost = ((usage.prompt_tokens || 0) * 2.5 + (usage.completion_tokens || 0) * 10) / 1_000_000; + result.gradeDistribution = computeGradeDistribution(result.overall, result.confidence); } return result; } @@ -597,6 +599,36 @@ async function gradeSubgrade(subgrade, imageBlocks, config, extraBlocks = [], pr return result; } +export function computeGradeDistribution(overall, confidence) { + const grades = [10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6, 5.5, 5]; + const idx = grades.indexOf(overall); + if (idx === -1) { + const closest = grades.reduce((a, b) => Math.abs(b - overall) < Math.abs(a - overall) ? b : a); + return computeGradeDistribution(closest, confidence); + } + const conf = Math.max(0.3, Math.min(1, confidence)); + const primaryPct = Math.round(conf * 60 + 20); + const remaining = 100 - primaryPct; + const dist = {}; + dist[String(overall)] = primaryPct; + + const above = idx > 0 ? grades[idx - 1] : null; + const below = idx < grades.length - 1 ? grades[idx + 1] : null; + + if (above && below) { + const abovePct = Math.round(remaining * 0.35); + const belowPct = remaining - abovePct; + dist[String(above)] = abovePct; + dist[String(below)] = belowPct; + } else if (above) { + dist[String(above)] = remaining; + } else if (below) { + dist[String(below)] = remaining; + } + + return dist; +} + export function roundGrade(raw) { const frac = raw - Math.floor(raw); if (frac < 0.25) return Math.floor(raw); @@ -738,6 +770,7 @@ export async function gradeDetailedLLM(frontUrl, backUrl, config, extraImages = notes: `Grade limiter: ${lowest.key} — ${lowest.detail}`, limitations: hasBack ? "" : "Back not available — back subgrades estimated from front.", subgradeDetails, + gradeDistribution: computeGradeDistribution(overall, clampConf(avgConf)), cardDetection: { front: frontDetect.cropped ? { ...frontDetect.bounds, tiltDeg: frontDetect.tiltDeg || 0 } : null, back: backDetect?.cropped ? { ...backDetect.bounds, tiltDeg: backDetect.tiltDeg || 0 } : null, diff --git a/test/unit-test.js b/test/unit-test.js index 9014c65..360800c 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -1,4 +1,4 @@ -import { parseGradeJSON, roundGrade, validateAndShape } from "../lib/grading/grading.js"; +import { parseGradeJSON, roundGrade, validateAndShape, computeGradeDistribution } from "../lib/grading/grading.js"; import { buildSignal } from "../lib/grading/psa.js"; import { deriveEra } from "../lib/cards/card-database.js"; import { cornerCropsToImageBlocks, imageBlockFromUrl, imageBlockFromBase64, parseAnthropicResponse, parseTogetherResponse } from "../lib/grading/preprocessing.js"; @@ -1718,6 +1718,53 @@ test("deriveEra: unknown prefix = Other", () => { eq(deriveEra("zzz999"), "Other"); }); +// ── gradeDistribution ── + +console.log("\n\x1b[1m=== gradeDistribution ===\x1b[0m"); + +test("gradeDistribution: high confidence concentrates on primary grade", () => { + const dist = computeGradeDistribution(8, 0.9); + assert(dist["8"] >= 70, `primary should be >=70%, got ${dist["8"]}`); + const total = Object.values(dist).reduce((a, b) => a + b, 0); + eq(total, 100); +}); + +test("gradeDistribution: low confidence spreads more", () => { + const dist = computeGradeDistribution(8, 0.3); + assert(dist["8"] <= 55, `primary should be <=55% at low conf, got ${dist["8"]}`); + const total = Object.values(dist).reduce((a, b) => a + b, 0); + eq(total, 100); +}); + +test("gradeDistribution: PSA 10 has no grade above", () => { + const dist = computeGradeDistribution(10, 0.8); + assert(dist["10"] > 0, "should have PSA 10"); + assert(!dist["10.5"], "should not have grade above 10"); + const total = Object.values(dist).reduce((a, b) => a + b, 0); + eq(total, 100); +}); + +test("gradeDistribution: PSA 5 has no grade below in our list", () => { + const dist = computeGradeDistribution(5, 0.7); + assert(dist["5"] > 0, "should have PSA 5"); + const total = Object.values(dist).reduce((a, b) => a + b, 0); + eq(total, 100); +}); + +test("gradeDistribution: half grades work", () => { + const dist = computeGradeDistribution(8.5, 0.7); + assert(dist["8.5"] > 0, "should have 8.5"); + const total = Object.values(dist).reduce((a, b) => a + b, 0); + eq(total, 100); +}); + +test("gradeDistribution: non-standard grade snaps to nearest", () => { + const dist = computeGradeDistribution(8.3, 0.7); + assert(dist["8.5"] > 0 || dist["8"] > 0, "should snap to nearest valid grade"); + const total = Object.values(dist).reduce((a, b) => a + b, 0); + eq(total, 100); +}); + // ── centering hint ── console.log("\n\x1b[1m=== centering hint ===\x1b[0m");