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