SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
9.1 KB · 187 lines typescript
Raw Blame History
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';8import { assessAsk } from '@rareindex/valuation';9import { toUsdAt } from '@/lib/account/fx';1011export interface ScanResult {12  sessionId: string;13  mode: 'photo' | 'url' | 'text';14  guess: Identification | null;15  guessConfidence: number | null;16  candidates: Candidate[];17  best: (Candidate & { context: Awaited<ReturnType<typeof assetContext>> }) | null;18  listing: ListingSummary | null;19  model: string | null;20  usdEst: number;21  durationMs: number;22  notes: string[];23}2425export interface ListingSummary {26  sourceId: string;27  sourceUrl: string;28  rawTitle: string;29  price: number | null;30  currency: string | null;31  kind: NormalizedRecord['kind'];32  grader: string | null;33  grade: string | null;34  imageUrls: string[];35  identifiers: Record<string, string>;36  /** price vs RIV when both known: negative = below fair value */37  discountToRiv: number | null;38  verdict: 'below_fair_value' | 'in_range' | 'above_fair_value' | 'anomaly' | 'unknown';39}4041interface ScanInput {42  mode: 'photo' | 'url' | 'text';43  images?: ImageInput[];44  thumbnails?: string[];45  url?: string;46  text?: string;47  userId: string | null;48  anonId: string | null;49  ipHash: string;50}5152/** Full scanner flow (§114). Everything shown is evidence-backed; AI output carries confidence. */53export async function runScan(input: ScanInput): Promise<ScanResult> {54  const started = Date.now();55  const id = newId('event').replace('evt_', 'scan_');56  const notes: string[] = ['AI identification is an estimate, not authentication. Confirm with the source, the certification number and a professional grader when value matters.'];57  let guess: Identification | null = null;58  let model: string | null = null;59  let usdEst = 0;60  let listing: ListingSummary | null = null;61  let identifiers: Record<string, string> = {};62  let textForModel = input.text ?? '';6364  if (input.mode === 'url' && input.url) {65    const looked = await lookupUrl(input.url);66    if (looked === 'unavailable') {67      notes.push('Listing lookup service unavailable; identification falls back to the URL text only.');68      textForModel = input.url;69    } else if (looked) {70      listing = looked.summary;71      identifiers = looked.summary.identifiers;72      textForModel = [looked.summary.rawTitle, looked.description ?? ''].filter(Boolean).join('\n');73      notes.push(`Listing parsed from ${looked.summary.sourceId} via connector lookup.`);74    } else {75      notes.push('No connector understands this URL yet; identification falls back to the URL text only.');76      textForModel = input.url;77    }78  }7980  try {81    const res = await identifyCollectible({82      images: input.images,83      text: textForModel || undefined,84      cost: { endpoint: 'scanner', userId: input.userId },85    });86    guess = res.data;87    model = res.model;88    usdEst += res.usdEst;89  } catch (err) {90    logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'scanner identification failed');91    if (!listing) throw err;92    notes.push('AI identification unavailable; showing the parsed listing only.');93  }9495  // Fill grade from the raw title when the model missed it (deterministic beats generative)96  if (guess && !guess.grader && listing?.rawTitle) {97    const g = parseGradeFromTitle(listing.rawTitle);98    if (g.grader) {99      guess.grader = g.grader;100      guess.grade = g.grade;101    }102  }103104  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);105  const top = candidates[0];106  const best = top && top.score >= 0.25 ? { ...top, context: await assetContext(top.assetId) } : null;107  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.');108109  if (listing && best?.rivUsd && listing.price !== null && listing.currency) {110    // Compare in USD at today's ECB rate; gate exactly like the valuation worker (transaction-based RIV,111    // ≥ 5 sales, medium+ confidence) and refuse implausible ratios instead of calling them deals.112    const fx = listing.currency === 'USD' ? { usd: listing.price } : await toUsdAt(listing.price, listing.currency, null);113    const gradedMismatch = Boolean(listing.grader && listing.grader !== 'raw' && best.context.variants.length && !best.context.variants.some((v) => (v.grader as string | null) === listing.grader && (v.grade as string | null) === listing.grade));114    const a = assessAsk({ askUsd: fx?.usd ?? null, rivUsd: best.rivUsd, confidence: best.rivConfidence, sampleSize: best.rivSampleSize, sameVariant: gradedMismatch ? false : undefined });115    listing.discountToRiv = a.discount;116    listing.verdict = a.verdict === 'deal' ? 'below_fair_value' : a.verdict === 'premium' ? 'above_fair_value' : a.verdict === 'fair' ? 'in_range' : a.verdict === 'anomaly' || a.verdict === 'review' ? 'anomaly' : 'unknown';117    if (a.verdict === 'review') notes.push('The ask is more than 50 % below the matched valuation: almost always a different item, grade or lot. Held for review, not a deal.');118    if (a.verdict === 'anomaly') notes.push('The asking price is implausible against the matched valuation (below 10 % or above 10× RIV): most likely a different variant, a lot, a currency or an identity mismatch — review before treating it as a deal.');119    else if (a.verdict === 'ungated') notes.push(`The ask was not compared with the valuation (${a.reasons.join(', ')}).`);120  }121122  const durationMs = Date.now() - started;123  await getDb()124    .insert(scannerSessions)125    .values({126      id,127      userId: input.userId,128      anonId: input.anonId,129      ipHash: input.ipHash,130      mode: input.mode,131      inputUrl: input.url ?? null,132      inputText: input.text ?? null,133      imageCount: input.images?.length ?? 0,134      thumbnails: input.thumbnails ?? [],135      guess: (guess ?? {}) as Record<string, unknown>,136      guessConfidence: guess?.confidence ?? null,137      candidates: candidates.map((c) => ({ assetId: c.assetId, slug: c.slug, title: c.title, score: c.score })),138      listing: (listing ?? {}) as unknown as Record<string, unknown>,139      model,140      usdEst,141      durationMs,142    });143144  return { sessionId: id, mode: input.mode, guess, guessConfidence: guess?.confidence ?? null, candidates, best, listing, model, usdEst, durationMs, notes };145}146147/** Record the user's confirmation of a candidate. Only the session's owner (user, anon cookie or, failing both, the same IP hash) may write it. */148export async function chooseCandidate(sessionId: string, assetId: string | null, owner: { userId: string | null; anonId: string | null; ipHash: string }): Promise<boolean> {149  const [s] = await getDb().select({ userId: scannerSessions.userId, anonId: scannerSessions.anonId, ipHash: scannerSessions.ipHash, candidates: scannerSessions.candidates }).from(scannerSessions).where(eq(scannerSessions.id, sessionId)).limit(1);150  if (!s) return false;151  const owns = s.userId ? s.userId === owner.userId : s.anonId ? s.anonId === owner.anonId : s.ipHash === owner.ipHash;152  if (!owns) return false;153  if (assetId !== null && !s.candidates.some((c) => c.assetId === assetId)) return false; // only a proposed candidate can be chosen154  await getDb().update(scannerSessions).set({ chosenAssetId: assetId, chosenAt: new Date() }).where(eq(scannerSessions.id, sessionId));155  return true;156}157158/** Resolve a marketplace URL through the connector framework (lookup → normalize), via the API service. */159export async function lookupUrl(url: string): Promise<{ summary: ListingSummary; description: string | null; records: NormalizedRecord[] } | null | 'unavailable'> {160  const looked = await lookupViaApi(url);161  if (looked === 'unavailable' || looked === null) return looked;162  const records = looked.records;163  const rec = records.find((r) => r.kind === 'listing' || r.kind === 'sale' || r.kind === 'catalog_item' || r.kind === 'price_observation');164  if (!rec || !('rawTitle' in rec)) return null;165  const price = 'price' in rec ? (rec.price as number | null) : null;166  const currency = 'currency' in rec ? (rec.currency as string | null) : null;167  const summary: ListingSummary = {168    sourceId: rec.sourceId,169    sourceUrl: rec.sourceUrl,170    rawTitle: rec.rawTitle,171    price,172    currency,173    kind: rec.kind,174    grader: rec.grade.grader ? (getGrader(rec.grade.grader)?.slug ?? rec.grade.grader) : null,175    grade: rec.grade.grade,176    imageUrls: rec.imageUrls,177    identifiers: rec.attributes.identifiers,178    discountToRiv: null,179    verdict: 'unknown',180  };181  return { summary, description: rec.description, records };182}183184export function aiConfigured(): boolean {185  return getRouter().configured;186}187