TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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