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 | // Grader token, optional "/DNA" (PSA/DNA autograph slabs), optional descriptive word, then either a | 156 | // Grader token, optional "/DNA" (PSA/DNA autograph slabs), optional descriptive word, then either a | ||
| 157 | // numeric grade ("10", "9.5", "(8.5)") or an authentication-only marker ("Authentic", "AUTH", "A"). | 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 | /** Grader tokens that are also ordinary words ("Tag Team", "Ace Attorney") — only trusted when followed by a grade. */ | 159 | /** Grader tokens that are also ordinary words ("Tag Team", "Ace Attorney") — only trusted when followed by a grade. */ | ||
| 160 | const AMBIGUOUS_GRADERS = new Set(['tag', 'ace']); | 160 | const AMBIGUOUS_GRADERS = new Set(['tag', 'ace']); | ||
| 161 | /** Authentication-only slabs (no numeric grade). Kept apart from raw and from graded variants. */ | 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 | expect(parseGradeFromTitle('Brad Davis [PSA/DNA Certified Authentic Card & Auto]')).toMatchObject({ grader: 'psa', grade: 'authentic' }); | 32 | expect(parseGradeFromTitle('Brad Davis [PSA/DNA Certified Authentic Card & Auto]')).toMatchObject({ grader: 'psa', grade: 'authentic' }); | ||
| 33 | expect(parseGradeFromTitle('Charizard PSA 2021 release')).toMatchObject({ grader: 'psa', grade: null }); // a year is not a grade | 33 | expect(parseGradeFromTitle('Charizard PSA 2021 release')).toMatchObject({ grader: 'psa', grade: null }); // a year is not a grade | ||
| 34 | expect(parseGradeFromTitle('Batman #1 CGC (8.5) white pages')).toMatchObject({ grader: 'cgc', grade: '8.5' }); | 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 | it('keeps slabs with an unreadable grade out of the raw variant', () => { | 43 | it('keeps slabs with an unreadable grade out of the raw variant', () => { | ||
| 37 | expect(variantKey({ grader: 'psa', grade: null })).toEqual({ key: 'psa|unknown', label: 'PSA · grade unknown' }); | 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 | case 'regrade': { | 127 | case 'regrade': { | ||
| 128 | // ri regrade [--kind sale|listing|both] [--connector id] [--limit N] [--dry-run] | 128 | // ri regrade [--kind sale|listing|both] [--connector id] [--limit N] [--dry-run] | ||
| 129 | const kind = (str('kind') ?? 'both') as 'sale' | 'listing' | 'both'; | 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 | break; | 131 | break; | ||
| 132 | } | 132 | } | ||
| 133 | case 'value': { | 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 | return rows as Row[]; | 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 | const kinds: Array<'sale' | 'listing'> = opts.kind === 'sale' ? ['sale'] : opts.kind === 'listing' ? ['listing'] : ['sale', 'listing']; | 78 | const kinds: Array<'sale' | 'listing'> = opts.kind === 'sale' ? ['sale'] : opts.kind === 'listing' ? ['listing'] : ['sale', 'listing']; | ||
| 67 | const limit = opts.limit ?? 50_000; | 79 | const limit = opts.limit ?? 50_000; | ||
| 68 | const res: RegradeResult = { scanned: 0, moved: 0, unknownGrade: 0, unchanged: 0, assets: 0 }; | 80 | const res: RegradeResult = { scanned: 0, moved: 0, unknownGrade: 0, unchanged: 0, assets: 0 }; | ||
| 69 | const touched = new Set<string>(); | 81 | const touched = new Set<string>(); | ||
| 70 | for (const kind of kinds) { | 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 | res.scanned += rows.length; | 84 | res.scanned += rows.length; | ||
| 73 | const audits: Array<{ entityType: string; entityId: string; action: string; reason: string; details: Record<string, unknown> }> = []; | 85 | const audits: Array<{ entityType: string; entityId: string; action: string; reason: string; details: Record<string, unknown> }> = []; | ||
| 74 | for (const r of rows) { | 86 | for (const r of rows) { | ||
@@ -78,7 +90,9 @@ export async function regradeRecords(opts: { kind?: 'sale' | 'listing' | 'both'; | |||||
| 78 | res.unchanged++; | 90 | res.unchanged++; | ||
| 79 | continue; | 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 | const target = await ensureVariant(r.assetId, { grader, grade, qualifier: parsed.qualifier, certificationNumber: null }, { condition: r.condition, completeness: r.completeness }, r.sizeLabel); | 96 | const target = await ensureVariant(r.assetId, { grader, grade, qualifier: parsed.qualifier, certificationNumber: null }, { condition: r.condition, completeness: r.completeness }, r.sizeLabel); | ||
| 83 | if (target.id === r.variantId && r.grader === grader && r.grade === grade) { | 97 | if (target.id === r.variantId && r.grader === grader && r.grade === grade) { | ||
| 84 | res.unchanged++; | 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 | const parsed = parseGradeFromTitle(rawTitle); | 95 | const parsed = parseGradeFromTitle(rawTitle); | ||
| 96 | if (!parsed.grader || parsed.grader === 'raw') return grade; | 96 | if (!parsed.grader || parsed.grader === 'raw') return grade; | ||
| 97 | if (grade.grader && parsed.grader !== grade.grader) return grade; // never contradict the connector's grader | 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 | async function applyCatalog(rec: NormalizedCatalogItem): Promise<ApplyResult> { | 104 | async function applyCatalog(rec: NormalizedCatalogItem): Promise<ApplyResult> { | ||
| 102 | 105 | ||||