import 'server-only'; import { getDb, scannerSessions, eq } from '@rareindex/database'; import { newId, logger, type NormalizedRecord } from '@rareindex/shared'; import { getGrader, parseGradeFromTitle } from '@rareindex/taxonomy'; import { identifyCollectible, getRouter, type ImageInput, type Identification } from '@rareindex/ai'; import { lookupViaApi } from './lookup'; import { findCandidates, assetContext, type Candidate } from './candidates'; import { assessAsk } from '@rareindex/valuation'; import { toUsdAt } from '@/lib/account/fx'; export interface ScanResult { sessionId: string; mode: 'photo' | 'url' | 'text'; guess: Identification | null; guessConfidence: number | null; candidates: Candidate[]; best: (Candidate & { context: Awaited> }) | null; listing: ListingSummary | null; model: string | null; usdEst: number; durationMs: number; notes: string[]; } export interface ListingSummary { sourceId: string; sourceUrl: string; rawTitle: string; price: number | null; currency: string | null; kind: NormalizedRecord['kind']; grader: string | null; grade: string | null; imageUrls: string[]; identifiers: Record; /** price vs RIV when both known: negative = below fair value */ discountToRiv: number | null; verdict: 'below_fair_value' | 'in_range' | 'above_fair_value' | 'anomaly' | 'unknown'; } interface ScanInput { mode: 'photo' | 'url' | 'text'; images?: ImageInput[]; thumbnails?: string[]; url?: string; text?: string; userId: string | null; anonId: string | null; ipHash: string; } /** Full scanner flow (§114). Everything shown is evidence-backed; AI output carries confidence. */ export async function runScan(input: ScanInput): Promise { const started = Date.now(); const id = newId('event').replace('evt_', 'scan_'); const notes: string[] = ['AI identification is an estimate, not authentication. Confirm with the source, the certification number and a professional grader when value matters.']; let guess: Identification | null = null; let model: string | null = null; let usdEst = 0; let listing: ListingSummary | null = null; let identifiers: Record = {}; let textForModel = input.text ?? ''; if (input.mode === 'url' && input.url) { const looked = await lookupUrl(input.url); if (looked === 'unavailable') { notes.push('Listing lookup service unavailable; identification falls back to the URL text only.'); textForModel = input.url; } else if (looked) { listing = looked.summary; identifiers = looked.summary.identifiers; textForModel = [looked.summary.rawTitle, looked.description ?? ''].filter(Boolean).join('\n'); notes.push(`Listing parsed from ${looked.summary.sourceId} via connector lookup.`); } else { notes.push('No connector understands this URL yet; identification falls back to the URL text only.'); textForModel = input.url; } } try { const res = await identifyCollectible({ images: input.images, text: textForModel || undefined, cost: { endpoint: 'scanner', userId: input.userId }, }); guess = res.data; model = res.model; usdEst += res.usdEst; } catch (err) { logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'scanner identification failed'); if (!listing) throw err; notes.push('AI identification unavailable; showing the parsed listing only.'); } // Fill grade from the raw title when the model missed it (deterministic beats generative) if (guess && !guess.grader && listing?.rawTitle) { const g = parseGradeFromTitle(listing.rawTitle); if (g.grader) { guess.grader = g.grader; guess.grade = g.grade; } } 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); const top = candidates[0]; const best = top && top.score >= 0.25 ? { ...top, context: await assetContext(top.assetId) } : null; 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.'); if (listing && best?.rivUsd && listing.price !== null && listing.currency) { // Compare in USD at today's ECB rate; gate exactly like the valuation worker (transaction-based RIV, // ≥ 5 sales, medium+ confidence) and refuse implausible ratios instead of calling them deals. const fx = listing.currency === 'USD' ? { usd: listing.price } : await toUsdAt(listing.price, listing.currency, null); 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)); const a = assessAsk({ askUsd: fx?.usd ?? null, rivUsd: best.rivUsd, confidence: best.rivConfidence, sampleSize: best.rivSampleSize, sameVariant: gradedMismatch ? false : undefined }); listing.discountToRiv = a.discount; 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'; 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.'); 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.'); else if (a.verdict === 'ungated') notes.push(`The ask was not compared with the valuation (${a.reasons.join(', ')}).`); } const durationMs = Date.now() - started; await getDb() .insert(scannerSessions) .values({ id, userId: input.userId, anonId: input.anonId, ipHash: input.ipHash, mode: input.mode, inputUrl: input.url ?? null, inputText: input.text ?? null, imageCount: input.images?.length ?? 0, thumbnails: input.thumbnails ?? [], guess: (guess ?? {}) as Record, guessConfidence: guess?.confidence ?? null, candidates: candidates.map((c) => ({ assetId: c.assetId, slug: c.slug, title: c.title, score: c.score })), listing: (listing ?? {}) as unknown as Record, model, usdEst, durationMs, }); return { sessionId: id, mode: input.mode, guess, guessConfidence: guess?.confidence ?? null, candidates, best, listing, model, usdEst, durationMs, notes }; } /** 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. */ export async function chooseCandidate(sessionId: string, assetId: string | null, owner: { userId: string | null; anonId: string | null; ipHash: string }): Promise { 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); if (!s) return false; const owns = s.userId ? s.userId === owner.userId : s.anonId ? s.anonId === owner.anonId : s.ipHash === owner.ipHash; if (!owns) return false; if (assetId !== null && !s.candidates.some((c) => c.assetId === assetId)) return false; // only a proposed candidate can be chosen await getDb().update(scannerSessions).set({ chosenAssetId: assetId, chosenAt: new Date() }).where(eq(scannerSessions.id, sessionId)); return true; } /** Resolve a marketplace URL through the connector framework (lookup → normalize), via the API service. */ export async function lookupUrl(url: string): Promise<{ summary: ListingSummary; description: string | null; records: NormalizedRecord[] } | null | 'unavailable'> { const looked = await lookupViaApi(url); if (looked === 'unavailable' || looked === null) return looked; const records = looked.records; const rec = records.find((r) => r.kind === 'listing' || r.kind === 'sale' || r.kind === 'catalog_item' || r.kind === 'price_observation'); if (!rec || !('rawTitle' in rec)) return null; const price = 'price' in rec ? (rec.price as number | null) : null; const currency = 'currency' in rec ? (rec.currency as string | null) : null; const summary: ListingSummary = { sourceId: rec.sourceId, sourceUrl: rec.sourceUrl, rawTitle: rec.rawTitle, price, currency, kind: rec.kind, grader: rec.grade.grader ? (getGrader(rec.grade.grader)?.slug ?? rec.grade.grader) : null, grade: rec.grade.grade, imageUrls: rec.imageUrls, identifiers: rec.attributes.identifiers, discountToRiv: null, verdict: 'unknown', }; return { summary, description: rec.description, records }; } export function aiConfigured(): boolean { return getRouter().configured; }