Grade parser: descriptive grade words (EX-MT 6, MINT+ 9.5, VG+ 4.5, NM/MT 9.8); a grade recorded without a grader never overrides the title; ri regrade --recheck
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 changed files +30 −6
modified
packages/taxonomy/src/index.ts
+1 −1
@@ -155,7 +155,7 @@ export function getGrader(slugOrAlias: string | null | undefined): GraderDef | u | ||
| 155 | 155 | |
| 156 | 156 | // Grader token, optional "/DNA" (PSA/DNA autograph slabs), optional descriptive word, then either a |
| 157 | 157 | // numeric grade ("10", "9.5", "(8.5)") or an authentication-only marker ("Authentic", "AUTH", "A"). |
| 158 | −const GRADE_RE = /\b(PSA|BGS|BECKETT|CGC|SGC|TAG|ACE|CBCS|PGX|WATA|VGA|PCGS|NGC|ANACS|ICCS|PMG)\b(?:\s*\/?\s*DNA\b)?[\s:\-\[(]*(?:GEM\s*MT|GEM\s*MINT|MINT|NM-?MT|PRISTINE|BLACK\s*LABEL)?[\s:\-\[(]*(?:(\d{1,3}(?:\.\d)?)(?![\d.])|\b(AUTHENTIC|AUTH|CERTIFIED\s+AUTHENTIC)\b)?/gi; | |
| 158 | +const GRADE_RE = /\b(PSA|BGS|BECKETT|CGC|SGC|TAG|ACE|CBCS|PGX|WATA|VGA|PCGS|NGC|ANACS|ICCS|PMG)\b(?:\s*\/?\s*DNA\b)?[\s:\-\[(]*(?:GEM\s*MT|GEM\s*MINT|PRISTINE|BLACK\s*LABEL|NM[-\/]?MT\+?|EX[-\/]?MT\+?|VG[-\/]?EX\+?|MINT\+?|NM\+?|EX\+?|VG\+?|FN\+?|GD\+?|MT\+?|PR|FR)?[\s:\-\[(]*(?:(\d{1,3}(?:\.\d)?)(?![\d.])|\b(AUTHENTIC|AUTH|CERTIFIED\s+AUTHENTIC)\b)?/gi; | |
| 159 | 159 | /** Grader tokens that are also ordinary words ("Tag Team", "Ace Attorney") — only trusted when followed by a grade. */ |
| 160 | 160 | const AMBIGUOUS_GRADERS = new Set(['tag', 'ace']); |
| 161 | 161 | /** Authentication-only slabs (no numeric grade). Kept apart from raw and from graded variants. */ |
modified
packages/taxonomy/src/taxonomy.test.ts
+7 −0
@@ -32,6 +32,13 @@ describe('taxonomy', () => { | ||
| 32 | 32 | expect(parseGradeFromTitle('Brad Davis [PSA/DNA Certified Authentic Card & Auto]')).toMatchObject({ grader: 'psa', grade: 'authentic' }); |
| 33 | 33 | expect(parseGradeFromTitle('Charizard PSA 2021 release')).toMatchObject({ grader: 'psa', grade: null }); // a year is not a grade |
| 34 | 34 | expect(parseGradeFromTitle('Batman #1 CGC (8.5) white pages')).toMatchObject({ grader: 'cgc', grade: '8.5' }); |
| 35 | + // descriptive grade words between grader and number (Goldin, SCP, PriceCharting titles) | |
| 36 | + expect(parseGradeFromTitle('1973 Topps Test Pin-Ups Hank Aaron - PSA EX-MT 6')).toMatchObject({ grader: 'psa', grade: '6' }); | |
| 37 | + expect(parseGradeFromTitle('2022 Panini Donruss #374 Brock Purdy Rookie Card - SGC MINT+ 9.5')).toMatchObject({ grader: 'sgc', grade: '9.5' }); | |
| 38 | + expect(parseGradeFromTitle('The Amazing Spider-Man #26 (Marvel, 1965) CGC VG+ 4.5 Cream pages')).toMatchObject({ grader: 'cgc', grade: '4.5' }); | |
| 39 | + expect(parseGradeFromTitle('The Incredible Hulk #318 CGC NM/MT 9.8 White pages')).toMatchObject({ grader: 'cgc', grade: '9.8' }); | |
| 40 | + expect(parseGradeFromTitle('X-Men #2 (Marvel, 1963) CGC FN+ 6.5')).toMatchObject({ grader: 'cgc', grade: '6.5' }); | |
| 41 | + expect(parseGradeFromTitle('Amazing Spider-Man #11 CGC 2.0 1964 Marvel')).toMatchObject({ grader: 'cgc', grade: '2.0' }); | |
| 35 | 42 | }); |
| 36 | 43 | it('keeps slabs with an unreadable grade out of the raw variant', () => { |
| 37 | 44 | expect(variantKey({ grader: 'psa', grade: null })).toEqual({ key: 'psa|unknown', label: 'PSA · grade unknown' }); |
modified
workers/cli.ts
+1 −1
@@ -127,7 +127,7 @@ async function main() { | ||
| 127 | 127 | case 'regrade': { |
| 128 | 128 | // ri regrade [--kind sale|listing|both] [--connector id] [--limit N] [--dry-run] |
| 129 | 129 | const kind = (str('kind') ?? 'both') as 'sale' | 'listing' | 'both'; |
| 130 | − print(await regradeRecords({ kind, connectorId: str('connector'), limit: num('limit', 50_000), dryRun: Boolean(flags['dry-run']) })); | |
| 130 | + print(await regradeRecords({ kind, connectorId: str('connector'), limit: num('limit', 50_000), dryRun: Boolean(flags['dry-run']), recheck: Boolean(flags.recheck) })); | |
| 131 | 131 | break; |
| 132 | 132 | } |
| 133 | 133 | case 'value': { |
modified
workers/entity-resolution/regrade.ts
+17 −3
@@ -62,13 +62,25 @@ async function candidates(kind: 'sale' | 'listing', limit: number, connectorId?: | ||
| 62 | 62 | return rows as Row[]; |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | −export async function regradeRecords(opts: { kind?: 'sale' | 'listing' | 'both'; limit?: number; connectorId?: string; dryRun?: boolean } = {}): Promise<RegradeResult> { | |
| 65 | +/** Rows touched by an earlier regrade pass (audit_log action = regraded), to re-verify with the current parser. */ | |
| 66 | +async function regradedRows(kind: 'sale' | 'listing', limit: number): Promise<Row[]> { | |
| 67 | + const t = kind === 'sale' ? sales : listings; | |
| 68 | + const rows = await db() | |
| 69 | + .select({ id: t.id, assetId: t.assetId, variantId: t.variantId, grader: t.grader, grade: t.grade, rawTitle: t.rawTitle, variantKey: assetVariants.variantKey, vGrader: assetVariants.grader, vGrade: assetVariants.grade, condition: assetVariants.condition, completeness: assetVariants.completeness, sizeLabel: assetVariants.sizeLabel }) | |
| 70 | + .from(t) | |
| 71 | + .leftJoin(assetVariants, eq(assetVariants.id, t.variantId)) | |
| 72 | + .where(sql`${t.id} in (select entity_id from audit_log where action = 'regraded' and entity_type = ${kind})`) | |
| 73 | + .limit(limit); | |
| 74 | + return rows as Row[]; | |
| 75 | +} | |
| 76 | + | |
| 77 | +export async function regradeRecords(opts: { kind?: 'sale' | 'listing' | 'both'; limit?: number; connectorId?: string; dryRun?: boolean; recheck?: boolean } = {}): Promise<RegradeResult> { | |
| 66 | 78 | const kinds: Array<'sale' | 'listing'> = opts.kind === 'sale' ? ['sale'] : opts.kind === 'listing' ? ['listing'] : ['sale', 'listing']; |
| 67 | 79 | const limit = opts.limit ?? 50_000; |
| 68 | 80 | const res: RegradeResult = { scanned: 0, moved: 0, unknownGrade: 0, unchanged: 0, assets: 0 }; |
| 69 | 81 | const touched = new Set<string>(); |
| 70 | 82 | for (const kind of kinds) { |
| 71 | − const rows = await candidates(kind, limit, opts.connectorId); | |
| 83 | + const rows = opts.recheck ? await regradedRows(kind, limit) : await candidates(kind, limit, opts.connectorId); | |
| 72 | 84 | res.scanned += rows.length; |
| 73 | 85 | const audits: Array<{ entityType: string; entityId: string; action: string; reason: string; details: Record<string, unknown> }> = []; |
| 74 | 86 | for (const r of rows) { |
@@ -78,7 +90,9 @@ export async function regradeRecords(opts: { kind?: 'sale' | 'listing' | 'both'; | ||
| 78 | 90 | res.unchanged++; |
| 79 | 91 | continue; |
| 80 | 92 | } |
| 81 | − const grade = r.grade ?? (parsed.grader === grader ? parsed.grade : null); | |
| 93 | + // grade recorded WITH a grader is trusted; a grade recorded without one is a condition value → title wins | |
| 94 | + const titleGrade = parsed.grader === grader ? parsed.grade : null; | |
| 95 | + const grade = opts.recheck ? (titleGrade ?? r.grade) : r.grader && r.grader !== 'raw' ? (r.grade ?? titleGrade) : titleGrade; | |
| 82 | 96 | const target = await ensureVariant(r.assetId, { grader, grade, qualifier: parsed.qualifier, certificationNumber: null }, { condition: r.condition, completeness: r.completeness }, r.sizeLabel); |
| 83 | 97 | if (target.id === r.variantId && r.grader === grader && r.grade === grade) { |
| 84 | 98 | res.unchanged++; |
modified
workers/entity-resolution/writers.ts
+4 −1
@@ -95,7 +95,10 @@ export function refineGrade(grade: Grade, rawTitle: string | null | undefined): | ||
| 95 | 95 | const parsed = parseGradeFromTitle(rawTitle); |
| 96 | 96 | if (!parsed.grader || parsed.grader === 'raw') return grade; |
| 97 | 97 | if (grade.grader && parsed.grader !== grade.grader) return grade; // never contradict the connector's grader |
| 98 | − return { ...grade, grader: grade.grader ?? parsed.grader, grade: grade.grade ?? parsed.grade, qualifier: grade.qualifier ?? parsed.qualifier }; | |
| 98 | + // A grade recorded without a grader is a condition-scale value (e.g. a price-guide tab), not a slab | |
| 99 | + // grade: when the title reveals the slab, the title's grade wins. | |
| 100 | + const g = grade.grader ? (grade.grade ?? parsed.grade) : parsed.grade; | |
| 101 | + return { ...grade, grader: grade.grader ?? parsed.grader, grade: g, qualifier: grade.qualifier ?? parsed.qualifier }; | |
| 99 | 102 | } |
| 100 | 103 | |
| 101 | 104 | async function applyCatalog(rec: NormalizedCatalogItem): Promise<ApplyResult> { |
| 102 | 105 | |