SPB Git

spb/rareindex

Public
6.9 KB · 170 lines typescript
1import 'server-only';2import { getDb, scannerSessions, eq } from '@rareindex/database';3import { newId, logger, type NormalizedRecord } from '@rareindex/shared';4import { getGrader, parseGradeFromTitle } from '@rareindex/taxonomy';5import { identifyCollectible, getRouter, type ImageInput, type Identification } from '@rareindex/ai';6import { lookupViaApi } from './lookup';7import { findCandidates, assetContext, type Candidate } from './candidates';89export interface ScanResult {10  sessionId: string;11  mode: 'photo' | 'url' | 'text';12  guess: Identification | null;13  guessConfidence: number | null;14  candidates: Candidate[];15  best: (Candidate & { context: Awaited<ReturnType<typeof assetContext>> }) | null;16  listing: ListingSummary | null;17  model: string | null;18  usdEst: number;19  durationMs: number;20  notes: string[];21}2223export interface ListingSummary {24  sourceId: string;25  sourceUrl: string;26  rawTitle: string;27  price: number | null;28  currency: string | null;29  kind: NormalizedRecord['kind'];30  grader: string | null;31  grade: string | null;32  imageUrls: string[];33  identifiers: Record<string, string>;34  /** price vs RIV when both known: negative = below fair value */35  discountToRiv: number | null;36  verdict: 'below_fair_value' | 'in_range' | 'above_fair_value' | 'unknown';37}3839interface ScanInput {40  mode: 'photo' | 'url' | 'text';41  images?: ImageInput[];42  thumbnails?: string[];43  url?: string;44  text?: string;45  userId: string | null;46  anonId: string | null;47  ipHash: string;48}4950/** Full scanner flow (§114). Everything shown is evidence-backed; AI output carries confidence. */51export async function runScan(input: ScanInput): Promise<ScanResult> {52  const started = Date.now();53  const id = newId('event').replace('evt_', 'scan_');54  const notes: string[] = ['AI identification is an estimate, not authentication. Confirm with the source, the certification number and a professional grader when value matters.'];55  let guess: Identification | null = null;56  let model: string | null = null;57  let usdEst = 0;58  let listing: ListingSummary | null = null;59  let identifiers: Record<string, string> = {};60  let textForModel = input.text ?? '';6162  if (input.mode === 'url' && input.url) {63    const looked = await lookupUrl(input.url);64    if (looked === 'unavailable') {65      notes.push('Listing lookup service unavailable; identification falls back to the URL text only.');66      textForModel = input.url;67    } else if (looked) {68      listing = looked.summary;69      identifiers = looked.summary.identifiers;70      textForModel = [looked.summary.rawTitle, looked.description ?? ''].filter(Boolean).join('\n');71      notes.push(`Listing parsed from ${looked.summary.sourceId} via connector lookup.`);72    } else {73      notes.push('No connector understands this URL yet; identification falls back to the URL text only.');74      textForModel = input.url;75    }76  }7778  try {79    const res = await identifyCollectible({80      images: input.images,81      text: textForModel || undefined,82      cost: { endpoint: 'scanner', userId: input.userId },83    });84    guess = res.data;85    model = res.model;86    usdEst += res.usdEst;87  } catch (err) {88    logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'scanner identification failed');89    if (!listing) throw err;90    notes.push('AI identification unavailable; showing the parsed listing only.');91  }9293  // Fill grade from the raw title when the model missed it (deterministic beats generative)94  if (guess && !guess.grader && listing?.rawTitle) {95    const g = parseGradeFromTitle(listing.rawTitle);96    if (g.grader) {97      guess.grader = g.grader;98      guess.grade = g.grade;99    }100  }101102  const candidates = guess ? await findCandidates(guess, identifiers) : await findCandidates({ categorySlug: null, name: listing?.rawTitle ?? '', set: null, number: null, year: null, variant: null, brand: null, searchQueries: listing ? [listing.rawTitle] : [], certificationNumber: null }, identifiers);103  const top = candidates[0];104  const best = top && top.score >= 0.25 ? { ...top, context: await assetContext(top.assetId) } : null;105  if (!best) notes.push(candidates.length ? 'Low-confidence match: review the candidates below.' : 'No matching asset in the RareIndex catalog yet. The pipeline adds assets as connectors ingest sources.');106107  if (listing && best?.rivUsd && listing.price !== null && listing.currency === 'USD') {108    listing.discountToRiv = (listing.price - best.rivUsd) / best.rivUsd;109    listing.verdict = listing.discountToRiv <= -0.15 ? 'below_fair_value' : listing.discountToRiv >= 0.2 ? 'above_fair_value' : 'in_range';110  }111112  const durationMs = Date.now() - started;113  await getDb()114    .insert(scannerSessions)115    .values({116      id,117      userId: input.userId,118      anonId: input.anonId,119      ipHash: input.ipHash,120      mode: input.mode,121      inputUrl: input.url ?? null,122      inputText: input.text ?? null,123      imageCount: input.images?.length ?? 0,124      thumbnails: input.thumbnails ?? [],125      guess: (guess ?? {}) as Record<string, unknown>,126      guessConfidence: guess?.confidence ?? null,127      candidates: candidates.map((c) => ({ assetId: c.assetId, slug: c.slug, title: c.title, score: c.score })),128      listing: (listing ?? {}) as unknown as Record<string, unknown>,129      model,130      usdEst,131      durationMs,132    });133134  return { sessionId: id, mode: input.mode, guess, guessConfidence: guess?.confidence ?? null, candidates, best, listing, model, usdEst, durationMs, notes };135}136137export async function chooseCandidate(sessionId: string, assetId: string | null): Promise<void> {138  await getDb().update(scannerSessions).set({ chosenAssetId: assetId, chosenAt: new Date() }).where(eq(scannerSessions.id, sessionId));139}140141/** Resolve a marketplace URL through the connector framework (lookup → normalize), via the API service. */142export async function lookupUrl(url: string): Promise<{ summary: ListingSummary; description: string | null; records: NormalizedRecord[] } | null | 'unavailable'> {143  const looked = await lookupViaApi(url);144  if (looked === 'unavailable' || looked === null) return looked;145  const records = looked.records;146  const rec = records.find((r) => r.kind === 'listing' || r.kind === 'sale' || r.kind === 'catalog_item' || r.kind === 'price_observation');147  if (!rec || !('rawTitle' in rec)) return null;148  const price = 'price' in rec ? (rec.price as number | null) : null;149  const currency = 'currency' in rec ? (rec.currency as string | null) : null;150  const summary: ListingSummary = {151    sourceId: rec.sourceId,152    sourceUrl: rec.sourceUrl,153    rawTitle: rec.rawTitle,154    price,155    currency,156    kind: rec.kind,157    grader: rec.grade.grader ? (getGrader(rec.grade.grader)?.slug ?? rec.grade.grader) : null,158    grade: rec.grade.grade,159    imageUrls: rec.imageUrls,160    identifiers: rec.attributes.identifiers,161    discountToRiv: null,162    verdict: 'unknown',163  };164  return { summary, description: rec.description, records };165}166167export function aiConfigured(): boolean {168  return getRouter().configured;169}170
Files · 93afc664ddb333d55c451ac27e1a79bfd8e89448
Menu

Theme