diff --git a/.claude/commands/practices.md b/.claude/commands/practices.md new file mode 100644 index 0000000..157a099 --- /dev/null +++ b/.claude/commands/practices.md @@ -0,0 +1,129 @@ +# /practices — Casecomp coding practices + +Reference guide for patterns and conventions observed in this codebase. Use when writing new code to stay consistent. + +## API endpoint pattern + +```javascript +app.get("/api/endpoint", apiAuthMiddleware, async (req, res) => { + try { + // validate input + // business logic + res.json({ data }); + } catch (e) { + logError("endpoint-name", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); +``` + +- Always use `safeErrorMessage(e)` — never leak raw `e.message` +- Always include `requestId` in error responses +- Use `apiAuthMiddleware` for read endpoints (allows `?demo=true`) +- Use `authMiddleware` for write endpoints (no demo bypass) +- Use `ownerOnly` for admin endpoints +- Use `isAdminUser(req)` for Google OAuth admin checks + +## Auth levels + +``` +ownerOnly → CASECOMP_API_KEY only +isAdminUser(req) → CASECOMP_ADMIN_SUB match or owner key +authMiddleware → owner + sandbox + JWT + developer keys +apiAuthMiddleware → authMiddleware + ?demo=true bypass +(none) → public endpoint +``` + +## AI grading pipeline (v3) + +8 subgrades: `centering_front`, `centering_back`, `corners_front`, `corners_back`, `edges_front`, `edges_back`, `surface_front`, `surface_back`. + +Pipeline: `detectAndCropCard()` → `cropCorners()` per side → 8x `gradeSubgrade()` → `roundGrade()`. + +``` +frontOverall = avg(4 front scores) +backOverall = avg(4 back scores) +raw = (frontOverall * 0.60) + (backOverall * 0.40) +overall = roundGrade(min(raw, lowestSubgrade + 1)) +``` + +- Card detection uses Haiku (cheapest), subgrades use configured model +- `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 +- Response includes `cardDetection.front`/`.back` with crop bounds when background detected +- Mode: `"llm-detailed-v3"` (distinguishes from v2 `"llm-detailed"`) + +## Firestore patterns + +- Collection per feature: `api-keys`, `portfolios`, `price-history`, `api-analytics`, `error-logs`, `grading-dataset` +- Portfolio path: `portfolios/{userId}/cards/{cardId_escaped}` (slash → underscore) +- Cache collections: `cache-grades`, `cache-psa-pop`, `cache-psa-spec`, `cache-ebay-active` +- Always `try { ... } catch {}` for non-critical Firestore writes (analytics, search frequency) +- Use `Firestore.FieldValue.increment(1)` for counters +- TTL via `ts` field + Firestore TTL policy (api-analytics: 30d) + +## Error handling + +- `safeErrorMessage(e)` sanitizes: network errors → "Upstream service unavailable", auth → "Authentication error", Firestore/gRPC → "Internal storage error" +- Fire-and-forget for non-critical ops: `logRequest({...}).catch(() => {})` +- Always catch Firestore writes in analytics/logging paths + +## Demo data pattern + +```javascript +if (req.query.demo === "true") { + // return canned data from lib/data/demo.js + return res.json({ ...demoData, _demo: true }); +} +// ... live data path +``` + +- `_demo: true` flag in response when serving demo data +- 3 demo cards: sv8a/217-187 (Umbreon), m4/114-083 (Greninja), m2a/234-193 (Pikachu) +- Demo rate limit: 360 req/min + +## New secret workflow + +1. Add to `terraform/secrets.tf` locals.secrets list +2. Push → CI creates the empty secret +3. `gcloud secrets versions add SECRET_NAME --data-file=- --project=casecomp-495718` +4. Never `gcloud secrets create` (conflicts with Terraform) + +## Testing pattern + +Unit tests (test/unit-test.js, ~172 tests): +```javascript +test("descriptive name", () => { + eq(actualValue, expectedValue); +}); +``` +- 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 + +API tests (test/api-test.js, ~130 tests): +```javascript +await test("GET /api/endpoint returns expected", async () => { + const { res, body } = await jsonNoAuth("/api/endpoint?demo=true"); + assert(res.status === 200, `status ${res.status}`); +}); +``` +- `json()` for auth'd requests, `jsonNoAuth()` for public +- Auth tests accept both success and 401 (local dev disables auth) +- 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 + +- Endpoints: `/api/noun` (GET list, POST create), `/api/noun/:id` (GET/PATCH/DELETE) +- Firestore collections: kebab-case (`api-keys`, `price-history`) +- Functions: camelCase (`getPortfolio`, `computePriceTrend`) +- Files: kebab-case (`card-database.js`, `price-history.js`, `grading-dataset.js`) +- Card IDs: `setCode/localId-total` (e.g. `sv8a/217-187`) + +## Git conventions + +- Prefixes: `feat:`, `fix:`, `docs:`, `ci:`, `sec:`, `infra:`, `refactor:`, `test:`, `chore:` +- No Co-Authored-By, no "Generated with Claude Code" +- Push to dev or main directly (no mandatory PR for solo dev) +- CI required: unit + codeql. Smoke is non-blocking. diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..6021e0e --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,7 @@ +[allowlist] +description = "Known false positives" +regexes = [ + '''claude-haiku-4-5-20251001''', + '''claude-sonnet-4-6''', + '''claude-opus-4-7''', +] diff --git a/CHANGELOG.md b/CHANGELOG.md index 36eef5a..36ef821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## Unreleased +## 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 + +## 1.2.0 (2026-05-15) + ## 1.1.0 (2026-05-15) ### Added diff --git a/README.md b/README.md index dc2bf7c..b844e31 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Casecomp logo Casecomp -[![Version](https://img.shields.io/badge/version-1.2.0-d9b676)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.3.0-d9b676)](CHANGELOG.md) [![CI](https://github.com/Pyronewbic/casecomp/actions/workflows/ci.yml/badge.svg)](https://github.com/Pyronewbic/casecomp/actions/workflows/ci.yml) [![Deploy](https://github.com/Pyronewbic/casecomp/actions/workflows/deploy.yml/badge.svg)](https://github.com/Pyronewbic/casecomp/actions/workflows/deploy.yml) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) @@ -19,7 +19,7 @@ Search any Pokemon card across four marketplaces in one query. Get live prices, - **Multi-source search** - eBay, magi.camp, Yahoo Auctions JP, SNKRDUNK in one query - **Cross-source arbitrage** - compares lowest prices across sources, highlights spread - **Condition detection** - auto-detects card condition across sources (EN: NM/LP/MP, JP: 状態A/美品) -- **AI pre-grading** - per-subgrade analysis (centering, corners, edges, surface) from listing photos +- **AI pre-grading** - 8-subgrade front/back analysis with card detection, 60/40 weighting, PSA rubric - **Price history** - sold comp tracking over time with line charts and stats - **PSA grading signals** - population data, difficulty, gem 10%, recommended submission tier - **Slab comparison** - compare PSA 10 / BGS 9.5 / TAG 10 prices across sources diff --git a/api.js b/api.js index 5116a13..86ea160 100644 --- a/api.js +++ b/api.js @@ -22,6 +22,7 @@ import { createApiKey, listApiKeys, listAllKeys, listKeysByOwner, getApiKey, upd import { recordSoldPrices, getPriceHistory, computePriceTrend } from "./lib/data/price-history.js"; import { sendAlertEmail } from "./lib/data/email.js"; import { logRequest, getAnalytics, getAnalyticsByUser } from "./lib/data/analytics.js"; +import { saveGradedImages } from "./lib/data/grading-dataset.js"; import { verifyGoogleToken, generateJwt, verifyJwt } from "./lib/data/auth.js"; import { seedFromTCGPlayer } from "./lib/sources/tcgplayer.js"; import { getOrCreateCard, findCardByQuery, parseCardIdentity, resolveCardIdToQuery, SET_NAME_MAP } from "./lib/data/card-identity.js"; @@ -518,6 +519,17 @@ app.get("/api/analytics", ownerOnly, async (req, res) => { } }); +// GET /api/grading-dataset/stats +app.get("/api/grading-dataset/stats", ownerOnly, async (req, res) => { + try { + const { getDatasetStats } = await import("./lib/data/grading-dataset.js"); + const stats = await getDatasetStats(); + res.json(stats); + } catch (e) { + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + // GET /api/health app.get("/api/health", async (req, res) => { const firestoreStatus = await getFirestoreStatus(); @@ -1915,6 +1927,7 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { ebaySold = soldRes.items || []; if (ebaySold.length) { await recordSoldPrices(card, ebaySold, "ebay"); + saveGradedImages(ebaySold, "ebay").catch(() => {}); } } catch (e) { logError("track-prices", `eBay fetch failed for "${card}": ${e.message}`, "/api/track-prices"); diff --git a/docs/internals.md b/docs/internals.md index 4ad38fd..246536b 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -15,8 +15,8 @@ lib/ snkrdunk.js SNKRDUNK JSON API tcgplayer.js TCGPlayer price seeding grading/ - grading.js AI pre-grading (per-subgrade, Claude/OpenAI) - preprocessing.js Corner crop extraction via sharp + grading.js AI pre-grading (8-subgrade v3, Claude/OpenAI) + preprocessing.js Card detection, corner crops, SSRF-safe image fetch psa.js PSA pop reports, cert lookup, grading signal psaTiers.js PSA submission tier data data/ @@ -31,6 +31,9 @@ lib/ email.js Alert emails via Resend csv.js CSV export helpers portfolio.js Portfolio CRUD (Firestore subcollection) + analytics.js Request analytics (Firestore, 30d TTL) + auth.js Google OAuth token verification, JWT (HS256) + grading-dataset.js ML slab image collection from eBay sold listings search/ filters.js Language, relevance, condition detection, outlier flagging listingQuery.js eBay search query builder (raw vs slab) @@ -44,8 +47,8 @@ public/admin/ Admin panel (keys, stats, errors) extension/ Chrome extension: queue auto-join, drop intel terraform/ GCP infra (Cloud Run, Firestore, LB, CDN, Scheduler) test/ - unit-test.js 140 unit tests - api-test.js 99 API integration tests + unit-test.js 172 unit tests + api-test.js ~130 API integration tests smoke-test.js 74 Playwright UI smoke tests ``` @@ -53,8 +56,8 @@ test/ `api.js` is the primary entry point for production. Express 5 with: -- **Auth middleware**: owner key (`CC_LIVE_`) → sandbox → Firestore developer keys (30s cache). `apiAuthMiddleware` adds demo bypass. -- **Rate limiting**: 60/min authenticated, 360/min demo, 5/min sandbox. +- **Auth middleware**: owner key (`CC_LIVE_`) → sandbox → JWT (Google OAuth) → Firestore developer keys (30s cache). `apiAuthMiddleware` adds demo bypass. +- **Rate limiting**: 60/min authenticated, 360/min demo, 5/min sandbox, 10/min auth endpoint. - **Security**: Helmet headers, trust proxy = 1, request IDs, compression, `safeErrorMessage()` on all errors. - **CORS**: wildcard `*` — API key is the access control layer. - **Dashboard**: static files from `public/` served at `/` and `/admin`. @@ -93,6 +96,8 @@ All caches use Firestore (shared across Cloud Run instances, single region). No | `price-history` | permanent | Sold comp prices over time | | `api-keys` | permanent | Developer API keys (hashed) | | `error-logs` | permanent | API errors with request IDs | +| `api-analytics` | 30 days | Request analytics (tier, path, latency) | +| `grading-dataset` | permanent | ML training data: slab images + parsed grades | Stale-while-revalidate on active listings for owner key. File-based cache (`.json` files) still used by the CLI. @@ -117,14 +122,19 @@ Use `--refresh` to delete all cache files before a run. 5. `portfolioUserId`: JWT users get Google `sub` as userId. API key users get SHA256 hash of key (first 16 chars). 6. Developer self-serve: `GET/POST/DELETE /api/developer/keys` + `GET /api/developer/stats`. Keys linked to Google account via `ownerId`. Usage stats aggregated from `api-analytics` collection. -## AI grading pipeline +## AI grading pipeline (v3) 1. Listing images fetched, upgraded to `s-l1600` resolution for eBay. -2. `preprocessing.js` crops 4 corners (20% region) from front + back via `sharp` (~100ms). -3. Four parallel LLM calls: centering, corners, edges, surface — each with the full PSA rubric (grades 5-10). -4. Corners subgrade receives front + back URLs + 8 magnified corner crops. Others receive all listing images. -5. Overall = minimum of all subgrades (matches PSA methodology). -6. Falls back to single combined prompt for non-Claude providers or missing back image. +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. +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). + +**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. ## Security pipeline @@ -134,7 +144,7 @@ Three workflows: `ci.yml` (all checks), `deploy.yml` (build + sign + deploy), `t | Job | What | Required? | |-----|------|-----------| -| unit | 140 unit tests | Yes | +| unit | 172 unit tests | Yes | | smoke | 74 Playwright smoke tests | No (continue-on-error) | | codeql | SAST for JavaScript/TypeScript | Yes | | scan | SBOM (Syft) + Grype vulnerability scan | No | diff --git a/lib/data/grading-dataset.js b/lib/data/grading-dataset.js new file mode 100644 index 0000000..73e4e1a --- /dev/null +++ b/lib/data/grading-dataset.js @@ -0,0 +1,71 @@ +import { Firestore } from "@google-cloud/firestore"; + +const COLLECTION = "grading-dataset"; + +let db = null; +function getDb() { + if (db) return db; + try { db = new Firestore(); return db; } catch { return null; } +} + +export async function saveGradedImages(items, source) { + const fs = getDb(); + if (!fs || !items?.length) return 0; + + let saved = 0; + const batch = fs.batch(); + + for (const item of items) { + if (!item.listingGradeLabel || !item.imageUrl) continue; + + const gradeMatch = item.listingGradeLabel.match(/(?:PSA|BGS|CGC|TAG)\s*(\d+\.?\d*)/i); + if (!gradeMatch) continue; + + const grade = parseFloat(gradeMatch[1]); + if (grade < 1 || grade > 10) continue; + + const provider = item.listingGradeLabel.match(/PSA|BGS|CGC|TAG/i)?.[0]?.toUpperCase() || "UNKNOWN"; + const docId = `${source}_${item.itemId || Date.now()}_${saved}`; + + batch.set(fs.collection(COLLECTION).doc(docId), { + imageUrl: item.imageUrl, + additionalImages: (item.additionalImages || []).map(i => i.imageUrl).filter(Boolean).slice(0, 4), + grade, + provider, + title: (item.title || "").substring(0, 150), + price: item.price || null, + source, + soldDate: item.soldDate || null, + collectedAt: new Date().toISOString(), + }, { merge: true }); + + saved++; + } + + if (saved > 0) { + try { await batch.commit(); } catch {} + } + return saved; +} + +export async function getDatasetStats() { + const fs = getDb(); + if (!fs) return { total: 0, byGrade: {}, byProvider: {} }; + + try { + const snap = await fs.collection(COLLECTION).limit(10000).get(); + const byGrade = {}; + const byProvider = {}; + + for (const doc of snap.docs) { + const d = doc.data(); + const g = String(d.grade); + byGrade[g] = (byGrade[g] || 0) + 1; + byProvider[d.provider] = (byProvider[d.provider] || 0) + 1; + } + + return { total: snap.size, byGrade, byProvider }; + } catch { + return { total: 0, byGrade: {}, byProvider: {} }; + } +} diff --git a/lib/grading/grading.js b/lib/grading/grading.js index 6934dbe..3c89c9c 100644 --- a/lib/grading/grading.js +++ b/lib/grading/grading.js @@ -1,24 +1,24 @@ import axios from "axios"; import { sha256 } from "../data/cache.js"; import { cacheGet, cacheSet } from "../data/firestore.js"; -import { cropCorners, cornerCropsToImageBlocks } from "./preprocessing.js"; +import { cropCorners, cornerCropsToImageBlocks, detectAndCropCard, imageBlockFromUrl, imageBlockFromBase64 } from "./preprocessing.js"; const CACHE_COLLECTION = "cache-grades"; const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; const SUBGRADE_PROMPTS = { - centering: `Grade ONLY the centering of this Pokémon trading card photo. + centering_front: `Grade ONLY the centering of the FRONT of this Pokémon trading card. You are receiving ONLY the front 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. Note which direction any shift goes (e.g. "shifted slightly left" or "heavier bottom border"). -PSA CENTERING THRESHOLDS (front / back): -- 10 (Gem Mint): 55/45 or better front, 75/25 or better back -- 9 (Mint): 60/40 front, 90/10 back -- 8 (NM-MT): 65/35 front, 90/10 back -- 7 (NM): 70/30 front, 90/10 back -- 6 (EX-MT): 80/20 front, 90/10 back +PSA FRONT CENTERING THRESHOLDS: +- 10 (Gem Mint): 55/45 or better +- 9 (Mint): 60/40 +- 8 (NM-MT): 65/35 +- 7 (NM): 70/30 +- 6 (EX-MT): 80/20 SCORING GUIDE: - 10: Borders appear equal on all sides within normal printing tolerance @@ -32,7 +32,32 @@ If the photo angle is steep or the card is heavily tilted, set confidence below Respond ONLY with valid JSON (no markdown): {"score": , "confidence": , "detail": ""}`, - corners: `Grade ONLY the corners of this Pokémon trading card photo. Examine each of the 4 corners individually: top-left, top-right, bottom-left, bottom-right. + 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. + +PSA BACK CENTERING THRESHOLDS (more lenient than front): +- 10 (Gem Mint): 75/25 or better +- 9 (Mint): 90/10 +- 8 (NM-MT): 90/10 +- 7 (NM): 90/10 +- 6 (EX-MT): 90/10 + +SCORING GUIDE: +- 10: Borders appear equal on all sides within normal printing tolerance +- 9: Slight shift in one direction, still within 90/10 +- 8: Noticeable shift but within 90/10 tolerance +- 7: Shift approaching 90/10 boundary +- 6 or below: Exceeds 90/10 — severely off-center + +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": ""}`, + + 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. WHAT TO LOOK FOR per corner: - Whitening: white fibers visible along the corner edge (most common defect) @@ -56,7 +81,31 @@ If the photo lacks close-up detail, note which corners you can and cannot assess Respond ONLY with valid JSON (no markdown): {"score": , "confidence": , "detail": ""}`, - edges: `Grade ONLY the edges of this Pokémon trading card photo. Examine each of the 4 edges individually: top, bottom, left, right. + corners_back: `Grade ONLY the corners of the BACK of this Pokémon trading card. You are receiving ONLY the back image (plus close-up corner crops if available). Examine each of the 4 corners individually: top-left, top-right, bottom-left, bottom-right. + +WHAT TO LOOK FOR per corner: +- Whitening: white fibers visible along the corner edge (most common defect on backs) +- Rounding: corner lost its sharp point, appears soft or curved +- Dings/dents: physical impact marks, often small indentations +- Lifting: layers of cardstock separating at the corner +- Fuzzing: frayed edge fibers at the corner point + +Back corners often show MORE wear than front corners — cards stored in sleeves protect the front while the back contacts the sleeve directly. + +PSA CORNER THRESHOLDS: +- 10 (Gem Mint): All 4 corners sharp and clean, no whitening under magnification +- 9 (Mint): Corners sharp, may have one tiny spot of whitening only visible under magnification +- 8 (NM-MT): Minor whitening on 1-2 corners, still sharp points +- 7 (NM): Slight whitening or softness on 2-3 corners +- 6 (EX-MT): Noticeable whitening or slight rounding on multiple corners +- 5 (EX): Moderate whitening, possible rounding on 1+ corners + +If the photo lacks close-up detail, note which corners you can and cannot assess clearly, and set confidence accordingly. + +Respond ONLY with valid JSON (no markdown): +{"score": , "confidence": , "detail": ""}`, + + edges_front: `Grade ONLY the edges of the FRONT of this Pokémon trading card. You are receiving ONLY the front image. Examine each of the 4 edges individually: top, bottom, left, right. WHAT TO LOOK FOR per edge: - Whitening: white line along the edge where color has worn away @@ -75,14 +124,36 @@ PSA EDGE THRESHOLDS: DARK-BORDERED CARDS: Edge whitening is far more visible against dark/black card borders. Apply stricter standards. -BACK EDGES: If a back image is provided, back edges often show more wear than the front — check carefully. +If the photo lacks close-up detail, note which edges you can assess and set confidence accordingly. + +Respond ONLY with valid JSON (no markdown): +{"score": , "confidence": , "detail": ""}`, + + edges_back: `Grade ONLY the edges of the BACK of this Pokémon trading card. You are receiving ONLY the back image. Examine each of the 4 edges individually: top, bottom, left, right. + +WHAT TO LOOK FOR per edge: +- Whitening: white line along the edge where color has worn away +- Chipping: small chips or flakes missing from the edge +- Nicks: tiny cuts or indentations along the edge +- Roughness: uneven or jagged edge surface +- Peeling: cardstock layers separating along the edge + +Back edges typically show MORE wear than front edges. The standard Pokémon card back has a blue border where whitening is clearly visible. + +PSA EDGE THRESHOLDS: +- 10 (Gem Mint): All edges clean and smooth, no whitening or wear +- 9 (Mint): Edges clean, one minor spot of whitening only visible under magnification +- 8 (NM-MT): Minor whitening on 1-2 edges, no chipping +- 7 (NM): Light whitening along 2+ edges, or one small chip +- 6 (EX-MT): Noticeable whitening on multiple edges, minor chipping possible +- 5 (EX): Moderate whitening, possible chipping on 1+ edges If the photo lacks close-up detail, note which edges you can assess and set confidence accordingly. Respond ONLY with valid JSON (no markdown): {"score": , "confidence": , "detail": ""}`, - surface: `Grade ONLY the surface of this Pokémon trading card photo. Assess the entire printable area of the card. + surface_front: `Grade ONLY the surface of the FRONT of this Pokémon trading card. You are receiving ONLY the front image. Assess the entire printable area including artwork and borders. WHAT TO LOOK FOR: - Scratches: linear marks across the surface, often visible when light catches them @@ -107,6 +178,31 @@ PHOTO QUALITY: Listing photos are often low-resolution or poorly lit. Surface de Respond ONLY with valid JSON (no markdown): {"score": , "confidence": , "detail": ""}`, + + surface_back: `Grade ONLY the surface of the BACK of this Pokémon trading card. You are receiving ONLY the back image. Assess the entire back surface. + +WHAT TO LOOK FOR: +- Scratches: linear marks across the surface, common from sleeve contact +- Whitening/scuffing: surface wear showing lighter patches on the blue Pokémon card back +- Print lines: factory printing defects, thin lines running through the card +- Dents/indentations: depressions in the cardstock visible as shadows +- Surface contamination: fingerprints, residue, sticker marks, or foreign material +- Creasing: any crease, even minor, severely limits grade (PSA 5 max for crease <1 inch) + +The standard Pokémon card back is uniform blue with a Poké Ball design — surface defects are often easier to spot on this consistent pattern than on the varied front artwork. + +PSA SURFACE THRESHOLDS: +- 10 (Gem Mint): Surface immaculate, no defects visible even under magnification +- 9 (Mint): Surface clean, one minor imperfection allowed if not immediately noticeable +- 8 (NM-MT): Minor surface wear or scuffing, no scratches +- 7 (NM): Light surface wear, minor scuffing, or one faint scratch +- 6 (EX-MT): Noticeable surface wear, light scratches, or whitening patches +- 5 (EX): Moderate scratches, whitening, or minor surface damage + +PHOTO QUALITY: Listing photos are often low-resolution or poorly lit. If the image quality prevents confident surface assessment, set confidence below 0.5. + +Respond ONLY with valid JSON (no markdown): +{"score": , "confidence": , "detail": ""}`, }; const GRADING_PROMPT = `You are estimating the PSA grade for a Pokémon trading card based on listing photos. You may receive 1-2 images (front, back, or both). @@ -316,17 +412,18 @@ export async function gradeViaClaude(imageUrl, config, extraImages = []) { const text = res.data?.content?.map((b) => (b.type === "text" ? b.text : "")).join("") || ""; + const usage = res.data?.usage || {}; const parsed = parseGradeJSON(text); if (parsed.error) { console.warn(`[grade] Claude parse: ${parsed.error}`); return { error: parsed.error, raw: res.data }; } - return validateAndShape( - "claude", - "llm", - parsed.ok, - res.data, - ); + const result = validateAndShape("claude", "llm", parsed.ok, res.data); + 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; + } + return result; } export async function gradeViaOpenAI(imageUrl, config, extraImages = []) { @@ -359,12 +456,18 @@ export async function gradeViaOpenAI(imageUrl, config, extraImages = []) { }), ); const text = res.data?.choices?.[0]?.message?.content || ""; + const usage = res.data?.usage || {}; const parsed = parseGradeJSON(text); if (parsed.error) { console.warn(`[grade] OpenAI parse: ${parsed.error}`); return { error: parsed.error, raw: res.data }; } - return validateAndShape("openai", "llm", parsed.ok, res.data); + const result = validateAndShape("openai", "llm", parsed.ok, res.data); + 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; + } + return result; } export async function gradeViaLLM(imageUrl, config, extraImages = []) { @@ -468,11 +571,10 @@ export async function gradeViaSite(imageUrl, config) { } } -async function gradeSubgrade(subgrade, imageUrls, config, extraBlocks = []) { +async function gradeSubgrade(subgrade, imageBlocks, config, extraBlocks = []) { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) return null; await throttleLlm(); - const imageBlocks = imageUrls.filter(Boolean).map(url => ({ type: "image", source: { type: "url", url } })); const body = { model: config.aiGrading.llm.model, max_tokens: 200, @@ -485,59 +587,145 @@ async function gradeSubgrade(subgrade, imageUrls, config, extraBlocks = []) { }), ); const text = res.data?.content?.map(b => b.type === "text" ? b.text : "").join("") || ""; + 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 || "" }; + 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 } }; +} + +export function roundGrade(raw) { + const frac = raw - Math.floor(raw); + if (frac < 0.25) return Math.floor(raw); + if (frac < 0.75) return Math.floor(raw) + 0.5; + return Math.ceil(raw); } export async function gradeDetailedLLM(frontUrl, backUrl, config, extraImages = []) { - const known = new Set([frontUrl, backUrl].filter(Boolean)); - const deduped = extraImages - .map(e => (typeof e === "string" ? e : e?.imageUrl)) - .filter(u => u && !known.has(u)); - const allImages = [frontUrl, backUrl, ...deduped].filter(Boolean); + const hasBack = !!backUrl; + const apiKey = process.env.ANTHROPIC_API_KEY; + let detectTokens = { input: 0, output: 0 }; + + const detectJobs = [detectAndCropCard(frontUrl, apiKey, "claude-haiku-4-5-20251001")]; + if (hasBack) detectJobs.push(detectAndCropCard(backUrl, apiKey, "claude-haiku-4-5-20251001")); + const [frontDetect, backDetect] = await Promise.all(detectJobs); + + const frontBlock = frontDetect.cropped + ? imageBlockFromBase64(frontDetect.base64, frontDetect.mediaType) + : imageBlockFromUrl(frontUrl); + const frontSource = frontDetect.cropped ? frontDetect.buffer : frontUrl; + + let backBlock = null; + let backSource = null; + if (hasBack) { + backBlock = backDetect.cropped + ? imageBlockFromBase64(backDetect.base64, backDetect.mediaType) + : imageBlockFromUrl(backUrl); + backSource = backDetect.cropped ? backDetect.buffer : backUrl; + } + + if (frontDetect._tokens) { + detectTokens.input += frontDetect._tokens.input; + detectTokens.output += frontDetect._tokens.output; + } + if (backDetect?._tokens) { + detectTokens.input += backDetect._tokens.input; + detectTokens.output += backDetect._tokens.output; + } - let cornerBlocks = []; + if (frontDetect.cropped) console.log(`[grade] card detected in front image, cropped ${frontDetect.bounds.width}x${frontDetect.bounds.height} from ${frontDetect.originalSize.width}x${frontDetect.originalSize.height}`); + if (backDetect?.cropped) console.log(`[grade] card detected in back image, cropped ${backDetect.bounds.width}x${backDetect.bounds.height} from ${backDetect.originalSize.width}x${backDetect.originalSize.height}`); + + let frontCornerBlocks = []; + let backCornerBlocks = []; try { - const cropJobs = [frontUrl, backUrl].filter(Boolean).map(url => cropCorners(url)); - const allCrops = (await Promise.all(cropJobs)).flat(); - cornerBlocks = cornerCropsToImageBlocks(allCrops); + const frontCrops = await cropCorners(frontSource); + frontCornerBlocks = cornerCropsToImageBlocks(frontCrops); + if (hasBack) { + const backCrops = await cropCorners(backSource); + backCornerBlocks = cornerCropsToImageBlocks(backCrops); + } } catch (e) { console.warn(`[grade] corner crop failed, using full images: ${e.message || e}`); } - const primaryImages = [frontUrl, backUrl].filter(Boolean); - const [centering, corners, edges, surface] = await Promise.all([ - gradeSubgrade("centering", allImages, config), - gradeSubgrade("corners", primaryImages, config, cornerBlocks), - gradeSubgrade("edges", allImages, config), - gradeSubgrade("surface", allImages, config), - ]); + const subgradeCalls = [ + gradeSubgrade("centering_front", [frontBlock], config), + gradeSubgrade("centering_back", hasBack ? [backBlock] : [frontBlock], config), + gradeSubgrade("corners_front", [frontBlock], config, frontCornerBlocks), + hasBack ? gradeSubgrade("corners_back", [backBlock], config, backCornerBlocks) : null, + gradeSubgrade("edges_front", [frontBlock], config), + hasBack ? gradeSubgrade("edges_back", [backBlock], config) : null, + gradeSubgrade("surface_front", [frontBlock], config), + hasBack ? gradeSubgrade("surface_back", [backBlock], config) : null, + ]; + + const [cf, cb, crf, crb, ef, eb, sf, sb] = await Promise.all( + subgradeCalls.map(p => p || Promise.resolve(null)) + ); + + if (!cf || !crf || !ef || !sf) return null; + + const frontScores = [cf.score, crf.score, ef.score, sf.score]; + const frontOverall = frontScores.reduce((a, b) => a + b, 0) / 4; + + let backOverall; + let allSubgrades; + if (hasBack && cb && crb && eb && sb) { + const backScores = [cb.score, crb.score, eb.score, sb.score]; + backOverall = backScores.reduce((a, b) => a + b, 0) / 4; + allSubgrades = [ + { key: "centering_front", ...cf }, { key: "centering_back", ...cb }, + { key: "corners_front", ...crf }, { key: "corners_back", ...crb }, + { key: "edges_front", ...ef }, { key: "edges_back", ...eb }, + { key: "surface_front", ...sf }, { key: "surface_back", ...sb }, + ]; + } else { + backOverall = frontOverall; + allSubgrades = [ + { key: "centering_front", ...cf }, { key: "centering_back", ...(cb || cf) }, + { key: "corners_front", ...crf }, + { key: "edges_front", ...ef }, + { key: "surface_front", ...sf }, + ]; + } - if (!centering || !corners || !edges || !surface) return null; + const rawOverall = (frontOverall * 0.60) + (backOverall * 0.40); + const lowest = allSubgrades.sort((a, b) => a.score - b.score)[0]; + const overall = roundGrade(Math.min(rawOverall, lowest.score + 1)); - const overall = Math.min(centering.score, corners.score, edges.score, surface.score); - const avgConf = (centering.confidence + corners.confidence + edges.confidence + surface.confidence) / 4; + const allResults = [cf, cb, crf, crb, ef, eb, sf, sb].filter(Boolean); + const avgConf = allResults.reduce((a, r) => a + r.confidence, 0) / allResults.length; + const totalInput = allResults.reduce((a, r) => a + (r._tokens?.input || 0), 0); + const totalOutput = allResults.reduce((a, r) => a + (r._tokens?.output || 0), 0); - const lowest = [ - { name: "centering", ...centering }, - { name: "corners", ...corners }, - { name: "edges", ...edges }, - { name: "surface", ...surface }, - ].sort((a, b) => a.score - b.score)[0]; + const subgradeDetails = { centering_front: cf, centering_back: cb || cf }; + if (crf) subgradeDetails.corners_front = crf; + if (crb) subgradeDetails.corners_back = crb; + if (ef) subgradeDetails.edges_front = ef; + if (eb) subgradeDetails.edges_back = eb; + if (sf) subgradeDetails.surface_front = sf; + if (sb) subgradeDetails.surface_back = sb; return { provider: "claude", - mode: "llm-detailed", + mode: "llm-detailed-v3", overall, - centering: centering.score, - corners: corners.score, - edges: edges.score, - surface: surface.score, + frontOverall: roundGrade(frontOverall), + backOverall: roundGrade(backOverall), + centering: Math.min(cf.score, (cb || cf).score), + corners: Math.min(crf.score, (crb || crf).score), + edges: Math.min(ef.score, (eb || ef).score), + surface: Math.min(sf.score, (sb || sf).score), confidence: clampConf(avgConf), - notes: `Grade limiter: ${lowest.name} — ${lowest.detail}`, - limitations: backUrl ? "" : "Back not available — centering and edge grades are front-only estimates.", - subgradeDetails: { centering, corners, edges, surface }, + notes: `Grade limiter: ${lowest.key} — ${lowest.detail}`, + limitations: hasBack ? "" : "Back not available — back subgrades estimated from front.", + subgradeDetails, + cardDetection: { + front: frontDetect.cropped ? frontDetect.bounds : null, + back: backDetect?.cropped ? backDetect.bounds : 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 4b356ad..aee9ae5 100644 --- a/lib/grading/preprocessing.js +++ b/lib/grading/preprocessing.js @@ -1,17 +1,143 @@ import sharp from "sharp"; import axios from "axios"; +import { URL } from "url"; +import dns from "dns/promises"; +import net from "net"; const CORNER_RATIO = 0.20; +const CARD_AREA_THRESHOLD = 0.80; -export async function cropCorners(imageUrl) { +const BLOCKED_HOSTS = new Set(["metadata.google.internal", "169.254.169.254"]); + +function isPrivateIp(ip) { + if (net.isIPv4(ip)) { + const parts = ip.split(".").map(Number); + if (parts[0] === 10) return true; + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; + if (parts[0] === 192 && parts[1] === 168) return true; + if (parts[0] === 127) return true; + if (parts[0] === 169 && parts[1] === 254) return true; + if (parts[0] === 0) return true; + } + if (net.isIPv6(ip)) { + if (ip === "::1" || ip.startsWith("fe80:") || ip.startsWith("fc") || ip.startsWith("fd")) return true; + } + return false; +} + +async function validateImageUrl(imageUrl) { + let parsed; + try { parsed = new URL(imageUrl); } catch { throw new Error("Invalid image URL"); } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error("Image URL must use HTTP(S)"); + if (BLOCKED_HOSTS.has(parsed.hostname)) throw new Error("Blocked host"); + if (net.isIP(parsed.hostname)) { + if (isPrivateIp(parsed.hostname)) throw new Error("Blocked host"); + } else { + const { address } = await dns.lookup(parsed.hostname); + if (isPrivateIp(address)) throw new Error("Blocked host"); + } +} + +const DETECT_PROMPT = `Locate the trading card in this photo. Return the bounding box 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": } + +Respond ONLY with valid JSON, no markdown.`; + +export async function detectAndCropCard(imageUrl, apiKey, model) { + if (!apiKey) return { imageUrl, cropped: false }; + + await validateImageUrl(imageUrl); + const imgRes = await axios.get(imageUrl, { + responseType: "arraybuffer", + timeout: 15_000, + maxRedirects: 5, + }); + const imgBuf = Buffer.from(imgRes.data); + const { width, height } = await sharp(imgBuf).metadata(); + if (!width || !height) return { imageUrl, cropped: false }; + + const res = await axios.post("https://api.anthropic.com/v1/messages", { + model: model || "claude-sonnet-4-6", + max_tokens: 100, + messages: [{ + role: "user", + content: [ + { type: "image", source: { type: "url", url: imageUrl } }, + { type: "text", text: DETECT_PROMPT }, + ], + }], + }, { + headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" }, + timeout: 30_000, + }); + + const text = (res.data?.content || []).map(b => b.type === "text" ? b.text : "").join(""); + const usage = res.data?.usage || {}; + const tokens = { input: usage.input_tokens || 0, output: usage.output_tokens || 0 }; + + let bounds; + try { + const cleaned = text.replace(/```json?\s*/g, "").replace(/```/g, "").trim(); + bounds = JSON.parse(cleaned); + } catch { + return { imageUrl, cropped: false, _tokens: tokens }; + } + + if (bounds.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); + + const cardArea = bw * bh; + const imageArea = width * height; + if (cardArea / imageArea >= CARD_AREA_THRESHOLD) { + return { imageUrl, cropped: false, _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(); + + return { + buffer: croppedBuf, + base64: croppedBuf.toString("base64"), + mediaType: "image/jpeg", + bounds: { x: bx, y: by, width: bw, height: bh }, + originalSize: { width, height }, + cropped: true, + _tokens: tokens, + }; +} + +async function fetchImageBuffer(imageUrl) { + await validateImageUrl(imageUrl); const res = await axios.get(imageUrl, { responseType: "arraybuffer", timeout: 15_000, maxRedirects: 5, }); + return Buffer.from(res.data); +} + +export async function cropCorners(imageUrlOrBuffer) { + const imgBuf = Buffer.isBuffer(imageUrlOrBuffer) + ? imageUrlOrBuffer + : await fetchImageBuffer(imageUrlOrBuffer); - const img = sharp(Buffer.from(res.data)); - const { width, height } = await img.metadata(); + const { width, height } = await sharp(imgBuf).metadata(); if (!width || !height) throw new Error("could not read image dimensions"); const cw = Math.round(width * CORNER_RATIO); @@ -26,7 +152,7 @@ export async function cropCorners(imageUrl) { const crops = await Promise.all( regions.map(async (r) => { - const buf = await sharp(Buffer.from(res.data)) + const buf = await sharp(imgBuf) .extract({ left: r.left, top: r.top, width: cw, height: ch }) .jpeg({ quality: 90 }) .toBuffer(); @@ -51,3 +177,11 @@ export function cornerCropsToImageBlocks(crops) { }, })); } + +export function imageBlockFromUrl(url) { + return { type: "image", source: { type: "url", url } }; +} + +export function imageBlockFromBase64(base64, mediaType = "image/jpeg") { + return { type: "image", source: { type: "base64", media_type: mediaType, data: base64 } }; +} diff --git a/test/api-test.js b/test/api-test.js index 73f4a72..fcbab00 100644 --- a/test/api-test.js +++ b/test/api-test.js @@ -1123,6 +1123,87 @@ async function run() { assert(typeof body.avgLatencyMs === "number", "avgLatencyMs should be a number"); }); + // ── Autocomplete ── + + console.log("\n\x1b[1m=== autocomplete ===\x1b[0m"); + + await test("GET /api/autocomplete requires q", async () => { + const { res } = await jsonNoAuth("/api/autocomplete"); + assert(res.status === 400, `expected 400, got ${res.status}`); + }); + + await test("GET /api/autocomplete?q=pika returns results", async () => { + const { res, body } = await jsonNoAuth("/api/autocomplete?q=pika"); + assert(res.status === 200, `status ${res.status}`); + assert(Array.isArray(body.results), "results should be array"); + }); + + await test("GET /api/autocomplete?q=x returns empty for nonsense", async () => { + const { res, body } = await jsonNoAuth("/api/autocomplete?q=zzzznonexistent"); + assert(res.status === 200, `status ${res.status}`); + assert(Array.isArray(body.results), "results should be array"); + assert(body.results.length === 0, `expected 0 results, got ${body.results.length}`); + }); + + // ── Set detail ── + + console.log("\n\x1b[1m=== set detail ===\x1b[0m"); + + await test("GET /api/sets/sv8a returns set with cards", async () => { + const { res, body } = await jsonNoAuth("/api/sets/sv8a"); + assert(res.status === 200, `status ${res.status}`); + assert(body.setCode === "sv8a", `expected sv8a, got ${body.setCode}`); + assert(Array.isArray(body.cards), "cards should be array"); + assert(typeof body.name === "string", "name should be string"); + }); + + await test("GET /api/sets/nonexistent returns 404", async () => { + const { res } = await jsonNoAuth("/api/sets/zzz_nonexistent"); + assert(res.status === 404, `expected 404, got ${res.status}`); + }); + + // ── Grading dataset stats ── + + console.log("\n\x1b[1m=== grading dataset ===\x1b[0m"); + + await test("GET /api/grading-dataset/stats without owner key returns 401/403", async () => { + const res = await fetch(`${BASE}/api/grading-dataset/stats`); + assert(res.status === 401 || res.status === 403, `expected 401/403, got ${res.status}`); + }); + + await test("GET /api/grading-dataset/stats with owner key returns stats", async () => { + const { res, body } = await json("/api/grading-dataset/stats"); + if (res.status === 403) return; + assert(res.status === 200, `expected 200, got ${res.status}`); + assert(typeof body.total === "number", "total should be a number"); + assert(typeof body.byGrade === "object", "byGrade should be an object"); + assert(typeof body.byProvider === "object", "byProvider should be an object"); + }); + + // ── Grade endpoint validation ── + + console.log("\n\x1b[1m=== grade validation ===\x1b[0m"); + + await test("POST /api/grade rejects missing imageUrl", async () => { + const res = await fetch(`${BASE}/api/grade`, { + method: "POST", + headers: { "Content-Type": "application/json", ...(API_KEY ? { "x-api-key": API_KEY } : {}) }, + body: JSON.stringify({}), + }); + assert(res.status === 400, `expected 400, got ${res.status}`); + const body = await res.json(); + assert(body.error.includes("imageUrl"), `error should mention imageUrl: ${body.error}`); + }); + + await test("POST /api/grade rejects empty imageUrl", async () => { + const res = await fetch(`${BASE}/api/grade`, { + method: "POST", + headers: { "Content-Type": "application/json", ...(API_KEY ? { "x-api-key": API_KEY } : {}) }, + body: JSON.stringify({ imageUrl: "" }), + }); + assert(res.status === 400, `expected 400, got ${res.status}`); + }); + // ── Summary ── console.log(`\n\x1b[1m=== ${passed} passed, ${failed} failed ===\x1b[0m\n`); diff --git a/test/unit-test.js b/test/unit-test.js index 1620f7b..279aa7b 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -1,5 +1,5 @@ -import { parseGradeJSON } from "../lib/grading/grading.js"; -import { cornerCropsToImageBlocks } from "../lib/grading/preprocessing.js"; +import { parseGradeJSON, roundGrade } from "../lib/grading/grading.js"; +import { cornerCropsToImageBlocks, imageBlockFromUrl, imageBlockFromBase64 } from "../lib/grading/preprocessing.js"; import { buildEbaySearchQuery, describeListingSearch } from "../lib/search/listingQuery.js"; import { filterByCondition, @@ -1197,6 +1197,180 @@ test("findCardByCardId: returns null for missing number part", () => { eq(findCardByCardId("sv8a/"), null); }); +// ── roundGrade (v3 grading formula) ── + +console.log("\n\x1b[1m=== roundGrade ===\x1b[0m"); + +test("roundGrade: 8.0 stays 8", () => { + eq(roundGrade(8.0), 8); +}); + +test("roundGrade: 8.1 rounds down to 8", () => { + eq(roundGrade(8.1), 8); +}); + +test("roundGrade: 8.24 rounds down to 8", () => { + eq(roundGrade(8.24), 8); +}); + +test("roundGrade: 8.25 rounds to 8.5", () => { + eq(roundGrade(8.25), 8.5); +}); + +test("roundGrade: 8.5 stays 8.5", () => { + eq(roundGrade(8.5), 8.5); +}); + +test("roundGrade: 8.74 rounds to 8.5", () => { + eq(roundGrade(8.74), 8.5); +}); + +test("roundGrade: 8.75 rounds up to 9", () => { + eq(roundGrade(8.75), 9); +}); + +test("roundGrade: 8.99 rounds up to 9", () => { + eq(roundGrade(8.99), 9); +}); + +test("roundGrade: 10.0 stays 10", () => { + eq(roundGrade(10.0), 10); +}); + +test("roundGrade: v3 overall formula — front 9 avg, back 7 avg, 60/40 weighting", () => { + const frontOverall = (9 + 9 + 9 + 9) / 4; + const backOverall = (7 + 7 + 7 + 7) / 4; + const raw = (frontOverall * 0.60) + (backOverall * 0.40); + eq(raw, 8.2); + eq(roundGrade(raw), 8); +}); + +test("roundGrade: v3 excessive defect cap — raw 8.6 but lowest is 6, capped at 7", () => { + const raw = 8.6; + const lowestSubgrade = 6; + const capped = Math.min(raw, lowestSubgrade + 1); + eq(roundGrade(capped), 7); +}); + +test("roundGrade: v3 cap doesn't apply when no single subgrade drags", () => { + const raw = 8.5; + const lowestSubgrade = 8; + const capped = Math.min(raw, lowestSubgrade + 1); + eq(roundGrade(capped), 8.5); +}); + +// ── imageBlockFromUrl / imageBlockFromBase64 ── + +console.log("\n\x1b[1m=== image block helpers ===\x1b[0m"); + +test("imageBlockFromUrl: correct structure", () => { + const block = imageBlockFromUrl("https://example.com/card.jpg"); + eq(block.type, "image"); + eq(block.source.type, "url"); + eq(block.source.url, "https://example.com/card.jpg"); +}); + +test("imageBlockFromBase64: correct structure with default mediaType", () => { + const block = imageBlockFromBase64("abc123"); + eq(block.type, "image"); + eq(block.source.type, "base64"); + eq(block.source.media_type, "image/jpeg"); + eq(block.source.data, "abc123"); +}); + +test("imageBlockFromBase64: custom mediaType", () => { + const block = imageBlockFromBase64("abc123", "image/png"); + eq(block.source.media_type, "image/png"); +}); + +// ── SUBGRADE_PROMPTS keys ── + +console.log("\n\x1b[1m=== subgrade prompt keys ===\x1b[0m"); + +test("parseGradeJSON: v3 response shape with front/back subgrades", () => { + const json = JSON.stringify({ + score: 8, confidence: 0.85, detail: "Minor whitening on bottom-left corner" + }); + const r = parseGradeJSON(json); + assert(!r.error, `unexpected error: ${r.error}`); + eq(r.ok.score, 8); + eq(r.ok.confidence, 0.85); + assert(r.ok.detail.includes("whitening"), "detail should mention whitening"); +}); + +test("roundGrade: v3 full pipeline simulation — mixed front/back scores", () => { + const front = { centering: 9, corners: 8, edges: 9, surface: 8 }; + const back = { centering: 7, corners: 6, edges: 7, surface: 7 }; + const frontAvg = (front.centering + front.corners + front.edges + front.surface) / 4; + const backAvg = (back.centering + back.corners + back.edges + back.surface) / 4; + eq(frontAvg, 8.5); + eq(backAvg, 6.75); + const raw = (frontAvg * 0.60) + (backAvg * 0.40); + eq(raw, 7.8); + const lowest = Math.min(front.centering, front.corners, front.edges, front.surface, + back.centering, back.corners, back.edges, back.surface); + eq(lowest, 6); + const capped = Math.min(raw, lowest + 1); + eq(capped, 7); + eq(roundGrade(capped), 7); +}); + +test("roundGrade: v3 front-only mode — back copies front scores", () => { + const front = { centering: 9, corners: 9, edges: 8, surface: 9 }; + const frontAvg = (front.centering + front.corners + front.edges + front.surface) / 4; + const backAvg = frontAvg; + const raw = (frontAvg * 0.60) + (backAvg * 0.40); + eq(raw, frontAvg); + eq(roundGrade(raw), 9); +}); + +// ── computePriceTrend edge cases ── + +console.log("\n\x1b[1m=== computePriceTrend edge cases ===\x1b[0m"); + +test("computePriceTrend: single source falling", () => { + const now = new Date(); + const history = [ + { recordedAt: new Date(now - 25 * 86400000).toISOString(), price: 100, source: "ebay" }, + { recordedAt: new Date(now - 15 * 86400000).toISOString(), price: 90, source: "ebay" }, + { recordedAt: new Date(now - 5 * 86400000).toISOString(), price: 70, source: "ebay" }, + { recordedAt: new Date(now - 2 * 86400000).toISOString(), price: 65, source: "ebay" }, + { recordedAt: new Date(now - 1 * 86400000).toISOString(), price: 60, source: "ebay" }, + ]; + const trend = computePriceTrend(history, now); + assert(trend !== null, "trend should not be null for 5 entries"); + eq(trend.direction, "falling"); + assert(trend.bySource.ebay, "should have ebay source"); +}); + +test("computePriceTrend: stable prices within 5%", () => { + const now = new Date(); + const history = [ + { recordedAt: new Date(now - 25 * 86400000).toISOString(), price: 100, source: "ebay" }, + { recordedAt: new Date(now - 15 * 86400000).toISOString(), price: 101, source: "ebay" }, + { recordedAt: new Date(now - 5 * 86400000).toISOString(), price: 100, source: "magi" }, + { recordedAt: new Date(now - 1 * 86400000).toISOString(), price: 99, source: "magi" }, + ]; + const trend = computePriceTrend(history, now); + assert(trend !== null, "trend should not be null"); + eq(trend.direction, "stable"); +}); + +test("computePriceTrend: rising prices give wait signal", () => { + const now = new Date(); + const history = [ + { recordedAt: new Date(now - 25 * 86400000).toISOString(), price: 50, source: "ebay" }, + { recordedAt: new Date(now - 15 * 86400000).toISOString(), price: 60, source: "ebay" }, + { recordedAt: new Date(now - 5 * 86400000).toISOString(), price: 80, source: "ebay" }, + { recordedAt: new Date(now - 2 * 86400000).toISOString(), price: 85, source: "ebay" }, + { recordedAt: new Date(now - 1 * 86400000).toISOString(), price: 90, source: "ebay" }, + ]; + const trend = computePriceTrend(history, now); + assert(trend !== null, "trend should not be null"); + eq(trend.direction, "rising"); + eq(trend.signal, "wait"); +}); + // ── Summary ── console.log(`\n\x1b[1m=== ${passed} passed, ${failed} failed ===\x1b[0m\n`);