AI package, scanner, research, admin console, public API (agent F2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
76 changed files +7,807 −7
modified
apps/api/package.json
+33 −1
@@ -1 +1,33 @@ | ||
| 1 | −{"name":"@rareindex/api","version":"0.1.0","private":true,"type":"module","scripts":{"typecheck":"tsc -p tsconfig.json --noEmit","test":"vitest run --passWithNoTests","build":"tsc -p tsconfig.json --noEmit"},"dependencies":{"@rareindex/shared":"workspace:*"},"devDependencies":{"@types/node":"^24.0.0","typescript":"^5.9.3","vitest":"^3.2.0"}} | |
| 1 | +{ | |
| 2 | + "name": "@rareindex/api", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "dev": "tsx watch src/server.ts", | |
| 8 | + "start": "tsx src/server.ts", | |
| 9 | + "build": "tsc -p tsconfig.json --noEmit", | |
| 10 | + "typecheck": "tsc -p tsconfig.json --noEmit", | |
| 11 | + "test": "vitest run --passWithNoTests", | |
| 12 | + "create-key": "tsx scripts/create-key.ts" | |
| 13 | + }, | |
| 14 | + "dependencies": { | |
| 15 | + "@fastify/compress": "^9.0.0", | |
| 16 | + "@fastify/cors": "^11.0.0", | |
| 17 | + "@fastify/etag": "^6.0.0", | |
| 18 | + "@fastify/rate-limit": "^11.0.0", | |
| 19 | + "@rareindex/connectors": "workspace:*", | |
| 20 | + "@rareindex/database": "workspace:*", | |
| 21 | + "@rareindex/shared": "workspace:*", | |
| 22 | + "@rareindex/taxonomy": "workspace:*", | |
| 23 | + "fastify": "^5.6.0", | |
| 24 | + "fastify-plugin": "^6.0.0", | |
| 25 | + "zod": "^4.0.0" | |
| 26 | + }, | |
| 27 | + "devDependencies": { | |
| 28 | + "@types/node": "^24.0.0", | |
| 29 | + "tsx": "^4.20.0", | |
| 30 | + "typescript": "^5.9.3", | |
| 31 | + "vitest": "^3.2.0" | |
| 32 | + } | |
| 33 | +} | |
added
apps/api/scripts/create-key.ts
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +/** | |
| 2 | + * Mint an API key. Prints the plaintext ONCE; only its hash is stored. | |
| 3 | + * pnpm --filter @rareindex/api create-key -- --name "Research desk" --tier professional [--user usr_xxx] | |
| 4 | + */ | |
| 5 | +import { closeDb } from '@rareindex/database'; | |
| 6 | +import { mintApiKey, TIERS, type Tier } from '../src/lib/keys.js'; | |
| 7 | + | |
| 8 | +const args = process.argv.slice(2); | |
| 9 | +const get = (flag: string) => { | |
| 10 | + const i = args.indexOf(flag); | |
| 11 | + return i >= 0 ? args[i + 1] : undefined; | |
| 12 | +}; | |
| 13 | +const name = get('--name') ?? 'default'; | |
| 14 | +const tier = (get('--tier') ?? 'free') as Tier; | |
| 15 | +if (!(tier in TIERS)) { | |
| 16 | + console.error(`unknown tier ${tier}; choose one of ${Object.keys(TIERS).join(', ')}`); | |
| 17 | + process.exit(1); | |
| 18 | +} | |
| 19 | +const minted = await mintApiKey({ name, tier, userId: get('--user') ?? null }); | |
| 20 | +console.log(`API key created (${minted.tier}, id ${minted.id}). Store it now — it will not be shown again:\n\n ${minted.key}\n`); | |
| 21 | +await closeDb(); | |
added
apps/api/scripts/gen-openapi.ts
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +import { writeFileSync } from 'node:fs'; | |
| 2 | +import path from 'node:path'; | |
| 3 | +import { fileURLToPath } from 'node:url'; | |
| 4 | +import { openapiDocument } from '../src/openapi.js'; | |
| 5 | + | |
| 6 | +const here = path.dirname(fileURLToPath(import.meta.url)); | |
| 7 | +const out = path.resolve(here, '../../../docs/openapi.json'); | |
| 8 | +writeFileSync(out, JSON.stringify(openapiDocument(), null, 2) + '\n'); | |
| 9 | +console.log(`wrote ${out}`); | |
added
apps/api/src/app.test.ts
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | |
| 2 | +import type { FastifyInstance } from 'fastify'; | |
| 3 | +import { closeDb, getDb, apiKeys, eq } from '@rareindex/database'; | |
| 4 | +import { buildApp } from './app.js'; | |
| 5 | +import { mintApiKey } from './lib/keys.js'; | |
| 6 | +import { toCsv, encodeCursor, decodeCursor } from './lib/envelope.js'; | |
| 7 | + | |
| 8 | +/** Integration tests against DATABASE_URL (rareindex_ai locally). They only read except for a temporary API key. */ | |
| 9 | +let app: FastifyInstance; | |
| 10 | +let key: { id: string; key: string }; | |
| 11 | + | |
| 12 | +beforeAll(async () => { | |
| 13 | + process.env.LOG_LEVEL = 'silent'; | |
| 14 | + app = await buildApp({ logger: false }); | |
| 15 | + key = await mintApiKey({ name: 'vitest', tier: 'hobby' }); | |
| 16 | +}); | |
| 17 | +afterAll(async () => { | |
| 18 | + await getDb().delete(apiKeys).where(eq(apiKeys.id, key.id)); | |
| 19 | + await app.close(); | |
| 20 | + await closeDb(); | |
| 21 | +}); | |
| 22 | + | |
| 23 | +describe('api', () => { | |
| 24 | + it('health and root', async () => { | |
| 25 | + const h = await app.inject({ method: 'GET', url: '/healthz' }); | |
| 26 | + expect(h.statusCode).toBe(200); | |
| 27 | + expect(h.json().ok).toBe(true); | |
| 28 | + const r = await app.inject({ method: 'GET', url: '/' }); | |
| 29 | + expect(r.json().openapi).toBe('/v1/openapi.json'); | |
| 30 | + }); | |
| 31 | + it('serves openapi', async () => { | |
| 32 | + const r = await app.inject({ method: 'GET', url: '/v1/openapi.json' }); | |
| 33 | + expect(r.statusCode).toBe(200); | |
| 34 | + expect(r.json().paths['/v1/assets/search']).toBeTruthy(); | |
| 35 | + }); | |
| 36 | + it('anonymous requests use the public tier with rate-limit headers', async () => { | |
| 37 | + const r = await app.inject({ method: 'GET', url: '/v1/categories' }); | |
| 38 | + expect(r.statusCode).toBe(200); | |
| 39 | + expect(r.headers['x-ratelimit-limit']).toBe('20'); | |
| 40 | + expect(r.headers['x-request-id']).toBeTruthy(); | |
| 41 | + const body = r.json(); | |
| 42 | + expect(body.meta.count).toBeGreaterThan(100); | |
| 43 | + expect(body.meta.attribution).toMatch(/RareIndex/); | |
| 44 | + expect(body.data.find((c: { slug: string }) => c.slug === 'pokemon')).toBeTruthy(); | |
| 45 | + }); | |
| 46 | + it('rejects unknown keys and accepts minted keys with tier limits', async () => { | |
| 47 | + const bad = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: 'Bearer ri_live_nope_nope' } }); | |
| 48 | + expect(bad.statusCode).toBe(401); | |
| 49 | + expect(bad.headers['content-type']).toMatch(/problem\+json/); | |
| 50 | + const good = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: `Bearer ${key.key}` } }); | |
| 51 | + expect(good.statusCode).toBe(200); | |
| 52 | + expect(good.headers['x-ratelimit-limit']).toBe('120'); | |
| 53 | + const idx = good.json(); | |
| 54 | + expect(idx.data.find((i: { ticker: string }) => i.ticker === 'RARE')).toBeTruthy(); | |
| 55 | + expect(idx.data.every((i: { published: boolean; value: unknown }) => i.published === (i.value !== null))).toBe(true); | |
| 56 | + }); | |
| 57 | + it('validates queries and returns problem+json', async () => { | |
| 58 | + const r = await app.inject({ method: 'GET', url: '/v1/assets/search?limit=9999' }); | |
| 59 | + expect(r.statusCode).toBe(400); | |
| 60 | + expect(r.json().title).toBe('Invalid query'); | |
| 61 | + }); | |
| 62 | + it('404 on unknown asset/market', async () => { | |
| 63 | + expect((await app.inject({ method: 'GET', url: '/v1/assets/does-not-exist' })).statusCode).toBe(404); | |
| 64 | + expect((await app.inject({ method: 'GET', url: '/v1/markets/does-not-exist' })).statusCode).toBe(404); | |
| 65 | + }); | |
| 66 | + it('search, trending, sales, records, markets and csv export respond', async () => { | |
| 67 | + for (const url of ['/v1/assets/search?q=charizard', '/v1/trending', '/v1/sales/latest', '/v1/records', '/v1/markets', '/v1/markets/pokemon', '/v1/indices/RARE-TCG/history', '/v1/stats']) { | |
| 68 | + const r = await app.inject({ method: 'GET', url }); | |
| 69 | + expect(r.statusCode, url).toBe(200); | |
| 70 | + } | |
| 71 | + const csv = await app.inject({ method: 'GET', url: '/v1/categories?format=csv' }); | |
| 72 | + expect(csv.headers['content-type']).toMatch(/text\/csv/); | |
| 73 | + expect(csv.body.split('\n')[0]).toContain('slug'); | |
| 74 | + }); | |
| 75 | + it('rate limits by key', async () => { | |
| 76 | + const small = await mintApiKey({ name: 'vitest-small', tier: 'free' }); | |
| 77 | + try { | |
| 78 | + let last = 200; | |
| 79 | + for (let i = 0; i < 65; i++) { | |
| 80 | + const r = await app.inject({ method: 'GET', url: '/v1/stats', headers: { authorization: `Bearer ${small.key}` } }); | |
| 81 | + last = r.statusCode; | |
| 82 | + if (last === 429) break; | |
| 83 | + } | |
| 84 | + expect(last).toBe(429); | |
| 85 | + } finally { | |
| 86 | + await getDb().delete(apiKeys).where(eq(apiKeys.id, small.id)); | |
| 87 | + } | |
| 88 | + }); | |
| 89 | +}); | |
| 90 | + | |
| 91 | +describe('envelope helpers', () => { | |
| 92 | + it('csv + cursor', () => { | |
| 93 | + expect(toCsv([{ a: 1, b: 'x,y' }, { a: 2, b: null }])).toBe('a,b\n1,"x,y"\n2,\n'); | |
| 94 | + expect(decodeCursor(encodeCursor(150))).toBe(150); | |
| 95 | + expect(decodeCursor('garbage')).toBe(0); | |
| 96 | + }); | |
| 97 | +}); | |
added
apps/api/src/app.ts
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +import Fastify, { type FastifyInstance } from 'fastify'; | |
| 2 | +import cors from '@fastify/cors'; | |
| 3 | +import compress from '@fastify/compress'; | |
| 4 | +import etag from '@fastify/etag'; | |
| 5 | +import rateLimit from '@fastify/rate-limit'; | |
| 6 | +import { randomUUID } from 'node:crypto'; | |
| 7 | +import { getSql } from '@rareindex/database'; | |
| 8 | +import { authPlugin, rateKeyFor, rateLimitFor } from './plugins/auth.js'; | |
| 9 | +import { v1Routes } from './routes/v1.js'; | |
| 10 | +import { internalRoutes } from './routes/internal.js'; | |
| 11 | +import { problem } from './lib/envelope.js'; | |
| 12 | + | |
| 13 | +export interface BuildOptions { | |
| 14 | + logger?: boolean; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance> { | |
| 18 | + const app = Fastify({ | |
| 19 | + logger: opts.logger === false ? false : { level: process.env.LOG_LEVEL ?? 'info', redact: ['req.headers.authorization'] }, | |
| 20 | + genReqId: (req) => (req.headers['x-request-id'] as string | undefined) ?? randomUUID(), | |
| 21 | + trustProxy: true, | |
| 22 | + }); | |
| 23 | + | |
| 24 | + await app.register(cors, { origin: true, methods: ['GET', 'HEAD', 'OPTIONS'], exposedHeaders: ['x-request-id', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset'] }); | |
| 25 | + await app.register(compress, { global: true, encodings: ['gzip', 'br', 'deflate'] }); | |
| 26 | + await app.register(etag); | |
| 27 | + await app.register(authPlugin); | |
| 28 | + await app.register(rateLimit, { | |
| 29 | + global: true, | |
| 30 | + max: (req) => rateLimitFor(req), | |
| 31 | + timeWindow: '1 minute', | |
| 32 | + keyGenerator: (req) => rateKeyFor(req), | |
| 33 | + errorResponseBuilder: (req, ctx) => ({ | |
| 34 | + statusCode: 429, | |
| 35 | + type: 'https://www.rareindex.io/api-docs#errors', | |
| 36 | + title: 'Rate limit exceeded', | |
| 37 | + status: 429, | |
| 38 | + detail: `Limit ${ctx.max} requests per ${ctx.after}. Retry after ${ctx.ttl} ms.`, | |
| 39 | + instance: req.url, | |
| 40 | + request_id: req.id, | |
| 41 | + }), | |
| 42 | + }); | |
| 43 | + | |
| 44 | + app.addHook('onSend', async (req, reply, payload) => { | |
| 45 | + reply.header('x-request-id', req.id); | |
| 46 | + return payload; | |
| 47 | + }); | |
| 48 | + | |
| 49 | + app.setErrorHandler((err: Error & { statusCode?: number; status?: number; title?: string; detail?: string }, req, reply) => { | |
| 50 | + const status = err.statusCode ?? err.status ?? 500; | |
| 51 | + if (status >= 500) req.log.error({ err }, 'unhandled api error'); | |
| 52 | + const title = status >= 500 ? 'Internal error' : (err.title ?? err.message); | |
| 53 | + const detail = status >= 500 ? 'The request could not be completed.' : (err.detail ?? err.message); | |
| 54 | + return problem(reply, status, title, detail); | |
| 55 | + }); | |
| 56 | + app.setNotFoundHandler((req, reply) => problem(reply, 404, 'Not found', `No route for ${req.method} ${req.url}`)); | |
| 57 | + | |
| 58 | + app.get('/healthz', async (_req, reply) => { | |
| 59 | + try { | |
| 60 | + await getSql()`select 1`; | |
| 61 | + return { ok: true, service: 'rareindex-api', time: new Date().toISOString() }; | |
| 62 | + } catch (err) { | |
| 63 | + return reply.code(503).send({ ok: false, service: 'rareindex-api', error: err instanceof Error ? err.message : String(err) }); | |
| 64 | + } | |
| 65 | + }); | |
| 66 | + app.get('/', async () => ({ name: 'RareIndex Public API', docs: 'https://www.rareindex.io/api-docs', openapi: '/v1/openapi.json' })); | |
| 67 | + | |
| 68 | + await app.register(v1Routes); | |
| 69 | + await app.register(internalRoutes); | |
| 70 | + return app; | |
| 71 | +} | |
added
apps/api/src/lib/envelope.ts
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +import type { FastifyReply } from 'fastify'; | |
| 2 | + | |
| 3 | +export const ATTRIBUTION = 'Data © RareIndex.io and its sources; valuations are estimates, listing prices are not confirmed transactions. See https://www.rareindex.io/data for methodology and terms.'; | |
| 4 | + | |
| 5 | +export interface Meta { | |
| 6 | + count: number; | |
| 7 | + cursor?: string | null; | |
| 8 | + as_of: string; | |
| 9 | + attribution: string; | |
| 10 | + [k: string]: unknown; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function envelope<T>(data: T, meta: Partial<Meta> & { count?: number } = {}) { | |
| 14 | + const count = meta.count ?? (Array.isArray(data) ? data.length : 1); | |
| 15 | + return { data, meta: { count, cursor: meta.cursor ?? null, as_of: new Date().toISOString(), attribution: ATTRIBUTION, ...meta } }; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export class ApiProblem extends Error { | |
| 19 | + constructor( | |
| 20 | + public status: number, | |
| 21 | + public title: string, | |
| 22 | + detail?: string, | |
| 23 | + public type = 'about:blank', | |
| 24 | + ) { | |
| 25 | + super(detail ?? title); | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function problem(reply: FastifyReply, status: number, title: string, detail?: string, extra: Record<string, unknown> = {}) { | |
| 30 | + return reply | |
| 31 | + .code(status) | |
| 32 | + .type('application/problem+json') | |
| 33 | + .send({ type: 'https://www.rareindex.io/api-docs#errors', title, status, detail: detail ?? title, instance: reply.request.url, request_id: reply.request.id, ...extra }); | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** Opaque offset cursor. */ | |
| 37 | +export function encodeCursor(offset: number): string { | |
| 38 | + return Buffer.from(String(offset), 'utf8').toString('base64url'); | |
| 39 | +} | |
| 40 | +export function decodeCursor(cursor: string | undefined | null): number { | |
| 41 | + if (!cursor) return 0; | |
| 42 | + const n = Number(Buffer.from(cursor, 'base64url').toString('utf8')); | |
| 43 | + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** Minimal CSV export for flat row arrays. */ | |
| 47 | +export function toCsv(rows: Array<Record<string, unknown>>): string { | |
| 48 | + if (rows.length === 0) return ''; | |
| 49 | + const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))]; | |
| 50 | + const esc = (v: unknown) => { | |
| 51 | + if (v === null || v === undefined) return ''; | |
| 52 | + const s = v instanceof Date ? v.toISOString() : typeof v === 'object' ? JSON.stringify(v) : String(v); | |
| 53 | + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; | |
| 54 | + }; | |
| 55 | + return [cols.join(','), ...rows.map((r) => cols.map((c) => esc(r[c])).join(','))].join('\n') + '\n'; | |
| 56 | +} | |
| 57 | + | |
| 58 | +export function wantsCsv(query: Record<string, unknown>): boolean { | |
| 59 | + return String(query.format ?? '').toLowerCase() === 'csv'; | |
| 60 | +} | |
| 61 | + | |
| 62 | +export function sendList(reply: FastifyReply, rows: Array<Record<string, unknown>>, meta: Partial<Meta> = {}, query: Record<string, unknown> = {}) { | |
| 63 | + if (wantsCsv(query)) return reply.type('text/csv; charset=utf-8').header('content-disposition', 'attachment; filename="rareindex-export.csv"').send(toCsv(rows)); | |
| 64 | + return reply.send(envelope(rows, meta)); | |
| 65 | +} | |
added
apps/api/src/lib/keys.ts
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +import { randomBytes } from 'node:crypto'; | |
| 2 | +import { sha256, newId } from '@rareindex/shared'; | |
| 3 | +import { getDb, apiKeys, eq } from '@rareindex/database'; | |
| 4 | + | |
| 5 | +/** API tiers (§158). Limits are per key per minute + a daily quota. */ | |
| 6 | +export const TIERS = { | |
| 7 | + public: { rateLimitPerMinute: 20, dailyQuota: 500 }, | |
| 8 | + free: { rateLimitPerMinute: 60, dailyQuota: 1_000 }, | |
| 9 | + hobby: { rateLimitPerMinute: 120, dailyQuota: 10_000 }, | |
| 10 | + professional: { rateLimitPerMinute: 600, dailyQuota: 100_000 }, | |
| 11 | + research: { rateLimitPerMinute: 600, dailyQuota: 250_000 }, | |
| 12 | + enterprise: { rateLimitPerMinute: 3_000, dailyQuota: 5_000_000 }, | |
| 13 | +} as const; | |
| 14 | +export type Tier = keyof typeof TIERS; | |
| 15 | + | |
| 16 | +export interface MintedKey { | |
| 17 | + id: string; | |
| 18 | + key: string; | |
| 19 | + prefix: string; | |
| 20 | + tier: Tier; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** Create a key: `ri_live_<prefix>_<secret>`; only the hash is stored. The plaintext is returned once. */ | |
| 24 | +export async function mintApiKey(input: { name: string; tier?: Tier; userId?: string | null }): Promise<MintedKey> { | |
| 25 | + const tier = input.tier ?? 'free'; | |
| 26 | + const prefix = randomBytes(4).toString('hex'); | |
| 27 | + const secret = randomBytes(24).toString('base64url'); | |
| 28 | + const key = `ri_live_${prefix}_${secret}`; | |
| 29 | + const id = newId('apiKey'); | |
| 30 | + const t = TIERS[tier]; | |
| 31 | + await getDb().insert(apiKeys).values({ id, userId: input.userId ?? null, name: input.name, prefix, keyHash: sha256(key), tier, rateLimitPerMinute: t.rateLimitPerMinute, dailyQuota: t.dailyQuota }); | |
| 32 | + return { id, key, prefix, tier }; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export interface ResolvedKey { | |
| 36 | + id: string; | |
| 37 | + tier: Tier; | |
| 38 | + rateLimitPerMinute: number; | |
| 39 | + dailyQuota: number; | |
| 40 | + userId: string | null; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export function parseBearer(header: string | undefined): string | null { | |
| 44 | + if (!header) return null; | |
| 45 | + const m = header.match(/^Bearer\s+(\S+)$/i); | |
| 46 | + return m ? m[1]! : null; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export async function resolveApiKey(key: string): Promise<ResolvedKey | null> { | |
| 50 | + if (!key.startsWith('ri_live_')) return null; | |
| 51 | + const [row] = await getDb().select().from(apiKeys).where(eq(apiKeys.keyHash, sha256(key))).limit(1); | |
| 52 | + if (!row || row.revokedAt) return null; | |
| 53 | + return { id: row.id, tier: (row.tier as Tier) ?? 'free', rateLimitPerMinute: row.rateLimitPerMinute, dailyQuota: row.dailyQuota, userId: row.userId }; | |
| 54 | +} | |
added
apps/api/src/lib/queries.ts
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +import { getDb, sql } from '@rareindex/database'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Read-only query helpers for the public API. Plain SQL through drizzle's `sql` tag: every | |
| 5 | + * function returns already-shaped rows so route handlers stay thin. Nothing here writes. | |
| 6 | + */ | |
| 7 | +type Row = Record<string, unknown>; | |
| 8 | +async function rows<T = Row>(q: ReturnType<typeof sql>): Promise<T[]> { | |
| 9 | + const res = await getDb().execute(q); | |
| 10 | + return res as unknown as T[]; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export const ASSET_COLUMNS = sql`a.id, a.slug, a.title, a.name, a.category_slug, a.family_slug, a.brand, a.franchise, a.set_name, a.set_code, a.number, a.year, a.edition, a.variant, a.language, a.hero_image_url, a.identifiers, a.data_quality, | |
| 14 | + s.riv_usd, s.riv_low_usd, s.riv_high_usd, s.riv_confidence, s.riv_sample_size, s.latest_sale_usd, s.latest_sale_at, s.change_7d, s.change_30d, s.change_1y, s.ath_usd, s.atl_usd, | |
| 15 | + s.sales_count, s.sales_30d, s.active_listings, s.min_ask_usd, s.liquidity_score, s.rarity_score, s.momentum_30d, s.trending_score, s.value_opportunity, s.updated_at as stats_updated_at`; | |
| 16 | + | |
| 17 | +export async function searchAssets(opts: { q?: string; category?: string; limit: number; offset: number }) { | |
| 18 | + const q = opts.q?.trim(); | |
| 19 | + const catFilter = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``; | |
| 20 | + if (q) { | |
| 21 | + const tsq = q | |
| 22 | + .split(/\s+/) | |
| 23 | + .filter(Boolean) | |
| 24 | + .map((t) => t.replace(/[^\p{L}\p{N}./-]/gu, '')) | |
| 25 | + .filter(Boolean) | |
| 26 | + .map((t) => `${t}:*`) | |
| 27 | + .join(' & '); | |
| 28 | + return rows(sql` | |
| 29 | + select ${ASSET_COLUMNS}, | |
| 30 | + (coalesce(ts_rank(a.search, to_tsquery('simple', ${tsq})), 0) * 2 + similarity(a.title, ${q}) + coalesce(log(1 + s.sales_count), 0) * 0.05) as score | |
| 31 | + from assets a left join asset_stats s on s.asset_id = a.id | |
| 32 | + where (a.search @@ to_tsquery('simple', ${tsq}) or a.title % ${q} or a.identifiers::text ilike ${'%' + q + '%'}) ${catFilter} | |
| 33 | + order by score desc, s.sales_count desc nulls last | |
| 34 | + limit ${opts.limit} offset ${opts.offset}`); | |
| 35 | + } | |
| 36 | + return rows(sql` | |
| 37 | + select ${ASSET_COLUMNS}, 0 as score from assets a left join asset_stats s on s.asset_id = a.id | |
| 38 | + where true ${catFilter} | |
| 39 | + order by s.sales_count desc nulls last, a.updated_at desc | |
| 40 | + limit ${opts.limit} offset ${opts.offset}`); | |
| 41 | +} | |
| 42 | + | |
| 43 | +export async function getAsset(idOrSlug: string) { | |
| 44 | + const [asset] = await rows(sql`select ${ASSET_COLUMNS}, a.description, a.reference, a.model, a.series, a.rarity, a.production_quantity, a.original_msrp, a.original_msrp_currency, a.release_date, a.metadata, a.created_at, a.updated_at | |
| 45 | + from assets a left join asset_stats s on s.asset_id = a.id where a.id = ${idOrSlug} or a.slug = ${idOrSlug} limit 1`); | |
| 46 | + if (!asset) return null; | |
| 47 | + const id = asset.id as string; | |
| 48 | + const variants = await rows(sql`select v.id, v.variant_key, v.label, v.grader, v.grade, v.qualifier, v.condition, v.completeness, v.is_default, | |
| 49 | + vs.riv_usd, vs.riv_low_usd, vs.riv_high_usd, vs.riv_confidence, vs.riv_sample_size, vs.latest_sale_usd, vs.latest_sale_at, vs.change_30d, vs.change_1y, vs.sales_count, vs.sales_30d, vs.active_listings, vs.min_ask_usd, vs.liquidity_score | |
| 50 | + from asset_variants v left join variant_stats vs on vs.variant_id = v.id where v.asset_id = ${id} order by vs.sales_count desc nulls last, v.label`); | |
| 51 | + const [valuation] = await rows(sql`select id, variant_id, computed_at, riv_usd, low_usd, high_usd, confidence, confidence_label, sample_size, window_days, methods, method, notes from valuations where asset_id = ${id} and variant_id is null order by computed_at desc limit 1`); | |
| 52 | + const sources = await rows(sql`select source_id, count(*)::int as sales from sales where asset_id = ${id} and status = 'valid' group by source_id order by sales desc`); | |
| 53 | + return { ...(asset as Row & { id: string }), variants, valuation: valuation ?? null, sources }; | |
| 54 | +} | |
| 55 | + | |
| 56 | +export async function assetSales(assetId: string, opts: { limit: number; offset: number; variantId?: string; includeFlagged?: boolean }) { | |
| 57 | + const statusFilter = opts.includeFlagged ? sql`and status <> 'excluded'` : sql`and status = 'valid'`; | |
| 58 | + const variantFilter = opts.variantId ? sql`and variant_id = ${opts.variantId}` : sql``; | |
| 59 | + return rows(sql`select id, variant_id, source_id, source_url, sale_type, sale_date, price, currency, price_usd, buyer_premium_included, condition, grader, grade, certification_number, auction_house, lot_number, image_urls, raw_title, confidence, data_quality, status, flags | |
| 60 | + from sales where asset_id = ${assetId} ${statusFilter} ${variantFilter} order by sale_date desc limit ${opts.limit} offset ${opts.offset}`); | |
| 61 | +} | |
| 62 | + | |
| 63 | +export async function assetListings(assetId: string, opts: { limit: number; offset: number; availability?: string }) { | |
| 64 | + const avail = opts.availability ?? 'available'; | |
| 65 | + return rows(sql`select id, variant_id, source_id, source_url, listing_type, price, currency, price_usd, seller, location, shipping_cost, condition, grader, grade, certification_number, image_urls, raw_title, listed_at, ends_at, availability, bid_count, first_seen_at, last_seen_at, discount_to_riv, flags | |
| 66 | + from listings where asset_id = ${assetId} and availability = ${avail} order by price_usd asc nulls last limit ${opts.limit} offset ${opts.offset}`); | |
| 67 | +} | |
| 68 | + | |
| 69 | +export async function assetHistory(assetId: string, opts: { variantId?: string; from?: string; to?: string }) { | |
| 70 | + const variant = opts.variantId ?? ''; | |
| 71 | + const from = opts.from ?? '1900-01-01'; | |
| 72 | + const to = opts.to ?? '2999-12-31'; | |
| 73 | + return rows(sql`select date, riv_usd, latest_sale_usd, median_usd, sales_count, volume_usd, listings_count, min_ask_usd, observation_usd | |
| 74 | + from price_snapshots where asset_id = ${assetId} and variant_id = ${variant} and date between ${from} and ${to} order by date asc`); | |
| 75 | +} | |
| 76 | + | |
| 77 | +export async function listCategories() { | |
| 78 | + return rows(sql`select c.slug, c.parent_slug, c.family_slug, c.name, c.short_name, c.description, c.level, c.phase, c.active, c.index_ticker, c.condition_scale, c.graders, | |
| 79 | + (select count(*)::int from assets a where a.category_slug = c.slug) as tracked_assets | |
| 80 | + from categories c where c.active order by c.sort_order`); | |
| 81 | +} | |
| 82 | + | |
| 83 | +export async function listIndices() { | |
| 84 | + return rows(sql`with latest as ( | |
| 85 | + select distinct on (index_id) index_id, date, value, constituents_count, transactions, volume_usd, median_sale_usd, market_cap_est_usd, market_cap_confidence, liquidity_score, momentum, tracked_assets, coverage | |
| 86 | + from index_values order by index_id, date desc) | |
| 87 | + select i.id, i.ticker, i.name, i.description, i.parent_ticker, i.family_slugs, i.methodology, i.weighting, i.base_date, i.base_value, i.min_constituents, i.is_flagship, i.color, | |
| 88 | + l.date as as_of, l.value, l.constituents_count, l.transactions, l.volume_usd, l.median_sale_usd, l.market_cap_est_usd, l.market_cap_confidence, l.liquidity_score, l.momentum, l.tracked_assets, l.coverage, | |
| 89 | + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '1 day' order by date desc limit 1) as value_1d, | |
| 90 | + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '7 day' order by date desc limit 1) as value_7d, | |
| 91 | + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '30 day' order by date desc limit 1) as value_30d, | |
| 92 | + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '365 day' order by date desc limit 1) as value_1y, | |
| 93 | + (select value from index_values v where v.index_id = i.id and v.date <= date_trunc('year', l.date)::date order by date desc limit 1) as value_ytd | |
| 94 | + from indices i left join latest l on l.index_id = i.id where i.active order by i.is_flagship desc, i.ticker`); | |
| 95 | +} | |
| 96 | + | |
| 97 | +export async function indexHistory(ticker: string, opts: { from?: string; to?: string }) { | |
| 98 | + const from = opts.from ?? '1900-01-01'; | |
| 99 | + const to = opts.to ?? '2999-12-31'; | |
| 100 | + return rows(sql`select v.date, v.value, v.constituents_count, v.transactions, v.volume_usd, v.median_sale_usd, v.avg_sale_usd, v.market_cap_est_usd, v.liquidity_score, v.momentum, v.coverage | |
| 101 | + from index_values v join indices i on i.id = v.index_id where i.ticker = ${ticker} and v.date between ${from} and ${to} order by v.date asc`); | |
| 102 | +} | |
| 103 | + | |
| 104 | +export async function listMarkets() { | |
| 105 | + return rows(sql`with latest as ( | |
| 106 | + select distinct on (category_slug) * from category_snapshots order by category_slug, date desc) | |
| 107 | + select c.slug, c.name, c.family_slug, c.level, c.index_ticker, l.date as as_of, l.index_value, l.tracked_assets, l.assets_with_valuation, l.sales, l.volume_usd, l.median_sale_usd, l.active_listings, l.market_cap_est_usd, l.liquidity_score, l.change_1d, l.change_7d, l.change_30d, l.change_1y, | |
| 108 | + (select count(*)::int from assets a where a.family_slug = c.slug or a.category_slug = c.slug) as assets_now | |
| 109 | + from categories c left join latest l on l.category_slug = c.slug where c.active and c.level = 0 order by c.sort_order`); | |
| 110 | +} | |
| 111 | + | |
| 112 | +export async function getMarket(slug: string) { | |
| 113 | + const [category] = await rows(sql`select slug, parent_slug, family_slug, name, description, level, phase, index_ticker, condition_scale, graders from categories where slug = ${slug}`); | |
| 114 | + if (!category) return null; | |
| 115 | + const [snapshot] = await rows(sql`select * from category_snapshots where category_slug = ${slug} order by date desc limit 1`); | |
| 116 | + const scope = sql`(a.category_slug = ${slug} or a.family_slug = ${slug})`; | |
| 117 | + const [counts] = await rows(sql`select count(*)::int as tracked_assets, count(s.riv_usd)::int as valued_assets, coalesce(sum(s.sales_count),0)::int as sales_total, coalesce(sum(s.active_listings),0)::int as active_listings from assets a left join asset_stats s on s.asset_id = a.id where ${scope}`); | |
| 118 | + const gainers = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.change_30d is not null and s.riv_sample_size >= 3 order by s.change_30d desc limit 10`); | |
| 119 | + const losers = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.change_30d is not null and s.riv_sample_size >= 3 order by s.change_30d asc limit 10`); | |
| 120 | + const mostValuable = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.riv_usd is not null order by s.riv_usd desc limit 10`); | |
| 121 | + const mostLiquid = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.liquidity_score is not null order by s.liquidity_score desc limit 10`); | |
| 122 | + const recentSales = await rows(sql`select sa.id, sa.asset_id, a.slug as asset_slug, a.title, sa.source_id, sa.source_url, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade from sales sa join assets a on a.id = sa.asset_id where ${scope} and sa.status = 'valid' order by sa.sale_date desc limit 20`); | |
| 123 | + const history = await rows(sql`select date, index_value, sales, volume_usd, median_sale_usd, active_listings, tracked_assets from category_snapshots where category_slug = ${slug} order by date asc`); | |
| 124 | + return { category, snapshot: snapshot ?? null, counts, gainers, losers, most_valuable: mostValuable, most_liquid: mostLiquid, recent_sales: recentSales, history }; | |
| 125 | +} | |
| 126 | + | |
| 127 | +export async function trending(opts: { category?: string; limit: number; offset: number }) { | |
| 128 | + const cat = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``; | |
| 129 | + return rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where s.trending_score is not null ${cat} order by s.trending_score desc limit ${opts.limit} offset ${opts.offset}`); | |
| 130 | +} | |
| 131 | + | |
| 132 | +export async function latestSales(opts: { category?: string; minUsd?: number; limit: number; offset: number }) { | |
| 133 | + const cat = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``; | |
| 134 | + const min = opts.minUsd ? sql`and sa.price_usd >= ${opts.minUsd}` : sql``; | |
| 135 | + return rows(sql`select sa.id, sa.asset_id, a.slug as asset_slug, a.title, a.category_slug, a.hero_image_url, sa.source_id, sa.source_url, sa.sale_type, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade, sa.condition, sa.auction_house, sa.confidence | |
| 136 | + from sales sa join assets a on a.id = sa.asset_id where sa.status = 'valid' ${cat} ${min} order by sa.sale_date desc, sa.created_at desc limit ${opts.limit} offset ${opts.offset}`); | |
| 137 | +} | |
| 138 | + | |
| 139 | +/** Record sales: highest verified transaction per family (§154). */ | |
| 140 | +export async function recordSales() { | |
| 141 | + return rows(sql`select distinct on (a.family_slug) a.family_slug, c.name as family_name, sa.id as sale_id, sa.asset_id, a.slug as asset_slug, a.title, sa.source_id, sa.source_url, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade, sa.auction_house | |
| 142 | + from sales sa join assets a on a.id = sa.asset_id join categories c on c.slug = a.family_slug | |
| 143 | + where sa.status = 'valid' and sa.confidence >= 0.8 and sa.is_bundle = false | |
| 144 | + order by a.family_slug, sa.price_usd desc`); | |
| 145 | +} | |
| 146 | + | |
| 147 | +export async function platformStats() { | |
| 148 | + const [r] = await rows(sql`select | |
| 149 | + (select count(*)::int from assets) as assets, | |
| 150 | + (select count(*)::int from sales where status = 'valid') as sales, | |
| 151 | + (select count(*)::int from listings where availability = 'available') as listings, | |
| 152 | + (select count(*)::int from sources where active) as sources, | |
| 153 | + (select count(*)::int from connectors where status = 'active') as connectors, | |
| 154 | + (select count(*)::int from categories where active) as categories`); | |
| 155 | + return r; | |
| 156 | +} | |
added
apps/api/src/openapi.ts
+91 −0
@@ -0,0 +1,91 @@ | ||
| 1 | +/** OpenAPI 3.1 document for the public API (§157). Served at /v1/openapi.json and written to docs/openapi.json by scripts/gen-openapi.ts. */ | |
| 2 | + | |
| 3 | +const paging = [ | |
| 4 | + { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }, | |
| 5 | + { name: 'cursor', in: 'query', schema: { type: 'string' }, description: 'Opaque cursor from meta.cursor' }, | |
| 6 | + { name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] }, description: 'csv returns a flat export of the rows' }, | |
| 7 | +]; | |
| 8 | +const range = [ | |
| 9 | + { name: 'from', in: 'query', schema: { type: 'string', format: 'date' } }, | |
| 10 | + { name: 'to', in: 'query', schema: { type: 'string', format: 'date' } }, | |
| 11 | + { name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] } }, | |
| 12 | +]; | |
| 13 | +const envelopeOf = (ref: string, list = true) => ({ | |
| 14 | + type: 'object', | |
| 15 | + properties: { | |
| 16 | + data: list ? { type: 'array', items: { $ref: ref } } : { $ref: ref }, | |
| 17 | + meta: { $ref: '#/components/schemas/Meta' }, | |
| 18 | + }, | |
| 19 | +}); | |
| 20 | +const ok = (ref: string, list = true) => ({ 200: { description: 'OK', content: { 'application/json': { schema: envelopeOf(ref, list) } } } }); | |
| 21 | +const errors = { | |
| 22 | + 400: { description: 'Invalid query', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } }, | |
| 23 | + 401: { description: 'Invalid API key', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } }, | |
| 24 | + 404: { description: 'Not found', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } }, | |
| 25 | + 429: { description: 'Rate limit or quota exceeded', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } }, | |
| 26 | +}; | |
| 27 | + | |
| 28 | +export function openapiDocument() { | |
| 29 | + return { | |
| 30 | + openapi: '3.1.0', | |
| 31 | + info: { | |
| 32 | + title: 'RareIndex Public API', | |
| 33 | + version: '1.0.0', | |
| 34 | + description: | |
| 35 | + 'Structured market data for collectible assets: canonical assets, observed sales, live listings, price history, category markets and the RareIndex indices. Valuations are estimates with confidence and sample size; listing prices are not confirmed transactions. Attribution to RareIndex and the original sources is required.', | |
| 36 | + contact: { name: 'RareIndex', url: 'https://www.rareindex.io/api-docs', email: 'api@rareindex.io' }, | |
| 37 | + termsOfService: 'https://www.rareindex.io/about#terms', | |
| 38 | + }, | |
| 39 | + servers: [{ url: 'https://www.rareindex.io/api', description: 'Production (proxied by the web app)' }, { url: 'http://localhost:8211', description: 'Local API service' }], | |
| 40 | + security: [{ bearerAuth: [] }, {}], | |
| 41 | + tags: [ | |
| 42 | + { name: 'assets' }, | |
| 43 | + { name: 'markets' }, | |
| 44 | + { name: 'indices' }, | |
| 45 | + { name: 'sales' }, | |
| 46 | + { name: 'reference' }, | |
| 47 | + ], | |
| 48 | + paths: { | |
| 49 | + '/v1/assets/search': { get: { tags: ['assets'], summary: 'Search canonical assets', parameters: [{ name: 'q', in: 'query', schema: { type: 'string' }, description: 'Natural language query, e.g. "1999 Charizard PSA 10"' }, { name: 'category', in: 'query', schema: { type: 'string' }, description: 'Category or family slug' }, ...paging], responses: { ...ok('#/components/schemas/AssetSummary'), ...errors } } }, | |
| 50 | + '/v1/assets/{id}': { get: { tags: ['assets'], summary: 'Asset detail with variants, latest valuation and sources', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Asset id (rare_…) or slug' }], responses: { ...ok('#/components/schemas/AssetDetail', false), ...errors } } }, | |
| 51 | + '/v1/assets/{id}/sales': { get: { tags: ['assets', 'sales'], summary: 'Observed sales for an asset', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'variant', in: 'query', schema: { type: 'string' } }, { name: 'include_flagged', in: 'query', schema: { type: 'boolean' } }, ...paging], responses: { ...ok('#/components/schemas/Sale'), ...errors } } }, | |
| 52 | + '/v1/assets/{id}/listings': { get: { tags: ['assets'], summary: 'Listings for an asset (asks, not transactions)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'availability', in: 'query', schema: { type: 'string', enum: ['available', 'sold', 'ended', 'removed'], default: 'available' } }, ...paging], responses: { ...ok('#/components/schemas/Listing'), ...errors } } }, | |
| 53 | + '/v1/assets/{id}/history': { get: { tags: ['assets'], summary: 'Daily price history (RIV, latest sale, median, volume, listings)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'variant', in: 'query', schema: { type: 'string' } }, ...range], responses: { ...ok('#/components/schemas/PricePoint'), ...errors } } }, | |
| 54 | + '/v1/categories': { get: { tags: ['reference'], summary: 'Taxonomy', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Category'), ...errors } } }, | |
| 55 | + '/v1/indices': { get: { tags: ['indices'], summary: 'RARE and subindices with latest values and changes', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Index'), ...errors } } }, | |
| 56 | + '/v1/indices/{ticker}/history': { get: { tags: ['indices'], summary: 'Index daily history', parameters: [{ name: 'ticker', in: 'path', required: true, schema: { type: 'string' }, example: 'RARE-TCG' }, ...range], responses: { ...ok('#/components/schemas/IndexPoint'), ...errors } } }, | |
| 57 | + '/v1/markets': { get: { tags: ['markets'], summary: 'Category markets overview (latest snapshot per family)', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Market'), ...errors } } }, | |
| 58 | + '/v1/markets/{slug}': { get: { tags: ['markets'], summary: 'Category market detail: movers, most valuable, most liquid, recent sales, history', parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' }, example: 'pokemon' }], responses: { ...ok('#/components/schemas/MarketDetail', false), ...errors } } }, | |
| 59 | + '/v1/trending': { get: { tags: ['markets'], summary: 'Trending assets', parameters: [{ name: 'category', in: 'query', schema: { type: 'string' } }, ...paging], responses: { ...ok('#/components/schemas/AssetSummary'), ...errors } } }, | |
| 60 | + '/v1/sales/latest': { get: { tags: ['sales'], summary: 'Latest observed sales across the platform', parameters: [{ name: 'category', in: 'query', schema: { type: 'string' } }, { name: 'min_usd', in: 'query', schema: { type: 'number' } }, ...paging], responses: { ...ok('#/components/schemas/SaleWithAsset'), ...errors } } }, | |
| 61 | + '/v1/records': { get: { tags: ['sales'], summary: 'Record sale per family (verified transactions only)', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/SaleWithAsset'), ...errors } } }, | |
| 62 | + '/v1/stats': { get: { tags: ['reference'], summary: 'Platform counts', responses: { ...ok('#/components/schemas/Stats', false) } } }, | |
| 63 | + }, | |
| 64 | + components: { | |
| 65 | + securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', description: 'API key `ri_live_…`. Requests without a key use the public tier (20 req/min).' } }, | |
| 66 | + schemas: { | |
| 67 | + Meta: { type: 'object', properties: { count: { type: 'integer' }, cursor: { type: ['string', 'null'] }, as_of: { type: 'string', format: 'date-time' }, attribution: { type: 'string' } }, additionalProperties: true }, | |
| 68 | + Problem: { type: 'object', properties: { type: { type: 'string' }, title: { type: 'string' }, status: { type: 'integer' }, detail: { type: 'string' }, instance: { type: 'string' }, request_id: { type: 'string' } } }, | |
| 69 | + AssetSummary: { | |
| 70 | + type: 'object', | |
| 71 | + properties: { | |
| 72 | + id: { type: 'string' }, slug: { type: 'string' }, title: { type: 'string' }, name: { type: 'string' }, category_slug: { type: 'string' }, family_slug: { type: 'string' }, brand: { type: ['string', 'null'] }, franchise: { type: ['string', 'null'] }, set_name: { type: ['string', 'null'] }, number: { type: ['string', 'null'] }, year: { type: ['integer', 'null'] }, variant: { type: ['string', 'null'] }, hero_image_url: { type: ['string', 'null'] }, | |
| 73 | + riv_usd: { type: ['number', 'null'], description: 'RareIndex Valuation (estimate)' }, riv_low_usd: { type: ['number', 'null'] }, riv_high_usd: { type: ['number', 'null'] }, riv_confidence: { type: ['number', 'null'] }, riv_sample_size: { type: 'integer' }, | |
| 74 | + latest_sale_usd: { type: ['number', 'null'] }, latest_sale_at: { type: ['string', 'null'] }, change_7d: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] }, change_1y: { type: ['number', 'null'] }, sales_count: { type: 'integer' }, sales_30d: { type: 'integer' }, active_listings: { type: 'integer' }, min_ask_usd: { type: ['number', 'null'] }, liquidity_score: { type: ['number', 'null'] }, rarity_score: { type: ['number', 'null'] }, trending_score: { type: ['number', 'null'] }, | |
| 75 | + }, | |
| 76 | + }, | |
| 77 | + AssetDetail: { allOf: [{ $ref: '#/components/schemas/AssetSummary' }, { type: 'object', properties: { description: { type: ['string', 'null'] }, identifiers: { type: 'object' }, variants: { type: 'array', items: { type: 'object' } }, valuation: { type: ['object', 'null'] }, sources: { type: 'array', items: { type: 'object' } } } }] }, | |
| 78 | + Sale: { type: 'object', properties: { id: { type: 'string' }, source_id: { type: 'string' }, source_url: { type: 'string' }, sale_type: { type: 'string' }, sale_date: { type: 'string' }, price: { type: 'number' }, currency: { type: 'string' }, price_usd: { type: 'number' }, grader: { type: ['string', 'null'] }, grade: { type: ['string', 'null'] }, condition: { type: ['string', 'null'] }, status: { type: 'string' }, confidence: { type: 'number' } } }, | |
| 79 | + SaleWithAsset: { allOf: [{ $ref: '#/components/schemas/Sale' }, { type: 'object', properties: { asset_id: { type: 'string' }, asset_slug: { type: 'string' }, title: { type: 'string' } } }] }, | |
| 80 | + Listing: { type: 'object', properties: { id: { type: 'string' }, source_id: { type: 'string' }, source_url: { type: 'string' }, listing_type: { type: 'string' }, price: { type: ['number', 'null'] }, currency: { type: ['string', 'null'] }, price_usd: { type: ['number', 'null'] }, availability: { type: 'string' }, discount_to_riv: { type: ['number', 'null'] } } }, | |
| 81 | + PricePoint: { type: 'object', properties: { date: { type: 'string', format: 'date' }, riv_usd: { type: ['number', 'null'] }, latest_sale_usd: { type: ['number', 'null'] }, median_usd: { type: ['number', 'null'] }, sales_count: { type: 'integer' }, volume_usd: { type: ['number', 'null'] }, listings_count: { type: 'integer' }, min_ask_usd: { type: ['number', 'null'] } } }, | |
| 82 | + Category: { type: 'object', properties: { slug: { type: 'string' }, parent_slug: { type: ['string', 'null'] }, family_slug: { type: 'string' }, name: { type: 'string' }, level: { type: 'integer' }, phase: { type: 'integer' }, index_ticker: { type: ['string', 'null'] }, tracked_assets: { type: 'integer' } } }, | |
| 83 | + Index: { type: 'object', properties: { ticker: { type: 'string' }, name: { type: 'string' }, is_flagship: { type: 'boolean' }, as_of: { type: ['string', 'null'] }, value: { type: ['number', 'null'] }, published: { type: 'boolean' }, change_1d: { type: ['number', 'null'] }, change_7d: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] }, change_ytd: { type: ['number', 'null'] }, change_1y: { type: ['number', 'null'] }, constituents_count: { type: ['integer', 'null'] }, transactions: { type: ['integer', 'null'] }, market_cap_est_usd: { type: ['number', 'null'] }, market_cap_confidence: { type: ['string', 'null'] } } }, | |
| 84 | + IndexPoint: { type: 'object', properties: { date: { type: 'string', format: 'date' }, value: { type: 'number' }, constituents_count: { type: 'integer' }, transactions: { type: 'integer' }, volume_usd: { type: ['number', 'null'] } } }, | |
| 85 | + Market: { type: 'object', properties: { slug: { type: 'string' }, name: { type: 'string' }, as_of: { type: ['string', 'null'] }, index_value: { type: ['number', 'null'] }, tracked_assets: { type: ['integer', 'null'] }, sales: { type: ['integer', 'null'] }, volume_usd: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] } } }, | |
| 86 | + MarketDetail: { type: 'object', properties: { category: { type: 'object' }, snapshot: { type: ['object', 'null'] }, counts: { type: 'object' }, gainers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, losers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_valuable: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_liquid: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, recent_sales: { type: 'array', items: { type: 'object' } }, history: { type: 'array', items: { type: 'object' } } } }, | |
| 87 | + Stats: { type: 'object', properties: { assets: { type: 'integer' }, sales: { type: 'integer' }, listings: { type: 'integer' }, sources: { type: 'integer' }, connectors: { type: 'integer' }, categories: { type: 'integer' } } }, | |
| 88 | + }, | |
| 89 | + }, | |
| 90 | + }; | |
| 91 | +} | |
added
apps/api/src/plugins/auth.ts
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +import type { FastifyInstance, FastifyRequest } from 'fastify'; | |
| 2 | +import fp from 'fastify-plugin'; | |
| 3 | +import { getDb, apiKeys, apiUsage, eq, sql } from '@rareindex/database'; | |
| 4 | +import { parseBearer, resolveApiKey, TIERS, type ResolvedKey } from '../lib/keys.js'; | |
| 5 | +import { problem } from '../lib/envelope.js'; | |
| 6 | + | |
| 7 | +declare module 'fastify' { | |
| 8 | + interface FastifyRequest { | |
| 9 | + apiKey: ResolvedKey | null; | |
| 10 | + } | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Bearer API-key authentication. Anonymous requests are allowed on the "public" tier (low limits) | |
| 15 | + * so the docs playground and casual use work; keyed requests get their tier's limits and usage counters. | |
| 16 | + */ | |
| 17 | +export const authPlugin = fp(async (app: FastifyInstance) => { | |
| 18 | + app.decorateRequest('apiKey', null); | |
| 19 | + const cache = new Map<string, { key: ResolvedKey | null; at: number }>(); | |
| 20 | + | |
| 21 | + app.addHook('onRequest', async (req, reply) => { | |
| 22 | + const bearer = parseBearer(req.headers.authorization); | |
| 23 | + if (!bearer) { | |
| 24 | + req.apiKey = null; | |
| 25 | + return; | |
| 26 | + } | |
| 27 | + const hit = cache.get(bearer); | |
| 28 | + let resolved: ResolvedKey | null; | |
| 29 | + if (hit && Date.now() - hit.at < 60_000) resolved = hit.key; | |
| 30 | + else { | |
| 31 | + resolved = await resolveApiKey(bearer); | |
| 32 | + cache.set(bearer, { key: resolved, at: Date.now() }); | |
| 33 | + if (cache.size > 5000) cache.clear(); | |
| 34 | + } | |
| 35 | + if (!resolved) return problem(reply, 401, 'Invalid API key', 'The provided API key is unknown or revoked.'); | |
| 36 | + req.apiKey = resolved; | |
| 37 | + // daily quota | |
| 38 | + const today = new Date().toISOString().slice(0, 10); | |
| 39 | + const [row] = await getDb().execute(sql`select coalesce(sum(count),0)::int as used from api_usage where key_id = ${resolved.id} and date = ${today}`); | |
| 40 | + const used = Number((row as { used?: number } | undefined)?.used ?? 0); | |
| 41 | + if (used >= resolved.dailyQuota) return problem(reply, 429, 'Daily quota exceeded', `Tier ${resolved.tier} allows ${resolved.dailyQuota} requests per day.`, { quota: resolved.dailyQuota, used }); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + app.addHook('onResponse', async (req, reply) => { | |
| 45 | + if (!req.apiKey || !req.routeOptions.url?.startsWith('/v1')) return; | |
| 46 | + const today = new Date().toISOString().slice(0, 10); | |
| 47 | + const endpoint = req.routeOptions.url; | |
| 48 | + const latency = reply.elapsedTime; | |
| 49 | + try { | |
| 50 | + await getDb() | |
| 51 | + .insert(apiUsage) | |
| 52 | + .values({ keyId: req.apiKey.id, date: today, endpoint, count: 1, latencyMsAvg: latency }) | |
| 53 | + .onConflictDoUpdate({ | |
| 54 | + target: [apiUsage.keyId, apiUsage.date, apiUsage.endpoint], | |
| 55 | + set: { count: sql`${apiUsage.count} + 1`, latencyMsAvg: sql`(${apiUsage.latencyMsAvg} * ${apiUsage.count} + ${latency}) / (${apiUsage.count} + 1)` }, | |
| 56 | + }); | |
| 57 | + await getDb().update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, req.apiKey.id)); | |
| 58 | + } catch (err) { | |
| 59 | + req.log.warn({ err }, 'api usage counter failed'); | |
| 60 | + } | |
| 61 | + }); | |
| 62 | +}); | |
| 63 | + | |
| 64 | +export function rateLimitFor(req: FastifyRequest): number { | |
| 65 | + return req.apiKey?.rateLimitPerMinute ?? TIERS.public.rateLimitPerMinute; | |
| 66 | +} | |
| 67 | +export function rateKeyFor(req: FastifyRequest): string { | |
| 68 | + return req.apiKey ? `key:${req.apiKey.id}` : `ip:${req.ip}`; | |
| 69 | +} | |
added
apps/api/src/routes/internal.ts
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +import type { FastifyInstance } from 'fastify'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { createHmac } from 'node:crypto'; | |
| 4 | +import { connectorsForUrl, createRouter, createCrawlContext, listConnectorMeta } from '@rareindex/connectors'; | |
| 5 | +import type { NormalizedRecord } from '@rareindex/shared'; | |
| 6 | +import { problem } from '../lib/envelope.js'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Internal (loopback-only) endpoints used by the web app. Connector modules are loaded dynamically | |
| 10 | + * (`import(path)`), which the Next.js bundler cannot do — so URL lookups run here, where tsx can. | |
| 11 | + * Auth: `x-internal-token` = HMAC(SESSION_SECRET, "internal") — both processes share SESSION_SECRET. | |
| 12 | + */ | |
| 13 | +export function internalToken(secret = process.env.SESSION_SECRET ?? 'dev-only'): string { | |
| 14 | + return createHmac('sha256', secret).update('internal').digest('hex'); | |
| 15 | +} | |
| 16 | + | |
| 17 | +export async function internalRoutes(app: FastifyInstance) { | |
| 18 | + app.addHook('onRequest', async (req, reply) => { | |
| 19 | + if (!req.url.startsWith('/internal/')) return; | |
| 20 | + const loopback = req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1'; | |
| 21 | + const ok = loopback && req.headers['x-internal-token'] === internalToken(); | |
| 22 | + if (!ok) return problem(reply, 403, 'Forbidden', 'Internal endpoint'); | |
| 23 | + }); | |
| 24 | + | |
| 25 | + app.get('/internal/lookup/sources', async () => ({ data: listConnectorMeta({ enabled: true }).filter((m) => m.supportsLookup).map((m) => ({ id: m.id, sourceName: m.sourceName, sourceUrl: m.sourceUrl })) })); | |
| 26 | + | |
| 27 | + app.post('/internal/lookup', async (req, reply) => { | |
| 28 | + const body = z.object({ url: z.string().url().max(2000) }).safeParse(req.body); | |
| 29 | + if (!body.success) return problem(reply, 400, 'Invalid body', 'url required'); | |
| 30 | + const url = body.data.url; | |
| 31 | + const connectors = await connectorsForUrl(url); | |
| 32 | + const connector = connectors[0]; | |
| 33 | + if (!connector?.lookup) return { data: null, meta: { supported: false } }; | |
| 34 | + const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY }); | |
| 35 | + const ctx = createCrawlContext({ router, meta: connector.meta, options: { mode: 'probe', limit: 1 } }); | |
| 36 | + const started = Date.now(); | |
| 37 | + const raws = await connector.lookup(url, ctx); | |
| 38 | + const records: NormalizedRecord[] = []; | |
| 39 | + for (const raw of raws) records.push(...(await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }))); | |
| 40 | + return { data: { connectorId: connector.meta.id, sourceId: connector.meta.sourceId, records }, meta: { supported: true, durationMs: Date.now() - started, engineStats: ctx.engineStats } }; | |
| 41 | + }); | |
| 42 | +} | |
added
apps/api/src/routes/v1.ts
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +import type { FastifyInstance } from 'fastify'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { decodeCursor, encodeCursor, envelope, problem, sendList } from '../lib/envelope.js'; | |
| 4 | +import * as q from '../lib/queries.js'; | |
| 5 | +import { openapiDocument } from '../openapi.js'; | |
| 6 | + | |
| 7 | +const Paging = z.object({ limit: z.coerce.number().int().min(1).max(200).default(50), cursor: z.string().optional(), format: z.enum(['json', 'csv']).optional() }); | |
| 8 | +const Range = z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), format: z.enum(['json', 'csv']).optional() }); | |
| 9 | + | |
| 10 | +function parse<T>(schema: z.ZodType<T>, input: unknown): { ok: true; data: T } | { ok: false; error: string } { | |
| 11 | + const r = schema.safeParse(input); | |
| 12 | + return r.success ? { ok: true, data: r.data } : { ok: false, error: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') }; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export async function v1Routes(app: FastifyInstance) { | |
| 16 | + app.get('/v1/openapi.json', async () => openapiDocument()); | |
| 17 | + | |
| 18 | + app.get('/v1/stats', async () => envelope(await q.platformStats())); | |
| 19 | + | |
| 20 | + app.get('/v1/assets/search', async (req, reply) => { | |
| 21 | + const p = parse(Paging.extend({ q: z.string().max(200).optional(), category: z.string().max(80).optional() }), req.query); | |
| 22 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | |
| 23 | + const offset = decodeCursor(p.data.cursor); | |
| 24 | + const rows = await q.searchAssets({ q: p.data.q, category: p.data.category, limit: p.data.limit + 1, offset }); | |
| 25 | + const page = rows.slice(0, p.data.limit); | |
| 26 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, query: p.data.q ?? null }, req.query as Record<string, unknown>); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + app.get<{ Params: { id: string } }>('/v1/assets/:id', async (req, reply) => { | |
| 30 | + const asset = await q.getAsset(req.params.id); | |
| 31 | + if (!asset) return problem(reply, 404, 'Asset not found'); | |
| 32 | + reply.header('cache-control', 'public, max-age=60, s-maxage=300'); | |
| 33 | + return envelope(asset); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + app.get<{ Params: { id: string } }>('/v1/assets/:id/sales', async (req, reply) => { | |
| 37 | + const p = parse(Paging.extend({ variant: z.string().optional(), include_flagged: z.coerce.boolean().optional() }), req.query); | |
| 38 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | |
| 39 | + const asset = await q.getAsset(req.params.id); | |
| 40 | + if (!asset) return problem(reply, 404, 'Asset not found'); | |
| 41 | + const offset = decodeCursor(p.data.cursor); | |
| 42 | + const rows = await q.assetSales(asset.id as string, { limit: p.data.limit + 1, offset, variantId: p.data.variant, includeFlagged: p.data.include_flagged }); | |
| 43 | + const page = rows.slice(0, p.data.limit); | |
| 44 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id }, req.query as Record<string, unknown>); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + app.get<{ Params: { id: string } }>('/v1/assets/:id/listings', async (req, reply) => { | |
| 48 | + const p = parse(Paging.extend({ availability: z.enum(['available', 'sold', 'ended', 'removed']).optional() }), req.query); | |
| 49 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | |
| 50 | + const asset = await q.getAsset(req.params.id); | |
| 51 | + if (!asset) return problem(reply, 404, 'Asset not found'); | |
| 52 | + const offset = decodeCursor(p.data.cursor); | |
| 53 | + const rows = await q.assetListings(asset.id as string, { limit: p.data.limit + 1, offset, availability: p.data.availability }); | |
| 54 | + const page = rows.slice(0, p.data.limit); | |
| 55 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id, note: 'Listing prices are asks, not confirmed transactions.' }, req.query as Record<string, unknown>); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + app.get<{ Params: { id: string } }>('/v1/assets/:id/history', async (req, reply) => { | |
| 59 | + const p = parse(Range.extend({ variant: z.string().optional() }), req.query); | |
| 60 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | |
| 61 | + const asset = await q.getAsset(req.params.id); | |
| 62 | + if (!asset) return problem(reply, 404, 'Asset not found'); | |
| 63 | + const rows = await q.assetHistory(asset.id as string, { variantId: p.data.variant, from: p.data.from, to: p.data.to }); | |
| 64 | + reply.header('cache-control', 'public, max-age=300'); | |
| 65 | + return sendList(reply, rows, { count: rows.length, asset_id: asset.id, variant_id: p.data.variant ?? null }, req.query as Record<string, unknown>); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + app.get('/v1/categories', async (req, reply) => { | |
| 69 | + const rows = await q.listCategories(); | |
| 70 | + reply.header('cache-control', 'public, max-age=3600'); | |
| 71 | + return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + app.get('/v1/indices', async (req, reply) => { | |
| 75 | + const rows = await q.listIndices(); | |
| 76 | + const shaped = rows.map((r) => { | |
| 77 | + const v = r.value as number | null; | |
| 78 | + const pct = (prev: unknown) => (v !== null && typeof prev === 'number' && prev > 0 ? (v - prev) / prev : null); | |
| 79 | + return { ...r, change_1d: pct(r.value_1d), change_7d: pct(r.value_7d), change_30d: pct(r.value_30d), change_1y: pct(r.value_1y), change_ytd: pct(r.value_ytd), published: v !== null }; | |
| 80 | + }); | |
| 81 | + reply.header('cache-control', 'public, max-age=300'); | |
| 82 | + return sendList(reply, shaped, { count: shaped.length, note: 'An index is published only once it has enough priced constituents; unpublished indices report value=null.' }, req.query as Record<string, unknown>); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + app.get<{ Params: { ticker: string } }>('/v1/indices/:ticker/history', async (req, reply) => { | |
| 86 | + const p = parse(Range, req.query); | |
| 87 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | |
| 88 | + const rows = await q.indexHistory(req.params.ticker.toUpperCase(), p.data); | |
| 89 | + reply.header('cache-control', 'public, max-age=300'); | |
| 90 | + return sendList(reply, rows, { count: rows.length, ticker: req.params.ticker.toUpperCase() }, req.query as Record<string, unknown>); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + app.get('/v1/markets', async (req, reply) => { | |
| 94 | + const rows = await q.listMarkets(); | |
| 95 | + reply.header('cache-control', 'public, max-age=300'); | |
| 96 | + return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + app.get<{ Params: { slug: string } }>('/v1/markets/:slug', async (req, reply) => { | |
| 100 | + const m = await q.getMarket(req.params.slug); | |
| 101 | + if (!m) return problem(reply, 404, 'Market not found'); | |
| 102 | + reply.header('cache-control', 'public, max-age=300'); | |
| 103 | + return envelope(m); | |
| 104 | + }); | |
| 105 | + | |
| 106 | + app.get('/v1/trending', async (req, reply) => { | |
| 107 | + const p = parse(Paging.extend({ category: z.string().optional() }), req.query); | |
| 108 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | |
| 109 | + const offset = decodeCursor(p.data.cursor); | |
| 110 | + const rows = await q.trending({ category: p.data.category, limit: p.data.limit + 1, offset }); | |
| 111 | + const page = rows.slice(0, p.data.limit); | |
| 112 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record<string, unknown>); | |
| 113 | + }); | |
| 114 | + | |
| 115 | + app.get('/v1/sales/latest', async (req, reply) => { | |
| 116 | + const p = parse(Paging.extend({ category: z.string().optional(), min_usd: z.coerce.number().nonnegative().optional() }), req.query); | |
| 117 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | |
| 118 | + const offset = decodeCursor(p.data.cursor); | |
| 119 | + const rows = await q.latestSales({ category: p.data.category, minUsd: p.data.min_usd, limit: p.data.limit + 1, offset }); | |
| 120 | + const page = rows.slice(0, p.data.limit); | |
| 121 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record<string, unknown>); | |
| 122 | + }); | |
| 123 | + | |
| 124 | + app.get('/v1/records', async (req, reply) => { | |
| 125 | + const rows = await q.recordSales(); | |
| 126 | + reply.header('cache-control', 'public, max-age=600'); | |
| 127 | + return sendList(reply, rows, { count: rows.length, note: 'Highest verified transaction per family; bundles and low-confidence records excluded.' }, req.query as Record<string, unknown>); | |
| 128 | + }); | |
| 129 | +} | |
added
apps/api/src/server.ts
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +import { buildApp } from './app.js'; | |
| 2 | + | |
| 3 | +const port = Number(process.env.API_PORT ?? 8211); | |
| 4 | +const host = process.env.API_HOST ?? '127.0.0.1'; | |
| 5 | + | |
| 6 | +const app = await buildApp(); | |
| 7 | +try { | |
| 8 | + await app.listen({ port, host }); | |
| 9 | + app.log.info({ port, host }, 'rareindex-api listening'); | |
| 10 | +} catch (err) { | |
| 11 | + app.log.error(err); | |
| 12 | + process.exit(1); | |
| 13 | +} | |
| 14 | + | |
| 15 | +for (const sig of ['SIGINT', 'SIGTERM'] as const) { | |
| 16 | + process.on(sig, async () => { | |
| 17 | + await app.close(); | |
| 18 | + process.exit(0); | |
| 19 | + }); | |
| 20 | +} | |
added
apps/api/tsconfig.json
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../../tsconfig.base.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "rootDir": ".", | |
| 5 | + "noEmit": true | |
| 6 | + }, | |
| 7 | + "include": ["src", "scripts"] | |
| 8 | +} | |
added
apps/api/vitest.config.ts
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { defineConfig } from 'vitest/config'; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + test: { include: ['src/**/*.test.ts'], testTimeout: 30_000 }, | |
| 5 | +}); | |
modified
apps/web/next.config.ts
+7 −2
@@ -4,8 +4,13 @@ const nextConfig: NextConfig = { | ||
| 4 | 4 | reactStrictMode: true, |
| 5 | 5 | poweredByHeader: false, |
| 6 | 6 | // Workspace packages are consumed as TypeScript sources. |
| 7 | − transpilePackages: ['@rareindex/shared', '@rareindex/database', '@rareindex/taxonomy', '@rareindex/connectors'], | |
| 8 | − serverExternalPackages: ['postgres', 'pino', 'cheerio'], | |
| 7 | + transpilePackages: ['@rareindex/shared', '@rareindex/database', '@rareindex/taxonomy', '@rareindex/connectors', '@rareindex/ai'], | |
| 8 | + serverExternalPackages: ['postgres', 'pino', 'cheerio', 'pg-boss', '@anthropic-ai/sdk', 'openai'], | |
| 9 | + // Public API: /api/v1/* is proxied to the Fastify service (apps/api) so one public host serves web + API. | |
| 10 | + async rewrites() { | |
| 11 | + const api = process.env.RI_API_URL ?? `http://127.0.0.1:${process.env.API_PORT ?? 8211}`; | |
| 12 | + return [{ source: '/api/v1/:path*', destination: `${api}/v1/:path*` }]; | |
| 13 | + }, | |
| 9 | 14 | images: { |
| 10 | 15 | remotePatterns: [{ protocol: 'https', hostname: '**' }], |
| 11 | 16 | formats: ['image/avif', 'image/webp'], |
modified
apps/web/package.json
+2 −0
@@ -11,6 +11,7 @@ | ||
| 11 | 11 | "test": "vitest run --passWithNoTests" |
| 12 | 12 | }, |
| 13 | 13 | "dependencies": { |
| 14 | + "@rareindex/ai": "workspace:*", | |
| 14 | 15 | "@rareindex/connectors": "workspace:*", |
| 15 | 16 | "@rareindex/database": "workspace:*", |
| 16 | 17 | "@rareindex/shared": "workspace:*", |
@@ -18,6 +19,7 @@ | ||
| 18 | 19 | "@tanstack/react-query": "^5.90.0", |
| 19 | 20 | "lucide-react": "^1.0.0", |
| 20 | 21 | "next": "16.3.4", |
| 22 | + "pg-boss": "^12.30.0", | |
| 21 | 23 | "react": "19.2.8", |
| 22 | 24 | "react-dom": "19.2.8", |
| 23 | 25 | "server-only": "^0.0.1", |
added
apps/web/src/app/admin/audit/page.tsx
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { requireAdmin } from '@/lib/admin/auth'; | |
| 3 | +import { auditLog } from '@/lib/admin/queries'; | |
| 4 | +import { AdminShell, fmtTs } from '@/components/admin/shell'; | |
| 5 | +import { Table, th, td } from '@/components/ui/primitives'; | |
| 6 | + | |
| 7 | +const TYPES = ['', 'sale', 'listing', 'asset', 'connector', 'normalized_record', 'taxonomy_proposal']; | |
| 8 | + | |
| 9 | +export default async function AuditPage({ searchParams }: { searchParams: Promise<{ type?: string }> }) { | |
| 10 | + await requireAdmin(); | |
| 11 | + const sp = await searchParams; | |
| 12 | + const rows = await auditLog({ entityType: sp.type || undefined }); | |
| 13 | + return ( | |
| 14 | + <AdminShell current="/admin/audit" title="Audit log" subtitle="Every flag, exclusion, restore, merge, edit and admin decision — the platform never silently deletes data" actions={<div className="flex flex-wrap gap-1 text-xs">{TYPES.map((t) => <Link key={t} href={`/admin/audit${t ? `?type=${t}` : ''}`} className={`rounded-md border px-2 py-1 ${(sp.type ?? '') === t ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset'}`}>{t || 'all'}</Link>)}</div>}> | |
| 15 | + <div className="card"> | |
| 16 | + <Table> | |
| 17 | + <thead><tr><th className={th}>When</th><th className={th}>Entity</th><th className={th}>Action</th><th className={th}>Actor</th><th className={th}>Reason</th><th className={th}>Details</th></tr></thead> | |
| 18 | + <tbody> | |
| 19 | + {rows.length === 0 ? <tr><td className={td} colSpan={6}><span className="text-subtle">No audit entries.</span></td></tr> : null} | |
| 20 | + {rows.map((r) => ( | |
| 21 | + <tr key={String(r.id)} className="align-top"> | |
| 22 | + <td className={td}>{fmtTs(r.created_at)}</td> | |
| 23 | + <td className={`${td} font-mono text-[11px]`}>{String(r.entity_type)}<br />{String(r.entity_id)}</td> | |
| 24 | + <td className={td}>{String(r.action)}</td> | |
| 25 | + <td className={td}>{String(r.actor)}</td> | |
| 26 | + <td className={`${td} max-w-[320px] whitespace-normal`}>{String(r.reason)}</td> | |
| 27 | + <td className={`${td} max-w-[320px] whitespace-normal font-mono text-[10px] text-muted`}>{JSON.stringify(r.details)}</td> | |
| 28 | + </tr> | |
| 29 | + ))} | |
| 30 | + </tbody> | |
| 31 | + </Table> | |
| 32 | + </div> | |
| 33 | + </AdminShell> | |
| 34 | + ); | |
| 35 | +} | |
added
apps/web/src/app/admin/connectors/[id]/page.tsx
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { notFound } from 'next/navigation'; | |
| 3 | +import { requireAdmin } from '@/lib/admin/auth'; | |
| 4 | +import { connectorDetail } from '@/lib/admin/queries'; | |
| 5 | +import { connectorAction, updateConnectorConfig } from '@/lib/admin/actions'; | |
| 6 | +import { AdminShell, ActionButton, StatusPill, Kpi, fmtTs, n } from '@/components/admin/shell'; | |
| 7 | +import { Table, th, td, tdNum } from '@/components/ui/primitives'; | |
| 8 | + | |
| 9 | +export default async function ConnectorInspectPage({ params }: { params: Promise<{ id: string }> }) { | |
| 10 | + await requireAdmin(); | |
| 11 | + const { id } = await params; | |
| 12 | + const d = await connectorDetail(id); | |
| 13 | + if (!d) notFound(); | |
| 14 | + const c = d.connector; | |
| 15 | + const health = (d.health?.health ?? {}) as Record<string, unknown>; | |
| 16 | + const outputs = (d.outputs ?? {}) as Record<string, unknown>; | |
| 17 | + return ( | |
| 18 | + <AdminShell | |
| 19 | + current="/admin/connectors" | |
| 20 | + title={String(c.display_name)} | |
| 21 | + subtitle={<>{String(c.id)} · source <span className="font-mono">{String(c.source_id)}</span> · {(c.engine_priority as string[]).join(' → ')} · categories {(c.categories as string[]).join(', ')} · <Link href="/admin/connectors" className="underline">back</Link></>} | |
| 22 | + actions={ | |
| 23 | + <div className="flex flex-wrap gap-1"> | |
| 24 | + {(['run', 'probe', 'recrawl', 'retry', c.status === 'paused' ? 'resume' : 'pause'] as const).map((a) => ( | |
| 25 | + <form key={a} action={connectorAction}> | |
| 26 | + <input type="hidden" name="id" value={String(c.id)} /> | |
| 27 | + <input type="hidden" name="action" value={a} /> | |
| 28 | + <ActionButton label={a} small={false} tone={a === 'pause' ? 'danger' : a === 'resume' ? 'primary' : 'neutral'} /> | |
| 29 | + </form> | |
| 30 | + ))} | |
| 31 | + </div> | |
| 32 | + } | |
| 33 | + > | |
| 34 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6"> | |
| 35 | + <Kpi label="Status" value={<StatusPill status={String(c.status)} />} sub={<>health <StatusPill status={(d.health?.status as string) ?? 'unknown'} /></>} /> | |
| 36 | + <Kpi label="Success 24h" value={health.success_rate_24h != null ? `${Math.round(Number(health.success_rate_24h) * 100)}%` : '—'} sub={`${n(health.pages_success)}/${n(health.pages_attempted)} pages`} /> | |
| 37 | + <Kpi label="Firecrawl / Scrapfly" value={`${health.firecrawl_success_rate != null ? Math.round(Number(health.firecrawl_success_rate) * 100) + '%' : '—'} / ${health.scrapfly_fallback_rate != null ? Math.round(Number(health.scrapfly_fallback_rate) * 100) + '%' : '—'}`} sub="success / fallback share" /> | |
| 38 | + <Kpi label="Parse failures" value={health.parse_failure_rate != null ? `${(Number(health.parse_failure_rate) * 100).toFixed(1)}%` : '—'} sub={`${n(health.duplicates_24h)} duplicates 24h`} /> | |
| 39 | + <Kpi label="Outputs" value={`${n(outputs.sales)} sales`} sub={`${n(outputs.listings)} listings · ${n(outputs.observations)} observations`} /> | |
| 40 | + <Kpi label="Last success" value={fmtTs(c.last_success_at).slice(0, 16)} sub={`next ${fmtTs(c.next_run_at).slice(0, 16)}`} /> | |
| 41 | + </div> | |
| 42 | + | |
| 43 | + {d.anomalies.length ? ( | |
| 44 | + <section className="card mt-6"> | |
| 45 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold text-alert">Anomalies (recent runs)</h2></header> | |
| 46 | + <ul className="divide-y divide-border px-4 text-xs">{d.anomalies.map((a, i) => <li key={i} className="py-1.5 font-mono">{a}</li>)}</ul> | |
| 47 | + </section> | |
| 48 | + ) : null} | |
| 49 | + | |
| 50 | + <section className="card mt-6"> | |
| 51 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Runs</h2></header> | |
| 52 | + <Table> | |
| 53 | + <thead><tr><th className={th}>Started</th><th className={th}>Status</th><th className={th}>Trigger</th><th className={`${th} text-right`}>Pages</th><th className={`${th} text-right`}>Raw</th><th className={`${th} text-right`}>Norm.</th><th className={`${th} text-right`}>Dup.</th><th className={`${th} text-right`}>Rej.</th><th className={`${th} text-right`}>Credits</th><th className={th}>Engines</th><th className={th}>Error</th></tr></thead> | |
| 54 | + <tbody> | |
| 55 | + {d.runs.length === 0 ? <tr><td className={td} colSpan={11}><span className="text-subtle">No runs yet.</span></td></tr> : null} | |
| 56 | + {d.runs.map((r) => ( | |
| 57 | + <tr key={String(r.id)}> | |
| 58 | + <td className={td}>{fmtTs(r.started_at)}<div className="text-[10px] text-subtle">{r.finished_at ? `${Math.round((new Date(String(r.finished_at)).getTime() - new Date(String(r.started_at)).getTime()) / 1000)} s` : 'running'}</div></td> | |
| 59 | + <td className={td}><StatusPill status={String(r.status)} /></td> | |
| 60 | + <td className={td}>{String(r.trigger)}</td> | |
| 61 | + <td className={tdNum}>{n(r.pages_success)}/{n(r.pages_attempted)}</td> | |
| 62 | + <td className={tdNum}>{n(r.records_raw)}</td> | |
| 63 | + <td className={tdNum}>{n(r.records_normalized)}</td> | |
| 64 | + <td className={tdNum}>{n(r.records_duplicate)}</td> | |
| 65 | + <td className={tdNum}>{n(r.records_rejected)}</td> | |
| 66 | + <td className={tdNum}>{n(r.cost_credits, 1)}</td> | |
| 67 | + <td className={`${td} font-mono text-[10px]`}>{Object.entries((r.engine_stats ?? {}) as Record<string, { attempts: number; success: number }>).map(([k, v]) => `${k} ${v.success}/${v.attempts}`).join(' · ')}</td> | |
| 68 | + <td className={`${td} max-w-[260px] truncate text-loss`} title={String(r.error ?? '')}>{r.error ? String(r.error) : ''}</td> | |
| 69 | + </tr> | |
| 70 | + ))} | |
| 71 | + </tbody> | |
| 72 | + </Table> | |
| 73 | + </section> | |
| 74 | + | |
| 75 | + <div className="mt-6 grid gap-6 lg:grid-cols-2"> | |
| 76 | + <section className="card"> | |
| 77 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Raw records (latest)</h2></header> | |
| 78 | + <ul className="divide-y divide-border text-xs"> | |
| 79 | + {d.rawSample.length === 0 ? <li className="px-4 py-3 text-subtle">None yet.</li> : null} | |
| 80 | + {d.rawSample.map((r) => ( | |
| 81 | + <li key={String(r.id)} className="px-4 py-2"> | |
| 82 | + <div className="flex items-center justify-between gap-2"><span className="font-mono text-[11px]">{String(r.kind)} · {String(r.engine)} · {String(r.http_status ?? '')}</span><span className="text-subtle">{fmtTs(r.fetched_at)}</span></div> | |
| 83 | + <a href={String(r.url)} target="_blank" rel="noreferrer noopener" className="block truncate text-muted hover:text-fg">{String(r.url)}</a> | |
| 84 | + {r.process_error ? <p className="text-loss">{String(r.process_error)}</p> : <p className="text-[10px] text-subtle">{r.processed_at ? `processed ${fmtTs(r.processed_at)}` : 'unprocessed'}</p>} | |
| 85 | + </li> | |
| 86 | + ))} | |
| 87 | + </ul> | |
| 88 | + </section> | |
| 89 | + <section className="card"> | |
| 90 | + <header className="flex items-center justify-between border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Normalized records (latest)</h2><span className="text-[11px] text-muted">{d.normalized.map((x) => `${x.status} ${n(x.n)}`).join(' · ')}</span></header> | |
| 91 | + <ul className="divide-y divide-border text-xs"> | |
| 92 | + {d.normalizedSample.length === 0 ? <li className="px-4 py-3 text-subtle">None yet.</li> : null} | |
| 93 | + {d.normalizedSample.map((r) => ( | |
| 94 | + <li key={String(r.id)} className="px-4 py-2"> | |
| 95 | + <div className="flex items-center justify-between gap-2"><span className="truncate">{String(r.raw_title ?? '')}</span><StatusPill status={String(r.status)} /></div> | |
| 96 | + <p className="text-[10px] text-subtle">{String(r.kind)} · {r.price ? `${String(r.price)} ${String(r.currency ?? '')}` : ''} · {r.match_method ? `${String(r.match_method)} ${r.match_confidence ? Math.round(Number(r.match_confidence) * 100) + '%' : ''}` : ''} {r.reject_reason ? `· ${String(r.reject_reason)}` : ''}</p> | |
| 97 | + </li> | |
| 98 | + ))} | |
| 99 | + </ul> | |
| 100 | + </section> | |
| 101 | + </div> | |
| 102 | + | |
| 103 | + <div className="mt-6 grid gap-6 lg:grid-cols-2"> | |
| 104 | + <section className="card"> | |
| 105 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Costs (30d)</h2></header> | |
| 106 | + <Table> | |
| 107 | + <thead><tr><th className={th}>Day</th><th className={th}>Kind</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD est.</th></tr></thead> | |
| 108 | + <tbody> | |
| 109 | + {d.costs.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">No cost events.</span></td></tr> : null} | |
| 110 | + {d.costs.map((r, i) => <tr key={i}><td className={td}>{String(r.day)}</td><td className={td}>{String(r.kind)}</td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td></tr>)} | |
| 111 | + </tbody> | |
| 112 | + </Table> | |
| 113 | + </section> | |
| 114 | + <section className="card"> | |
| 115 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Mapping & schedule</h2></header> | |
| 116 | + <form action={updateConnectorConfig} className="space-y-3 p-4 text-xs"> | |
| 117 | + <input type="hidden" name="id" value={String(c.id)} /> | |
| 118 | + <div className="grid grid-cols-2 gap-3"> | |
| 119 | + <label className="block text-muted">Refresh (minutes)<input name="refresh" type="number" min={5} defaultValue={Number(c.refresh_frequency_minutes)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm" /></label> | |
| 120 | + <label className="block text-muted">Priority | |
| 121 | + <select name="priority" defaultValue={String(c.priority)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm"><option value="high">high</option><option value="medium">medium</option><option value="low">low</option></select> | |
| 122 | + </label> | |
| 123 | + </div> | |
| 124 | + <label className="block text-muted">Config JSON (seeds, mappings, selectors — connector-specific) | |
| 125 | + <textarea name="config" rows={10} defaultValue={JSON.stringify(c.config ?? {}, null, 2)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 font-mono text-[11px]" /> | |
| 126 | + </label> | |
| 127 | + <div className="flex items-center justify-between"><span className="text-subtle">schema v{String(c.schema_version)} · connector v{String(c.connector_version)} · source trust {d.source ? Math.round(Number(d.source.trust_score) * 100) + '%' : '—'}</span><ActionButton label="Save mapping" small={false} tone="primary" /></div> | |
| 128 | + </form> | |
| 129 | + </section> | |
| 130 | + </div> | |
| 131 | + </AdminShell> | |
| 132 | + ); | |
| 133 | +} | |
added
apps/web/src/app/admin/connectors/page.tsx
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { requireAdmin } from '@/lib/admin/auth'; | |
| 3 | +import { connectorsOverview } from '@/lib/admin/queries'; | |
| 4 | +import { connectorAction } from '@/lib/admin/actions'; | |
| 5 | +import { AdminShell, ActionButton, StatusPill, fmtTs, n } from '@/components/admin/shell'; | |
| 6 | +import { Table, th, td, tdNum } from '@/components/ui/primitives'; | |
| 7 | + | |
| 8 | +function pct(v: unknown): string { | |
| 9 | + const x = Number(v); | |
| 10 | + return Number.isFinite(x) ? `${Math.round(x * 100)}%` : '—'; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export default async function ConnectorsPage() { | |
| 14 | + await requireAdmin(); | |
| 15 | + const rows = await connectorsOverview(); | |
| 16 | + return ( | |
| 17 | + <AdminShell current="/admin/connectors" title="Connector control center" subtitle={`${rows.length} connectors registered · actions enqueue jobs on the crawl.run queue consumed by the worker`}> | |
| 18 | + <div className="card"> | |
| 19 | + <Table> | |
| 20 | + <thead> | |
| 21 | + <tr> | |
| 22 | + <th className={th}>Connector</th> | |
| 23 | + <th className={th}>Health</th> | |
| 24 | + <th className={th}>Status</th> | |
| 25 | + <th className={`${th} text-right`}>Pages/min</th> | |
| 26 | + <th className={`${th} text-right`}>Errors</th> | |
| 27 | + <th className={th}>Last crawl</th> | |
| 28 | + <th className={`${th} text-right`}>Records 24h</th> | |
| 29 | + <th className={`${th} text-right`}>Dup.</th> | |
| 30 | + <th className={`${th} text-right`}>FC rate</th> | |
| 31 | + <th className={`${th} text-right`}>SF rate</th> | |
| 32 | + <th className={`${th} text-right`}>Parser conf.</th> | |
| 33 | + <th className={th}>Schema</th> | |
| 34 | + <th className={th}>Actions</th> | |
| 35 | + </tr> | |
| 36 | + </thead> | |
| 37 | + <tbody> | |
| 38 | + {rows.length === 0 ? <tr><td className={td} colSpan={13}><span className="text-subtle">No connectors in the registry yet. Add meta.json files under connectors/ and run pnpm db:seed.</span></td></tr> : null} | |
| 39 | + {rows.map((r) => { | |
| 40 | + const health = (r.health ?? {}) as Record<string, unknown>; | |
| 41 | + const anomalies = Array.isArray(r.last_anomalies) ? (r.last_anomalies as string[]) : []; | |
| 42 | + const drift = anomalies.some((a) => /schema|selector|missing_field|redesign/.test(a)); | |
| 43 | + return ( | |
| 44 | + <tr key={String(r.id)} className="align-top"> | |
| 45 | + <td className={td}> | |
| 46 | + <Link href={`/admin/connectors/${String(r.id)}`} className="font-medium hover:underline">{String(r.display_name)}</Link> | |
| 47 | + <div className="text-[11px] text-subtle">{String(r.id)} · {(r.engine_priority as string[]).join('→')} · {String(r.priority)} · every {n(r.refresh_frequency_minutes)} min</div> | |
| 48 | + </td> | |
| 49 | + <td className={td}><StatusPill status={(r.health_status as string) ?? 'unknown'} />{r.health_at ? <div className="text-[10px] text-subtle">{fmtTs(r.health_at)}</div> : null}</td> | |
| 50 | + <td className={td}><StatusPill status={String(r.status)} />{r.last_run_status ? <div className="text-[10px] text-subtle">run: {String(r.last_run_status)}</div> : null}</td> | |
| 51 | + <td className={tdNum}>{n(r.pages_per_min, 1)}</td> | |
| 52 | + <td className={`${tdNum} ${r.last_error ? 'text-loss' : ''}`} title={String(r.last_error ?? '')}>{r.last_error ? '1' : '0'}{anomalies.length ? <div className="text-[10px] text-alert">{anomalies.length} anomal.</div> : null}</td> | |
| 53 | + <td className={td}>{fmtTs(r.last_started_at)}<div className="text-[10px] text-subtle">ok: {fmtTs(r.last_success_at)}</div></td> | |
| 54 | + <td className={tdNum}>{n(r.records_24h)}<div className="text-[10px] text-subtle">{n(r.normalized_24h)} norm.</div></td> | |
| 55 | + <td className={tdNum}>{n(r.duplicates_24h)}</td> | |
| 56 | + <td className={tdNum}>{pct(health.firecrawl_success_rate)}</td> | |
| 57 | + <td className={tdNum}>{pct(health.scrapfly_fallback_rate)}</td> | |
| 58 | + <td className={tdNum}>{pct(r.parser_confidence)}</td> | |
| 59 | + <td className={td}>v{String(r.schema_version)}{drift ? <div className="text-[10px] text-alert">drift?</div> : null}</td> | |
| 60 | + <td className={td}> | |
| 61 | + <div className="flex flex-wrap gap-1"> | |
| 62 | + {(['run', 'probe', 'recrawl', 'retry'] as const).map((a) => ( | |
| 63 | + <form key={a} action={connectorAction}> | |
| 64 | + <input type="hidden" name="id" value={String(r.id)} /> | |
| 65 | + <input type="hidden" name="action" value={a} /> | |
| 66 | + <ActionButton label={a} /> | |
| 67 | + </form> | |
| 68 | + ))} | |
| 69 | + <form action={connectorAction}> | |
| 70 | + <input type="hidden" name="id" value={String(r.id)} /> | |
| 71 | + <input type="hidden" name="action" value={r.status === 'paused' ? 'resume' : 'pause'} /> | |
| 72 | + <ActionButton label={r.status === 'paused' ? 'resume' : 'pause'} tone={r.status === 'paused' ? 'primary' : 'danger'} /> | |
| 73 | + </form> | |
| 74 | + <Link href={`/admin/connectors/${String(r.id)}`} className="rounded-md border border-border px-2 py-0.5 text-[11px] font-medium hover:bg-inset">inspect</Link> | |
| 75 | + </div> | |
| 76 | + </td> | |
| 77 | + </tr> | |
| 78 | + ); | |
| 79 | + })} | |
| 80 | + </tbody> | |
| 81 | + </Table> | |
| 82 | + </div> | |
| 83 | + <p className="mt-3 text-[11px] text-subtle">run = incremental crawl · probe = 25-record smoke test · recrawl = backfill mode · retry = re-run from the last cursor. FC = Firecrawl success rate, SF = share of pages that needed Scrapfly (from the 24 h health snapshot).</p> | |
| 84 | + </AdminShell> | |
| 85 | + ); | |
| 86 | +} | |
added
apps/web/src/app/admin/costs/page.tsx
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { requireAdmin } from '@/lib/admin/auth'; | |
| 3 | +import { costsOverview } from '@/lib/admin/queries'; | |
| 4 | +import { AdminShell, Bars, Kpi, n } from '@/components/admin/shell'; | |
| 5 | +import { Table, th, td, tdNum } from '@/components/ui/primitives'; | |
| 6 | + | |
| 7 | +export default async function CostsPage({ searchParams }: { searchParams: Promise<{ days?: string }> }) { | |
| 8 | + await requireAdmin(); | |
| 9 | + const sp = await searchParams; | |
| 10 | + const days = [7, 30, 90].includes(Number(sp.days)) ? Number(sp.days) : 30; | |
| 11 | + const c = await costsOverview(days); | |
| 12 | + const t = (c.totals ?? {}) as Record<string, unknown>; | |
| 13 | + const raw = Number((c.perRecord as Record<string, unknown> | null)?.raw_records ?? 0); | |
| 14 | + const dayMap = new Map<string, { usd: number; credits: number }>(); | |
| 15 | + for (const r of c.byDay) { | |
| 16 | + const k = String(r.day); | |
| 17 | + const cur = dayMap.get(k) ?? { usd: 0, credits: 0 }; | |
| 18 | + cur.usd += Number(r.usd); | |
| 19 | + cur.credits += Number(r.credits); | |
| 20 | + dayMap.set(k, cur); | |
| 21 | + } | |
| 22 | + const series = [...dayMap.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([label, v]) => ({ label, value: v.usd })); | |
| 23 | + const creditSeries = [...dayMap.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([label, v]) => ({ label, value: v.credits })); | |
| 24 | + return ( | |
| 25 | + <AdminShell | |
| 26 | + current="/admin/costs" | |
| 27 | + title="Costs" | |
| 28 | + subtitle="Crawler credits and AI spend by day, kind, provider, connector, category and endpoint (estimates from list prices)" | |
| 29 | + actions={<div className="flex gap-1 text-xs">{[7, 30, 90].map((d) => <Link key={d} href={`/admin/costs?days=${d}`} className={`rounded-md border px-2 py-1 ${d === days ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset'}`}>{d}d</Link>)}</div>} | |
| 30 | + > | |
| 31 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-5"> | |
| 32 | + <Kpi label={`Spend ${days}d`} value={`$${n(t.usd, 2)}`} sub={`${n(t.events)} cost events`} /> | |
| 33 | + <Kpi label="AI" value={`$${n(t.ai_usd, 2)}`} /> | |
| 34 | + <Kpi label="Firecrawl credits" value={n(t.firecrawl_credits)} /> | |
| 35 | + <Kpi label="Scrapfly credits" value={n(t.scrapfly_credits)} /> | |
| 36 | + <Kpi label="Per raw record" value={raw ? `$${(Number(t.usd ?? 0) / raw).toFixed(4)}` : '—'} sub={`${n(raw)} raw records`} /> | |
| 37 | + </div> | |
| 38 | + <div className="mt-6 grid gap-6 lg:grid-cols-2"> | |
| 39 | + <section className="card p-4"><h2 className="mb-2 text-sm font-semibold">USD per day</h2><Bars points={series} format={(v) => `$${v.toFixed(3)}`} /></section> | |
| 40 | + <section className="card p-4"><h2 className="mb-2 text-sm font-semibold">Crawler credits per day</h2><Bars points={creditSeries} format={(v) => `${Math.round(v)} credits`} /></section> | |
| 41 | + </div> | |
| 42 | + <div className="mt-6 grid gap-6 lg:grid-cols-2"> | |
| 43 | + <section className="card"><header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">By kind / provider</h2></header> | |
| 44 | + <Table><thead><tr><th className={th}>Kind</th><th className={th}>Provider</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD</th><th className={`${th} text-right`}>Events</th></tr></thead> | |
| 45 | + <tbody>{c.byKind.length === 0 ? <tr><td className={td} colSpan={5}><span className="text-subtle">No cost events yet.</span></td></tr> : c.byKind.map((r, i) => <tr key={i}><td className={td}>{String(r.kind)}</td><td className={td}>{String(r.provider)}</td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td><td className={tdNum}>{n(r.events)}</td></tr>)}</tbody></Table> | |
| 46 | + </section> | |
| 47 | + <section className="card"><header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">By connector</h2></header> | |
| 48 | + <Table><thead><tr><th className={th}>Connector</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD</th><th className={`${th} text-right`}>Events</th></tr></thead> | |
| 49 | + <tbody>{c.byConnector.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">—</span></td></tr> : c.byConnector.map((r, i) => <tr key={i}><td className={td}><Link href={`/admin/connectors/${String(r.connector_id)}`} className="hover:underline">{String(r.connector_id)}</Link></td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td><td className={tdNum}>{n(r.events)}</td></tr>)}</tbody></Table> | |
| 50 | + </section> | |
| 51 | + <section className="card"><header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">By category</h2></header> | |
| 52 | + <Table><thead><tr><th className={th}>Category</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD</th></tr></thead> | |
| 53 | + <tbody>{c.byCategory.length === 0 ? <tr><td className={td} colSpan={3}><span className="text-subtle">—</span></td></tr> : c.byCategory.map((r, i) => <tr key={i}><td className={td}>{String(r.category_slug)}</td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td></tr>)}</tbody></Table> | |
| 54 | + </section> | |
| 55 | + <section className="card"><header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">AI by endpoint / model</h2></header> | |
| 56 | + <Table><thead><tr><th className={th}>Endpoint</th><th className={th}>Model</th><th className={`${th} text-right`}>Calls</th><th className={`${th} text-right`}>In tok</th><th className={`${th} text-right`}>Out tok</th><th className={`${th} text-right`}>USD</th></tr></thead> | |
| 57 | + <tbody>{c.byEndpoint.length === 0 ? <tr><td className={td} colSpan={6}><span className="text-subtle">—</span></td></tr> : c.byEndpoint.map((r, i) => <tr key={i}><td className={td}>{String(r.endpoint)}</td><td className={`${td} font-mono text-[11px]`}>{String(r.model)}</td><td className={tdNum}>{n(r.events)}</td><td className={tdNum}>{n(r.input_tokens)}</td><td className={tdNum}>{n(r.output_tokens)}</td><td className={tdNum}>${n(r.usd, 4)}</td></tr>)}</tbody></Table> | |
| 58 | + </section> | |
| 59 | + </div> | |
| 60 | + </AdminShell> | |
| 61 | + ); | |
| 62 | +} | |
added
apps/web/src/app/admin/data-quality/page.tsx
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { requireAdmin } from '@/lib/admin/auth'; | |
| 3 | +import { dataQuality, matchCandidatesForRecord } from '@/lib/admin/queries'; | |
| 4 | +import { manualMatchAction, saleStatusAction } from '@/lib/admin/actions'; | |
| 5 | +import { AdminShell, ActionButton, Kpi, StatusPill, fmtTs, n } from '@/components/admin/shell'; | |
| 6 | +import { Table, th, td, tdNum } from '@/components/ui/primitives'; | |
| 7 | + | |
| 8 | +export default async function DataQualityPage({ searchParams }: { searchParams: Promise<{ match?: string }> }) { | |
| 9 | + await requireAdmin(); | |
| 10 | + const sp = await searchParams; | |
| 11 | + const dq = await dataQuality(); | |
| 12 | + const q = (dq.quality ?? {}) as Record<string, unknown>; | |
| 13 | + const matching = sp.match ? await matchCandidatesForRecord(sp.match) : null; | |
| 14 | + return ( | |
| 15 | + <AdminShell current="/admin/data-quality" title="Data quality" subtitle="Flagged/excluded sales with audit reasons, unmatched records with manual matching, cross-listing duplicates. Nothing is deleted; every change is written to the audit log."> | |
| 16 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-4"> | |
| 17 | + <Kpi label="Sales data quality (avg)" value={n(q.sales_avg, 1)} sub={`p10 ${n(q.sales_p10, 1)}`} /> | |
| 18 | + <Kpi label="Assets data quality (avg)" value={n(q.assets_avg, 1)} /> | |
| 19 | + <Kpi label="Low-confidence sales" value={n(q.low_confidence_sales)} sub="confidence < 0.6" /> | |
| 20 | + <Kpi label="Flagged / excluded" value={dq.flagged.length} sub="latest 100 shown" /> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + {matching?.record ? ( | |
| 24 | + <section className="card mt-6 border-index/40"> | |
| 25 | + <header className="flex items-center justify-between border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Manual match</h2><Link href="/admin/data-quality" className="text-xs text-muted hover:text-fg">close</Link></header> | |
| 26 | + <div className="p-4 text-sm"> | |
| 27 | + <p className="font-medium">{String((matching.record.payload as { rawTitle?: string }).rawTitle ?? matching.record.id)}</p> | |
| 28 | + <ul className="mt-3 divide-y divide-border text-xs"> | |
| 29 | + {matching.candidates.length === 0 ? <li className="py-2 text-subtle">No similar asset titles. Reject or leave for the resolver.</li> : null} | |
| 30 | + {matching.candidates.map((c) => ( | |
| 31 | + <li key={String(c.id)} className="flex items-center justify-between gap-3 py-1.5"> | |
| 32 | + <span className="truncate"><Link href={`/asset/${String(c.slug)}`} className="hover:underline">{String(c.title)}</Link> <span className="text-subtle">{String(c.category_slug)} · {Math.round(Number(c.score) * 100)}%</span></span> | |
| 33 | + <form action={manualMatchAction}><input type="hidden" name="recordId" value={String(matching.record!.id)} /><input type="hidden" name="assetId" value={String(c.id)} /><ActionButton label="match" tone="primary" /></form> | |
| 34 | + </li> | |
| 35 | + ))} | |
| 36 | + </ul> | |
| 37 | + <form action={manualMatchAction} className="mt-3 flex items-center gap-2 text-xs"><input type="hidden" name="recordId" value={String(matching.record.id)} /><input name="assetId" placeholder="or paste an asset id (rare_…)" className="flex-1 rounded-md border border-border bg-sunken px-2 py-1" /><ActionButton label="match id" /></form> | |
| 38 | + <form action={manualMatchAction} className="mt-2"><input type="hidden" name="recordId" value={String(matching.record.id)} /><input type="hidden" name="assetId" value="" /><ActionButton label="reject record" tone="danger" /></form> | |
| 39 | + </div> | |
| 40 | + </section> | |
| 41 | + ) : null} | |
| 42 | + | |
| 43 | + <section className="card mt-6"> | |
| 44 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Flagged & excluded sales</h2></header> | |
| 45 | + <Table> | |
| 46 | + <thead><tr><th className={th}>Asset</th><th className={th}>Status</th><th className={th}>Flags</th><th className={`${th} text-right`}>Price</th><th className={th}>Date</th><th className={th}>Source</th><th className={th}>Last reason</th><th className={th}>Actions</th></tr></thead> | |
| 47 | + <tbody> | |
| 48 | + {dq.flagged.length === 0 ? <tr><td className={td} colSpan={8}><span className="text-subtle">No flagged or excluded sales.</span></td></tr> : null} | |
| 49 | + {dq.flagged.map((s) => ( | |
| 50 | + <tr key={String(s.id)}> | |
| 51 | + <td className={`${td} max-w-[260px] truncate`}><Link href={`/asset/${String(s.asset_slug)}`} className="hover:underline">{String(s.title)}</Link><div className="truncate text-[10px] text-subtle">{String(s.raw_title)}</div></td> | |
| 52 | + <td className={td}><StatusPill status={String(s.status)} /></td> | |
| 53 | + <td className={`${td} font-mono text-[10px]`}>{(s.flags as string[]).join(', ')}</td> | |
| 54 | + <td className={tdNum}>{n(s.price, 2)} {String(s.currency)}<div className="text-[10px] text-subtle">${n(s.price_usd)}</div></td> | |
| 55 | + <td className={td}>{fmtTs(s.sale_date).slice(0, 10)}</td> | |
| 56 | + <td className={td}><a href={String(s.source_url)} target="_blank" rel="noreferrer noopener" className="hover:underline">{String(s.source_id)}</a></td> | |
| 57 | + <td className={`${td} max-w-[220px] truncate`} title={String(s.last_reason ?? '')}>{String(s.last_reason ?? '')}</td> | |
| 58 | + <td className={td}> | |
| 59 | + <div className="flex gap-1"> | |
| 60 | + {(['valid', 'flagged', 'excluded'] as const).filter((x) => x !== s.status).map((x) => ( | |
| 61 | + <form key={x} action={saleStatusAction} className="flex items-center gap-1"><input type="hidden" name="id" value={String(s.id)} /><input type="hidden" name="status" value={x} /><input type="hidden" name="reason" value={`admin review → ${x}`} /><ActionButton label={x === 'valid' ? 'restore' : x} tone={x === 'excluded' ? 'danger' : 'neutral'} /></form> | |
| 62 | + ))} | |
| 63 | + </div> | |
| 64 | + </td> | |
| 65 | + </tr> | |
| 66 | + ))} | |
| 67 | + </tbody> | |
| 68 | + </Table> | |
| 69 | + </section> | |
| 70 | + | |
| 71 | + <section className="card mt-6"> | |
| 72 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Unmatched / rejected normalized records</h2></header> | |
| 73 | + <Table> | |
| 74 | + <thead><tr><th className={th}>Title</th><th className={th}>Connector</th><th className={th}>Kind</th><th className={th}>Category</th><th className={`${th} text-right`}>Price</th><th className={th}>Reason</th><th className={th}>Created</th><th className={th}></th></tr></thead> | |
| 75 | + <tbody> | |
| 76 | + {dq.unmatched.length === 0 ? <tr><td className={td} colSpan={8}><span className="text-subtle">Nothing waiting for a manual decision.</span></td></tr> : null} | |
| 77 | + {dq.unmatched.map((r) => ( | |
| 78 | + <tr key={String(r.id)}> | |
| 79 | + <td className={`${td} max-w-[300px] truncate`}><a href={String(r.source_url ?? '#')} target="_blank" rel="noreferrer noopener" className="hover:underline">{String(r.raw_title ?? r.id)}</a></td> | |
| 80 | + <td className={td}>{String(r.connector_id)}</td> | |
| 81 | + <td className={td}>{String(r.kind)}</td> | |
| 82 | + <td className={td}>{String(r.category_slug ?? '—')}</td> | |
| 83 | + <td className={tdNum}>{r.price ? `${n(r.price, 2)} ${String(r.currency ?? '')}` : '—'}</td> | |
| 84 | + <td className={`${td} max-w-[200px] truncate`} title={String(r.reject_reason ?? '')}>{String(r.reject_reason ?? '')}</td> | |
| 85 | + <td className={td}>{fmtTs(r.created_at)}</td> | |
| 86 | + <td className={td}><Link href={`/admin/data-quality?match=${String(r.id)}`} className="rounded-md border border-border px-2 py-0.5 text-[11px] font-medium hover:bg-inset">match</Link></td> | |
| 87 | + </tr> | |
| 88 | + ))} | |
| 89 | + </tbody> | |
| 90 | + </Table> | |
| 91 | + </section> | |
| 92 | + | |
| 93 | + <section className="card mt-6"> | |
| 94 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Cross-listing groups</h2></header> | |
| 95 | + <Table> | |
| 96 | + <thead><tr><th className={th}>Group</th><th className={`${th} text-right`}>Listings</th><th className={th}>Sources</th><th className={th}>Sample</th></tr></thead> | |
| 97 | + <tbody> | |
| 98 | + {dq.duplicates.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">No cross-listing groups detected yet.</span></td></tr> : null} | |
| 99 | + {dq.duplicates.map((d) => <tr key={String(d.cross_listing_group_id)}><td className={`${td} font-mono text-[11px]`}>{String(d.cross_listing_group_id)}</td><td className={tdNum}>{n(d.n)}</td><td className={td}>{(d.sources as string[]).join(', ')}</td><td className={`${td} max-w-[320px] truncate`}>{String(d.sample_title)}</td></tr>)} | |
| 100 | + </tbody> | |
| 101 | + </Table> | |
| 102 | + </section> | |
| 103 | + </AdminShell> | |
| 104 | + ); | |
| 105 | +} | |
added
apps/web/src/app/admin/events/page.tsx
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { requireAdmin } from '@/lib/admin/auth'; | |
| 3 | +import { eventsLog } from '@/lib/admin/queries'; | |
| 4 | +import { AdminShell, fmtTs, n } from '@/components/admin/shell'; | |
| 5 | +import { Table, th, td } from '@/components/ui/primitives'; | |
| 6 | + | |
| 7 | +export default async function EventsPage({ searchParams }: { searchParams: Promise<{ type?: string }> }) { | |
| 8 | + await requireAdmin(); | |
| 9 | + const sp = await searchParams; | |
| 10 | + const { rows, types } = await eventsLog({ type: sp.type || undefined }); | |
| 11 | + return ( | |
| 12 | + <AdminShell current="/admin/events" title="Domain events" subtitle="page_crawled, listing_created, sale_detected, entity_matched, valuation_updated, index_updated… (last 200)" actions={<div className="flex flex-wrap gap-1 text-xs"><Link href="/admin/events" className={`rounded-md border px-2 py-1 ${!sp.type ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset'}`}>all</Link>{types.map((t) => <Link key={String(t.type)} href={`/admin/events?type=${String(t.type)}`} className={`rounded-md border px-2 py-1 ${sp.type === t.type ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset'}`}>{String(t.type)} <span className="text-subtle">{n(t.n)}</span></Link>)}</div>}> | |
| 13 | + <div className="card"> | |
| 14 | + <Table> | |
| 15 | + <thead><tr><th className={th}>When</th><th className={th}>Type</th><th className={th}>Entity</th><th className={th}>Payload</th></tr></thead> | |
| 16 | + <tbody> | |
| 17 | + {rows.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">No events recorded yet.</span></td></tr> : null} | |
| 18 | + {rows.map((r) => ( | |
| 19 | + <tr key={String(r.id)} className="align-top"> | |
| 20 | + <td className={td}>{fmtTs(r.created_at)}</td> | |
| 21 | + <td className={`${td} font-mono text-[11px]`}>{String(r.type)}</td> | |
| 22 | + <td className={`${td} font-mono text-[11px]`}>{String(r.entity_type ?? '')} {String(r.entity_id ?? '')}</td> | |
| 23 | + <td className={`${td} max-w-[520px] whitespace-normal font-mono text-[10px] text-muted`}>{JSON.stringify(r.payload)}</td> | |
| 24 | + </tr> | |
| 25 | + ))} | |
| 26 | + </tbody> | |
| 27 | + </Table> | |
| 28 | + </div> | |
| 29 | + </AdminShell> | |
| 30 | + ); | |
| 31 | +} | |
added
apps/web/src/app/admin/layout.tsx
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | + | |
| 3 | +export const metadata: Metadata = { title: 'Admin', robots: { index: false, follow: false } }; | |
| 4 | +export const dynamic = 'force-dynamic'; | |
| 5 | + | |
| 6 | +export default function AdminLayout({ children }: { children: React.ReactNode }) { | |
| 7 | + return <>{children}</>; | |
| 8 | +} | |
added
apps/web/src/app/admin/login/page.tsx
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +import { adminLogin } from '@/lib/admin/actions'; | |
| 2 | + | |
| 3 | +export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) { | |
| 4 | + const sp = await searchParams; | |
| 5 | + return ( | |
| 6 | + <div className="mx-auto mt-16 max-w-sm"> | |
| 7 | + <div className="card p-6"> | |
| 8 | + <p className="text-[10px] font-semibold uppercase tracking-wider text-subtle">Admin</p> | |
| 9 | + <h1 className="mt-1 text-lg font-semibold">Connector control center</h1> | |
| 10 | + <p className="mt-1 text-xs text-muted">Enter the server admin token. The token is exchanged for a signed, httpOnly cookie valid 12 hours.</p> | |
| 11 | + <form action={adminLogin} className="mt-4 space-y-3"> | |
| 12 | + <input type="hidden" name="next" value={sp.next ?? '/admin'} /> | |
| 13 | + <input name="token" type="password" autoComplete="current-password" required placeholder="ADMIN_TOKEN" className="w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm focus:border-border-strong focus:outline-none" /> | |
| 14 | + {sp.error ? <p className="rounded-md bg-loss-bg px-3 py-2 text-xs text-loss">{sp.error}</p> : null} | |
| 15 | + <button type="submit" className="w-full rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-fg">Sign in</button> | |
| 16 | + </form> | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | + ); | |
| 20 | +} | |
added
apps/web/src/app/admin/page.tsx
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { requireAdmin } from '@/lib/admin/auth'; | |
| 3 | +import { dashboard } from '@/lib/admin/queries'; | |
| 4 | +import { queueDepths } from '@/lib/admin/queue'; | |
| 5 | +import { adminLogout } from '@/lib/admin/actions'; | |
| 6 | +import { AdminShell, Kpi, StatusPill, fmtTs, n } from '@/components/admin/shell'; | |
| 7 | +import { Table, th, td, tdNum } from '@/components/ui/primitives'; | |
| 8 | +import { getRouter } from '@rareindex/ai'; | |
| 9 | + | |
| 10 | +export default async function AdminHome() { | |
| 11 | + await requireAdmin(); | |
| 12 | + const [{ counts, recentRuns, recentEvents }, queues] = await Promise.all([dashboard(), queueDepths()]); | |
| 13 | + const c = (counts ?? {}) as Record<string, unknown>; | |
| 14 | + const ai = getRouter(); | |
| 15 | + return ( | |
| 16 | + <AdminShell | |
| 17 | + current="/admin" | |
| 18 | + title="Pipeline overview" | |
| 19 | + subtitle="Counts, lag and freshness across ingestion, canonical data and analytics" | |
| 20 | + actions={ | |
| 21 | + <form action={adminLogout}> | |
| 22 | + <button type="submit" className="rounded-md border border-border px-2.5 py-1 text-xs hover:bg-inset">Sign out</button> | |
| 23 | + </form> | |
| 24 | + } | |
| 25 | + > | |
| 26 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6"> | |
| 27 | + <Kpi label="Assets" value={n(c.assets)} sub={`${n(c.variants)} variants`} /> | |
| 28 | + <Kpi label="Sales" value={n(c.sales)} sub={`${n(c.sales_flagged)} flagged · ${n(c.sales_excluded)} excluded`} /> | |
| 29 | + <Kpi label="Live listings" value={n(c.listings_live)} /> | |
| 30 | + <Kpi label="Price observations" value={n(c.observations)} /> | |
| 31 | + <Kpi label="Raw records" value={n(c.raw_records)} sub={`${n(c.raw_unprocessed)} unprocessed · ${n(c.raw_errors)} errors`} tone={Number(c.raw_errors) > 0 ? 'alert' : undefined} /> | |
| 32 | + <Kpi label="Normalized pending" value={n(c.normalized_pending)} sub={`${n(c.normalized_unmatched)} unmatched`} tone={Number(c.normalized_pending) > 1000 ? 'alert' : undefined} /> | |
| 33 | + <Kpi label="Connectors" value={`${n(c.connectors_active)} active`} sub={`${n(c.connectors_paused)} paused · ${n(c.connectors_unhealthy)} unhealthy`} tone={Number(c.connectors_unhealthy) > 0 ? 'loss' : undefined} /> | |
| 34 | + <Kpi label="Taxonomy proposals" value={n(c.proposals_pending)} sub="pending review" /> | |
| 35 | + <Kpi label="Latest fetch" value={fmtTs(c.latest_fetch).slice(0, 16)} /> | |
| 36 | + <Kpi label="Latest sale" value={fmtTs(c.latest_sale).slice(0, 10)} sub={`valuation ${fmtTs(c.latest_valuation).slice(0, 16)}`} /> | |
| 37 | + <Kpi label="Latest index date" value={c.latest_index ? String(c.latest_index).slice(0, 10) : '—'} /> | |
| 38 | + <Kpi label="Spend 24h" value={`$${n(c.cost_24h_usd, 2)}`} sub={`FC ${n(c.firecrawl_credits_24h)} · SF ${n(c.scrapfly_credits_24h)} credits`} /> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <div className="mt-6 grid gap-6 lg:grid-cols-3"> | |
| 42 | + <section className="card lg:col-span-2"> | |
| 43 | + <header className="flex items-center justify-between border-b border-border px-4 py-2.5"> | |
| 44 | + <h2 className="text-sm font-semibold">Recent connector runs</h2> | |
| 45 | + <Link href="/admin/connectors" className="text-xs text-muted hover:text-fg">Control center →</Link> | |
| 46 | + </header> | |
| 47 | + <Table> | |
| 48 | + <thead><tr><th className={th}>Connector</th><th className={th}>Status</th><th className={th}>Trigger</th><th className={th}>Started</th><th className={`${th} text-right`}>Pages</th><th className={`${th} text-right`}>Raw</th><th className={`${th} text-right`}>Norm.</th><th className={`${th} text-right`}>Dup.</th><th className={th}>Error</th></tr></thead> | |
| 49 | + <tbody> | |
| 50 | + {recentRuns.length === 0 ? <tr><td className={td} colSpan={9}><span className="text-subtle">No runs yet. Trigger one from the control center.</span></td></tr> : null} | |
| 51 | + {recentRuns.map((r) => ( | |
| 52 | + <tr key={String(r.id)}> | |
| 53 | + <td className={td}><Link href={`/admin/connectors/${String(r.connector_id)}`} className="font-medium hover:underline">{String(r.connector_id)}</Link></td> | |
| 54 | + <td className={td}><StatusPill status={String(r.status)} /></td> | |
| 55 | + <td className={td}>{String(r.trigger)}</td> | |
| 56 | + <td className={td}>{fmtTs(r.started_at)}</td> | |
| 57 | + <td className={tdNum}>{n(r.pages_success)}/{n(r.pages_attempted)}</td> | |
| 58 | + <td className={tdNum}>{n(r.records_raw)}</td> | |
| 59 | + <td className={tdNum}>{n(r.records_normalized)}</td> | |
| 60 | + <td className={tdNum}>{n(r.records_duplicate)}</td> | |
| 61 | + <td className={`${td} max-w-[240px] truncate text-loss`} title={String(r.error ?? '')}>{r.error ? String(r.error) : ''}</td> | |
| 62 | + </tr> | |
| 63 | + ))} | |
| 64 | + </tbody> | |
| 65 | + </Table> | |
| 66 | + </section> | |
| 67 | + <div className="space-y-6"> | |
| 68 | + <section className="card"> | |
| 69 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Queues (pg-boss)</h2></header> | |
| 70 | + <ul className="divide-y divide-border text-xs"> | |
| 71 | + {queues.length === 0 ? <li className="px-4 py-3 text-subtle">Queue tables not initialised yet (the worker creates them on first start).</li> : null} | |
| 72 | + {queues.map((q) => ( | |
| 73 | + <li key={q.name} className="flex items-center justify-between px-4 py-2"><span className="font-mono">{q.name}</span><span className="num text-muted">{q.queued} queued · {q.active} active · {q.failed} failed</span></li> | |
| 74 | + ))} | |
| 75 | + </ul> | |
| 76 | + </section> | |
| 77 | + <section className="card"> | |
| 78 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Events (24h)</h2></header> | |
| 79 | + <ul className="divide-y divide-border text-xs"> | |
| 80 | + {recentEvents.length === 0 ? <li className="px-4 py-3 text-subtle">No events yet.</li> : null} | |
| 81 | + {recentEvents.map((e) => ( | |
| 82 | + <li key={String(e.type)} className="flex items-center justify-between px-4 py-2"><span className="font-mono">{String(e.type)}</span><span className="num text-muted">{n(e.n)}</span></li> | |
| 83 | + ))} | |
| 84 | + </ul> | |
| 85 | + </section> | |
| 86 | + <section className="card"> | |
| 87 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">AI model routing</h2></header> | |
| 88 | + <ul className="divide-y divide-border text-xs"> | |
| 89 | + {ai.table().map((r) => ( | |
| 90 | + <li key={r.role} className="flex items-center justify-between px-4 py-2"><span>{r.role}</span><span className="font-mono text-muted">{r.provider ? `${r.provider} · ${r.model}` : 'not configured'}</span></li> | |
| 91 | + ))} | |
| 92 | + </ul> | |
| 93 | + </section> | |
| 94 | + </div> | |
| 95 | + </div> | |
| 96 | + </AdminShell> | |
| 97 | + ); | |
| 98 | +} | |
added
apps/web/src/app/admin/taxonomy/page.tsx
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +import { requireAdmin } from '@/lib/admin/auth'; | |
| 2 | +import { taxonomyQueue } from '@/lib/admin/queries'; | |
| 3 | +import { proposeTaxonomyNode, taxonomyDecision } from '@/lib/admin/actions'; | |
| 4 | +import { AdminShell, ActionButton, StatusPill, fmtTs, n } from '@/components/admin/shell'; | |
| 5 | + | |
| 6 | +export default async function TaxonomyAdminPage() { | |
| 7 | + await requireAdmin(); | |
| 8 | + const { pending, decided, families } = await taxonomyQueue(); | |
| 9 | + return ( | |
| 10 | + <AdminShell current="/admin/taxonomy" title="Taxonomy proposals" subtitle="Category discovery queue (§101): approve to insert a node under the chosen parent; the category becomes available to connectors and pages immediately"> | |
| 11 | + <div className="grid gap-6 lg:grid-cols-[1fr_320px]"> | |
| 12 | + <section className="card"> | |
| 13 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Pending ({pending.length})</h2></header> | |
| 14 | + <ul className="divide-y divide-border"> | |
| 15 | + {pending.length === 0 ? <li className="px-4 py-6 text-center text-xs text-subtle">Queue empty. The discovery worker files proposals here when it finds recurring product clusters outside the taxonomy.</li> : null} | |
| 16 | + {pending.map((p) => ( | |
| 17 | + <li key={String(p.id)} className="px-4 py-3 text-sm"> | |
| 18 | + <div className="flex flex-wrap items-start justify-between gap-3"> | |
| 19 | + <div> | |
| 20 | + <p className="font-medium">{String(p.name)} <span className="font-mono text-xs text-muted">{String(p.proposed_slug)}</span></p> | |
| 21 | + <p className="text-xs text-muted">parent {String(p.parent_slug ?? '—')} · est. volume {n(p.volume_estimate)} · {fmtTs(p.created_at)}</p> | |
| 22 | + <pre className="mt-1 max-h-32 overflow-auto rounded-md bg-sunken p-2 text-[10px] text-muted">{JSON.stringify(p.evidence ?? {}, null, 1)}</pre> | |
| 23 | + </div> | |
| 24 | + <div className="flex items-center gap-2"> | |
| 25 | + <form action={taxonomyDecision} className="flex items-center gap-1"> | |
| 26 | + <input type="hidden" name="id" value={String(p.id)} /> | |
| 27 | + <input type="hidden" name="decision" value="approved" /> | |
| 28 | + <select name="parent" defaultValue={String(p.parent_slug ?? '')} className="rounded-md border border-border bg-sunken px-1.5 py-0.5 text-xs"> | |
| 29 | + <option value="">(new family)</option> | |
| 30 | + {families.map((f) => <option key={String(f.slug)} value={String(f.slug)}>{String(f.name)}</option>)} | |
| 31 | + </select> | |
| 32 | + <ActionButton label="approve" tone="primary" /> | |
| 33 | + </form> | |
| 34 | + <form action={taxonomyDecision}> | |
| 35 | + <input type="hidden" name="id" value={String(p.id)} /> | |
| 36 | + <input type="hidden" name="decision" value="rejected" /> | |
| 37 | + <ActionButton label="reject" tone="danger" /> | |
| 38 | + </form> | |
| 39 | + </div> | |
| 40 | + </div> | |
| 41 | + </li> | |
| 42 | + ))} | |
| 43 | + </ul> | |
| 44 | + <header className="border-y border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Decided</h2></header> | |
| 45 | + <ul className="divide-y divide-border text-xs"> | |
| 46 | + {decided.map((p) => <li key={String(p.id)} className="flex items-center justify-between px-4 py-2"><span>{String(p.name)} <span className="font-mono text-muted">{String(p.proposed_slug)}</span></span><span className="flex items-center gap-2 text-subtle"><StatusPill status={String(p.status)} />{fmtTs(p.decided_at)}</span></li>)} | |
| 47 | + </ul> | |
| 48 | + </section> | |
| 49 | + <section className="card self-start"> | |
| 50 | + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Propose manually</h2></header> | |
| 51 | + <form action={proposeTaxonomyNode} className="space-y-2 p-4 text-xs"> | |
| 52 | + <label className="block text-muted">Slug<input name="slug" required placeholder="riftbound" className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm" /></label> | |
| 53 | + <label className="block text-muted">Name<input name="name" required placeholder="Riftbound (League of Legends TCG)" className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm" /></label> | |
| 54 | + <label className="block text-muted">Parent<select name="parent" className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm"><option value="">(new family)</option>{families.map((f) => <option key={String(f.slug)} value={String(f.slug)}>{String(f.name)}</option>)}</select></label> | |
| 55 | + <ActionButton label="Add to queue" small={false} tone="primary" /> | |
| 56 | + </form> | |
| 57 | + </section> | |
| 58 | + </div> | |
| 59 | + </AdminShell> | |
| 60 | + ); | |
| 61 | +} | |
added
apps/web/src/app/api/admin/connectors/[id]/[action]/route.ts
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { connectorAction } from '@/lib/admin/actions'; | |
| 3 | + | |
| 4 | +export const runtime = 'nodejs'; | |
| 5 | + | |
| 6 | +/** JSON twin of the control-center buttons (for scripts/agents): POST /api/admin/connectors/:id/:action with the admin cookie. */ | |
| 7 | +export async function POST(_req: Request, ctx: { params: Promise<{ id: string; action: string }> }) { | |
| 8 | + const { id, action } = await ctx.params; | |
| 9 | + const fd = new FormData(); | |
| 10 | + fd.set('id', id); | |
| 11 | + fd.set('action', action); | |
| 12 | + try { | |
| 13 | + await connectorAction(fd); | |
| 14 | + return NextResponse.json({ ok: true, id, action }); | |
| 15 | + } catch (err) { | |
| 16 | + return NextResponse.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, { status: 400 }); | |
| 17 | + } | |
| 18 | +} | |
added
apps/web/src/app/api/admin/status/route.ts
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { dashboard, connectorsOverview } from '@/lib/admin/queries'; | |
| 3 | +import { queueDepths } from '@/lib/admin/queue'; | |
| 4 | + | |
| 5 | +export const runtime = 'nodejs'; | |
| 6 | +export const dynamic = 'force-dynamic'; | |
| 7 | + | |
| 8 | +/** Machine-readable pipeline status for monitors (admin cookie required by the proxy). */ | |
| 9 | +export async function GET() { | |
| 10 | + const [d, connectors, queues] = await Promise.all([dashboard(), connectorsOverview(), queueDepths()]); | |
| 11 | + return NextResponse.json({ | |
| 12 | + counts: d.counts, | |
| 13 | + queues, | |
| 14 | + connectors: connectors.map((c) => ({ id: c.id, status: c.status, health: c.health_status, lastRun: c.last_started_at, lastRunStatus: c.last_run_status, lastError: c.last_error, records24h: c.records_24h })), | |
| 15 | + as_of: new Date().toISOString(), | |
| 16 | + }); | |
| 17 | +} | |
added
apps/web/src/app/api/research/[id]/route.ts
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { loadSession } from '@/lib/ai/research'; | |
| 3 | +import { currentUserId, getAnonId } from '@/lib/ai/request'; | |
| 4 | + | |
| 5 | +export const runtime = 'nodejs'; | |
| 6 | + | |
| 7 | +export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) { | |
| 8 | + const { id } = await ctx.params; | |
| 9 | + const [userId, anonId] = await Promise.all([currentUserId(), getAnonId(false)]); | |
| 10 | + const loaded = await loadSession(id, { anonId, userId }); | |
| 11 | + if (!loaded) return NextResponse.json({ error: 'Not found' }, { status: 404 }); | |
| 12 | + return NextResponse.json({ | |
| 13 | + session: { id: loaded.session.id, title: loaded.session.title, usdEst: loaded.session.usdEst, model: loaded.session.model }, | |
| 14 | + messages: loaded.messages.map((m) => ({ id: m.id, role: m.role, content: m.content, toolCalls: m.toolCalls, createdAt: m.createdAt })), | |
| 15 | + }); | |
| 16 | +} | |
added
apps/web/src/app/api/research/route.ts
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { NextResponse } from 'next/server'; | |
| 3 | +import { researchTurn, listSessions } from '@/lib/ai/research'; | |
| 4 | +import { clientIpHash, consumeQuota, currentUserId, getAnonId } from '@/lib/ai/request'; | |
| 5 | + | |
| 6 | +export const runtime = 'nodejs'; | |
| 7 | +export const maxDuration = 300; | |
| 8 | + | |
| 9 | +const Body = z.object({ sessionId: z.string().regex(/^rs_[a-z0-9]+$/).nullable().optional(), message: z.string().min(1).max(4000) }); | |
| 10 | + | |
| 11 | +/** POST → Server-Sent Events stream of ResearchEvent JSON lines. */ | |
| 12 | +export async function POST(req: Request) { | |
| 13 | + const parsed = Body.safeParse(await req.json().catch(() => null)); | |
| 14 | + if (!parsed.success) return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); | |
| 15 | + const [userId, ipHash, anonId] = await Promise.all([currentUserId(), clientIpHash(), getAnonId()]); | |
| 16 | + const quota = await consumeQuota('research', { userId, ipHash }); | |
| 17 | + if (!quota.ok) return NextResponse.json({ error: `Daily research limit reached (${quota.limit}). Sign in for a higher limit.` }, { status: 429 }); | |
| 18 | + | |
| 19 | + const encoder = new TextEncoder(); | |
| 20 | + const stream = new ReadableStream<Uint8Array>({ | |
| 21 | + async start(controller) { | |
| 22 | + const send = (e: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`)); | |
| 23 | + try { | |
| 24 | + for await (const ev of researchTurn({ sessionId: parsed.data.sessionId ?? null, anonId, userId, message: parsed.data.message, signal: req.signal })) send(ev); | |
| 25 | + } catch (err) { | |
| 26 | + send({ type: 'error', message: err instanceof Error ? err.message : String(err) }); | |
| 27 | + } finally { | |
| 28 | + controller.enqueue(encoder.encode('data: [DONE]\n\n')); | |
| 29 | + controller.close(); | |
| 30 | + } | |
| 31 | + }, | |
| 32 | + }); | |
| 33 | + return new Response(stream, { headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive', 'x-accel-buffering': 'no' } }); | |
| 34 | +} | |
| 35 | + | |
| 36 | +export async function GET() { | |
| 37 | + const [userId, anonId] = await Promise.all([currentUserId(), getAnonId(false)]); | |
| 38 | + const sessions = await listSessions({ anonId, userId }); | |
| 39 | + return NextResponse.json({ sessions: sessions.map((s) => ({ id: s.id, title: s.title, messageCount: s.messageCount, updatedAt: s.updatedAt })) }); | |
| 40 | +} | |
added
apps/web/src/app/api/scanner/[id]/choose/route.ts
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { chooseCandidate } from '@/lib/ai/scanner'; | |
| 4 | + | |
| 5 | +export const runtime = 'nodejs'; | |
| 6 | + | |
| 7 | +export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) { | |
| 8 | + const { id } = await ctx.params; | |
| 9 | + const body = z.object({ assetId: z.string().nullable() }).safeParse(await req.json().catch(() => ({}))); | |
| 10 | + if (!body.success || !/^scan_[a-z0-9]+$/.test(id)) return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); | |
| 11 | + await chooseCandidate(id, body.data.assetId); | |
| 12 | + return NextResponse.json({ ok: true }); | |
| 13 | +} | |
added
apps/web/src/app/api/scanner/route.ts
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { runScan } from '@/lib/ai/scanner'; | |
| 4 | +import { aiErrorMessage, clientIpHash, consumeQuota, currentUserId, getAnonId } from '@/lib/ai/request'; | |
| 5 | + | |
| 6 | +export const runtime = 'nodejs'; | |
| 7 | +export const maxDuration = 120; | |
| 8 | + | |
| 9 | +const Body = z.object({ | |
| 10 | + mode: z.enum(['photo', 'url', 'text']), | |
| 11 | + images: z.array(z.object({ data: z.string().min(100).max(2_800_000), mediaType: z.enum(['image/jpeg', 'image/png', 'image/webp']) })).max(5).optional(), | |
| 12 | + thumbnails: z.array(z.string().max(80_000)).max(3).optional(), | |
| 13 | + url: z.string().url().max(2000).optional(), | |
| 14 | + text: z.string().max(4000).optional(), | |
| 15 | +}); | |
| 16 | + | |
| 17 | +export async function POST(req: Request) { | |
| 18 | + let body: z.infer<typeof Body>; | |
| 19 | + try { | |
| 20 | + body = Body.parse(await req.json()); | |
| 21 | + } catch (err) { | |
| 22 | + return NextResponse.json({ error: 'Invalid request', detail: err instanceof Error ? err.message : String(err) }, { status: 400 }); | |
| 23 | + } | |
| 24 | + if (body.mode === 'photo' && !body.images?.length) return NextResponse.json({ error: 'Add at least one photo' }, { status: 400 }); | |
| 25 | + if (body.mode === 'url' && !body.url) return NextResponse.json({ error: 'Paste a listing URL' }, { status: 400 }); | |
| 26 | + if (body.mode === 'text' && !body.text?.trim()) return NextResponse.json({ error: 'Describe the item' }, { status: 400 }); | |
| 27 | + | |
| 28 | + const [userId, ipHash, anonId] = await Promise.all([currentUserId(), clientIpHash(), getAnonId()]); | |
| 29 | + const quota = await consumeQuota('scanner', { userId, ipHash }); | |
| 30 | + if (!quota.ok) return NextResponse.json({ error: `Daily scanner limit reached (${quota.limit}). Sign in for a higher limit.` }, { status: 429 }); | |
| 31 | + | |
| 32 | + try { | |
| 33 | + const result = await runScan({ | |
| 34 | + mode: body.mode, | |
| 35 | + images: body.images?.map((i) => ({ data: i.data, mediaType: i.mediaType })), | |
| 36 | + thumbnails: body.thumbnails, | |
| 37 | + url: body.url, | |
| 38 | + text: body.text, | |
| 39 | + userId, | |
| 40 | + anonId, | |
| 41 | + ipHash, | |
| 42 | + }); | |
| 43 | + return NextResponse.json({ ...result, quota: { used: quota.used, limit: quota.limit } }); | |
| 44 | + } catch (err) { | |
| 45 | + const e = aiErrorMessage(err); | |
| 46 | + return NextResponse.json({ error: e.message }, { status: e.status }); | |
| 47 | + } | |
| 48 | +} | |
added
apps/web/src/app/research/page.tsx
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { getRouter } from '@rareindex/ai'; | |
| 3 | +import { ResearchClient } from '@/components/research/research-client'; | |
| 4 | +import { SUGGESTED_PROMPTS } from '@/lib/ai/research'; | |
| 5 | +import { getDb, sql } from '@rareindex/database'; | |
| 6 | + | |
| 7 | +export const metadata: Metadata = { | |
| 8 | + title: 'AI Research — ask questions over structured collectibles market data', | |
| 9 | + description: 'Conversational research over RareIndex data: assets, sales, listings, indices, movers and screens. Every answer is grounded in tool queries you can inspect.', | |
| 10 | +}; | |
| 11 | +export const dynamic = 'force-dynamic'; | |
| 12 | + | |
| 13 | +export default async function ResearchPage({ searchParams }: { searchParams: Promise<{ asset?: string; q?: string }> }) { | |
| 14 | + const sp = await searchParams; | |
| 15 | + const router = getRouter(); | |
| 16 | + let prefill = sp.q ?? ''; | |
| 17 | + if (sp.asset && !prefill) { | |
| 18 | + const [a] = (await getDb().execute(sql`select title from assets where slug = ${sp.asset} limit 1`)) as unknown as Array<{ title: string }>; | |
| 19 | + prefill = a ? `Give me a market brief on ${a.title} (/asset/${sp.asset}): valuation with confidence, recent sales, listings vs RIV, and 30d/1y change.` : ''; | |
| 20 | + } | |
| 21 | + return ( | |
| 22 | + <div className="mx-auto max-w-5xl"> | |
| 23 | + <header className="mb-4"> | |
| 24 | + <p className="text-[11px] font-semibold uppercase tracking-wider text-subtle">AI Research</p> | |
| 25 | + <h1 className="mt-1 text-2xl font-semibold tracking-tight">Ask the collectibles market</h1> | |
| 26 | + <p className="mt-2 max-w-2xl text-sm text-muted">Questions are answered only from RareIndex’s structured data through inspectable queries. When the data is not there, the answer says so.</p> | |
| 27 | + </header> | |
| 28 | + <ResearchClient aiReady={router.configured} model={router.configured ? router.modelFor('research') : null} suggestions={SUGGESTED_PROMPTS} prefill={prefill} /> | |
| 29 | + </div> | |
| 30 | + ); | |
| 31 | +} | |
added
apps/web/src/app/scanner/page.tsx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { ScannerClient } from '@/components/scanner/scanner-client'; | |
| 3 | +import { aiConfigured } from '@/lib/ai/scanner'; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { | |
| 6 | + title: 'Scanner — identify and price a collectible from a photo or URL', | |
| 7 | + description: 'Upload photos or paste a marketplace URL. RareIndex identifies the item, finds it in the catalog and shows its RareIndex Valuation, latest sales and listings — with confidence, never as authentication.', | |
| 8 | +}; | |
| 9 | + | |
| 10 | +export const dynamic = 'force-dynamic'; | |
| 11 | + | |
| 12 | +export default function ScannerPage() { | |
| 13 | + return ( | |
| 14 | + <div className="mx-auto max-w-5xl"> | |
| 15 | + <header className="mb-6"> | |
| 16 | + <p className="text-[11px] font-semibold uppercase tracking-wider text-subtle">Scanner</p> | |
| 17 | + <h1 className="mt-1 text-2xl font-semibold tracking-tight">What is this collectible worth?</h1> | |
| 18 | + <p className="mt-2 max-w-2xl text-sm text-muted">Take or upload up to five photos, paste a marketplace URL, or describe the item. RareIndex identifies it, matches it to the canonical catalog and returns the RareIndex Valuation with comparable sales and live listings. Identification is an estimate with a confidence score; it is not authentication.</p> | |
| 19 | + </header> | |
| 20 | + <ScannerClient aiReady={aiConfigured()} /> | |
| 21 | + </div> | |
| 22 | + ); | |
| 23 | +} | |
added
apps/web/src/components/admin/shell.tsx
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import type { ReactNode } from 'react'; | |
| 3 | +import { cn } from '@/lib/format'; | |
| 4 | + | |
| 5 | +export const ADMIN_NAV = [ | |
| 6 | + { href: '/admin', label: 'Overview' }, | |
| 7 | + { href: '/admin/connectors', label: 'Connectors' }, | |
| 8 | + { href: '/admin/data-quality', label: 'Data quality' }, | |
| 9 | + { href: '/admin/costs', label: 'Costs' }, | |
| 10 | + { href: '/admin/taxonomy', label: 'Taxonomy' }, | |
| 11 | + { href: '/admin/audit', label: 'Audit log' }, | |
| 12 | + { href: '/admin/events', label: 'Events' }, | |
| 13 | +]; | |
| 14 | + | |
| 15 | +export function AdminShell({ children, title, subtitle, actions, current }: { children: ReactNode; title: string; subtitle?: ReactNode; actions?: ReactNode; current: string }) { | |
| 16 | + return ( | |
| 17 | + <div className="grid gap-6 lg:grid-cols-[180px_1fr]"> | |
| 18 | + <aside className="lg:sticky lg:top-20 lg:self-start"> | |
| 19 | + <p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-subtle">Admin</p> | |
| 20 | + <nav className="flex gap-1 overflow-x-auto scrollbar-none lg:flex-col"> | |
| 21 | + {ADMIN_NAV.map((n) => ( | |
| 22 | + <Link key={n.href} href={n.href} className={cn('whitespace-nowrap rounded-md px-2.5 py-1.5 text-[13px]', current === n.href ? 'bg-inset font-medium text-fg' : 'text-muted hover:bg-inset hover:text-fg')}> | |
| 23 | + {n.label} | |
| 24 | + </Link> | |
| 25 | + ))} | |
| 26 | + </nav> | |
| 27 | + </aside> | |
| 28 | + <div className="min-w-0"> | |
| 29 | + <header className="mb-4 flex flex-wrap items-end justify-between gap-3"> | |
| 30 | + <div> | |
| 31 | + <h1 className="text-xl font-semibold tracking-tight">{title}</h1> | |
| 32 | + {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null} | |
| 33 | + </div> | |
| 34 | + {actions} | |
| 35 | + </header> | |
| 36 | + {children} | |
| 37 | + </div> | |
| 38 | + </div> | |
| 39 | + ); | |
| 40 | +} | |
| 41 | + | |
| 42 | +export function StatusPill({ status }: { status: string | null | undefined }) { | |
| 43 | + const s = status ?? 'unknown'; | |
| 44 | + const tone: Record<string, string> = { healthy: 'bg-gain-bg text-gain', active: 'bg-gain-bg text-gain', success: 'bg-gain-bg text-gain', valid: 'bg-gain-bg text-gain', degraded: 'bg-alert-bg text-alert', partial: 'bg-alert-bg text-alert', paused: 'bg-alert-bg text-alert', flagged: 'bg-alert-bg text-alert', pending: 'bg-alert-bg text-alert', running: 'bg-index-bg text-index', failing: 'bg-loss-bg text-loss', failed: 'bg-loss-bg text-loss', excluded: 'bg-loss-bg text-loss', disabled: 'bg-inset text-muted', unknown: 'bg-inset text-muted' }; | |
| 45 | + return <span className={cn('inline-flex rounded-sm px-1.5 py-0.5 text-[11px] font-medium', tone[s] ?? 'bg-inset text-muted')}>{s}</span>; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export function ActionButton({ label, tone = 'neutral', small = true }: { label: string; tone?: 'neutral' | 'danger' | 'primary'; small?: boolean }) { | |
| 49 | + return ( | |
| 50 | + <button type="submit" className={cn('rounded-md border font-medium', small ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-xs', tone === 'danger' ? 'border-loss/40 text-loss hover:bg-loss-bg' : tone === 'primary' ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset')}> | |
| 51 | + {label} | |
| 52 | + </button> | |
| 53 | + ); | |
| 54 | +} | |
| 55 | + | |
| 56 | +export function Kpi({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone?: 'gain' | 'loss' | 'alert' }) { | |
| 57 | + return ( | |
| 58 | + <div className="card px-3 py-2.5"> | |
| 59 | + <p className="text-[10px] font-semibold uppercase tracking-wider text-subtle">{label}</p> | |
| 60 | + <p className={cn('num mt-0.5 text-lg font-semibold', tone === 'gain' && 'text-gain', tone === 'loss' && 'text-loss', tone === 'alert' && 'text-alert')}>{value}</p> | |
| 61 | + {sub ? <p className="text-[11px] text-muted">{sub}</p> : null} | |
| 62 | + </div> | |
| 63 | + ); | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** Single-series bar chart (one hue, hover tooltips via <title>, no legend needed). */ | |
| 67 | +export function Bars({ points, format, className }: { points: Array<{ label: string; value: number }>; format?: (v: number) => string; className?: string }) { | |
| 68 | + if (!points.length) return <p className="text-xs text-subtle">No data in this window.</p>; | |
| 69 | + const max = Math.max(...points.map((p) => p.value), 0) || 1; | |
| 70 | + const w = 600; | |
| 71 | + const h = 120; | |
| 72 | + const gap = 2; | |
| 73 | + const bw = Math.max(2, (w - gap * points.length) / points.length); | |
| 74 | + const fmt = format ?? ((v: number) => String(Math.round(v * 100) / 100)); | |
| 75 | + return ( | |
| 76 | + <svg viewBox={`0 0 ${w} ${h + 16}`} className={cn('h-auto w-full', className)} role="img" aria-label="Daily values"> | |
| 77 | + <line x1={0} x2={w} y1={h} y2={h} stroke="var(--ri-border)" /> | |
| 78 | + {points.map((p, i) => { | |
| 79 | + const bh = Math.max(p.value > 0 ? 1 : 0, (p.value / max) * (h - 8)); | |
| 80 | + return ( | |
| 81 | + <g key={p.label}> | |
| 82 | + <rect x={i * (bw + gap)} y={h - bh} width={bw} height={bh} rx={bw > 6 ? 2 : 0} fill="var(--ri-index)" opacity={0.85}> | |
| 83 | + <title>{`${p.label}: ${fmt(p.value)}`}</title> | |
| 84 | + </rect> | |
| 85 | + {points.length <= 31 && (i === 0 || i === points.length - 1) ? ( | |
| 86 | + <text x={i * (bw + gap) + bw / 2} y={h + 12} textAnchor="middle" fontSize={9} fill="var(--ri-fg-subtle)">{p.label.slice(5)}</text> | |
| 87 | + ) : null} | |
| 88 | + </g> | |
| 89 | + ); | |
| 90 | + })} | |
| 91 | + </svg> | |
| 92 | + ); | |
| 93 | +} | |
| 94 | + | |
| 95 | +export function fmtTs(v: unknown): string { | |
| 96 | + if (!v) return '—'; | |
| 97 | + const d = v instanceof Date ? v : new Date(String(v)); | |
| 98 | + if (Number.isNaN(d.getTime())) return String(v); | |
| 99 | + return d.toISOString().replace('T', ' ').slice(0, 16) + 'Z'; | |
| 100 | +} | |
| 101 | + | |
| 102 | +export function n(v: unknown, digits = 0): string { | |
| 103 | + const x = Number(v); | |
| 104 | + return Number.isFinite(x) ? x.toLocaleString('en-US', { maximumFractionDigits: digits }) : '—'; | |
| 105 | +} | |
added
apps/web/src/components/research/markdown.tsx
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { Fragment, type ReactNode } from 'react'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Tiny dependency-free markdown renderer for assistant answers: paragraphs, headings, bullet and | |
| 8 | + * numbered lists, pipe tables, bold/italic/code and links (internal links use next/link). | |
| 9 | + * Not a full CommonMark implementation — enough for terminal-style research answers. | |
| 10 | + */ | |
| 11 | +export function Markdown({ text }: { text: string }) { | |
| 12 | + const blocks = splitBlocks(text); | |
| 13 | + return <div className="space-y-2 text-[13px] leading-relaxed">{blocks.map((b, i) => <Fragment key={i}>{renderBlock(b)}</Fragment>)}</div>; | |
| 14 | +} | |
| 15 | + | |
| 16 | +function splitBlocks(text: string): string[] { | |
| 17 | + return text.replace(/\r\n/g, '\n').split(/\n{2,}/).map((b) => b.trim()).filter(Boolean); | |
| 18 | +} | |
| 19 | + | |
| 20 | +function renderBlock(block: string): ReactNode { | |
| 21 | + const lines = block.split('\n'); | |
| 22 | + if (lines.length >= 2 && lines.every((l) => l.trim().startsWith('|'))) return renderTable(lines); | |
| 23 | + if (lines.every((l) => /^\s*[-*•]\s+/.test(l))) return <ul className="list-disc space-y-0.5 pl-5">{lines.map((l, i) => <li key={i}>{inline(l.replace(/^\s*[-*•]\s+/, ''))}</li>)}</ul>; | |
| 24 | + if (lines.every((l) => /^\s*\d+[.)]\s+/.test(l))) return <ol className="list-decimal space-y-0.5 pl-5">{lines.map((l, i) => <li key={i}>{inline(l.replace(/^\s*\d+[.)]\s+/, ''))}</li>)}</ol>; | |
| 25 | + const h = block.match(/^(#{1,4})\s+(.*)$/); | |
| 26 | + if (h && lines.length === 1) { | |
| 27 | + const level = h[1]!.length; | |
| 28 | + const cls = level <= 2 ? 'text-sm font-semibold' : 'text-[13px] font-semibold'; | |
| 29 | + return <p className={cls}>{inline(h[2]!)}</p>; | |
| 30 | + } | |
| 31 | + if (block.startsWith('```')) return <pre className="overflow-x-auto rounded-md bg-inset p-2 font-mono text-[11px]">{block.replace(/^```\w*\n?/, '').replace(/```$/, '')}</pre>; | |
| 32 | + return <p>{lines.map((l, i) => <Fragment key={i}>{i > 0 ? <br /> : null}{inline(l)}</Fragment>)}</p>; | |
| 33 | +} | |
| 34 | + | |
| 35 | +function renderTable(lines: string[]): ReactNode { | |
| 36 | + const rows = lines.filter((l) => !/^\s*\|?\s*:?-{2,}/.test(l)).map((l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())); | |
| 37 | + const [head, ...body] = rows; | |
| 38 | + if (!head) return null; | |
| 39 | + return ( | |
| 40 | + <div className="overflow-x-auto"> | |
| 41 | + <table className="w-full border-collapse text-[12px]"> | |
| 42 | + <thead> | |
| 43 | + <tr>{head.map((c, i) => <th key={i} className="border-b border-border px-2 py-1 text-left text-[10px] font-semibold uppercase tracking-wider text-subtle">{inline(c)}</th>)}</tr> | |
| 44 | + </thead> | |
| 45 | + <tbody> | |
| 46 | + {body.map((r, i) => ( | |
| 47 | + <tr key={i}>{r.map((c, j) => <td key={j} className={`border-b border-border px-2 py-1 ${/^[\s$€£¥+\-−]?[\d.,]+%?$/.test(c) ? 'num text-right' : ''}`}>{inline(c)}</td>)}</tr> | |
| 48 | + ))} | |
| 49 | + </tbody> | |
| 50 | + </table> | |
| 51 | + </div> | |
| 52 | + ); | |
| 53 | +} | |
| 54 | + | |
| 55 | +const INLINE = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g; | |
| 56 | + | |
| 57 | +function inline(s: string): ReactNode { | |
| 58 | + const parts = s.split(INLINE).filter((p) => p !== ''); | |
| 59 | + return parts.map((p, i) => { | |
| 60 | + if (p.startsWith('**') && p.endsWith('**')) return <strong key={i}>{p.slice(2, -2)}</strong>; | |
| 61 | + if (p.startsWith('*') && p.endsWith('*') && p.length > 2) return <em key={i}>{p.slice(1, -1)}</em>; | |
| 62 | + if (p.startsWith('`') && p.endsWith('`')) return <code key={i} className="rounded-sm bg-inset px-1 font-mono text-[11px]">{p.slice(1, -1)}</code>; | |
| 63 | + const m = p.match(/^\[([^\]]+)\]\(([^)]+)\)$/); | |
| 64 | + if (m) { | |
| 65 | + const href = m[2]!; | |
| 66 | + const cls = 'font-medium underline decoration-border-strong underline-offset-2 hover:decoration-fg'; | |
| 67 | + return href.startsWith('/') ? <Link key={i} href={href} className={cls}>{m[1]}</Link> : <a key={i} href={href} target="_blank" rel="noreferrer noopener" className={cls}>{m[1]}</a>; | |
| 68 | + } | |
| 69 | + return <Fragment key={i}>{p}</Fragment>; | |
| 70 | + }); | |
| 71 | +} | |
added
apps/web/src/components/research/research-client.tsx
+212 −0
@@ -0,0 +1,212 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect, useRef, useState } from 'react'; | |
| 4 | +import { ChevronDown, Loader2, Send, Sparkles } from 'lucide-react'; | |
| 5 | +import { cn } from '@/lib/format'; | |
| 6 | +import { Markdown } from './markdown'; | |
| 7 | + | |
| 8 | +interface ToolTrace { | |
| 9 | + id: string; | |
| 10 | + name: string; | |
| 11 | + input: unknown; | |
| 12 | + ok?: boolean; | |
| 13 | + ms?: number; | |
| 14 | + summary?: string; | |
| 15 | + preview?: unknown; | |
| 16 | +} | |
| 17 | +interface Turn { | |
| 18 | + id: string; | |
| 19 | + role: 'user' | 'assistant'; | |
| 20 | + content: string; | |
| 21 | + tools: ToolTrace[]; | |
| 22 | + streaming?: boolean; | |
| 23 | + error?: string; | |
| 24 | + usdEst?: number; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiReady: boolean; model: string | null; suggestions: string[]; prefill: string }) { | |
| 28 | + const [sessionId, setSessionId] = useState<string | null>(null); | |
| 29 | + const [turns, setTurns] = useState<Turn[]>([]); | |
| 30 | + const [input, setInput] = useState(prefill); | |
| 31 | + const [busy, setBusy] = useState(false); | |
| 32 | + const [sessions, setSessions] = useState<Array<{ id: string; title: string | null; updatedAt: string }>>([]); | |
| 33 | + const bottom = useRef<HTMLDivElement>(null); | |
| 34 | + const abort = useRef<AbortController | null>(null); | |
| 35 | + | |
| 36 | + useEffect(() => { | |
| 37 | + fetch('/api/research').then((r) => r.json()).then((j) => setSessions(j.sessions ?? [])).catch(() => {}); | |
| 38 | + }, []); | |
| 39 | + useEffect(() => { | |
| 40 | + bottom.current?.scrollIntoView({ block: 'end' }); | |
| 41 | + }, [turns]); | |
| 42 | + | |
| 43 | + async function openSession(id: string) { | |
| 44 | + const r = await fetch(`/api/research/${id}`); | |
| 45 | + if (!r.ok) return; | |
| 46 | + const j = (await r.json()) as { messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; toolCalls: Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }> }> }; | |
| 47 | + setSessionId(id); | |
| 48 | + setTurns(j.messages.map((m) => ({ id: m.id, role: m.role, content: m.content, tools: (m.toolCalls ?? []).map((t, i) => ({ id: `${m.id}-${i}`, ...t })) }))); | |
| 49 | + } | |
| 50 | + | |
| 51 | + async function send(text: string) { | |
| 52 | + const message = text.trim(); | |
| 53 | + if (!message || busy) return; | |
| 54 | + setInput(''); | |
| 55 | + setBusy(true); | |
| 56 | + const userTurn: Turn = { id: `u-${Date.now()}`, role: 'user', content: message, tools: [] }; | |
| 57 | + const asst: Turn = { id: `a-${Date.now()}`, role: 'assistant', content: '', tools: [], streaming: true }; | |
| 58 | + setTurns((t) => [...t, userTurn, asst]); | |
| 59 | + const update = (fn: (a: Turn) => Turn) => setTurns((t) => t.map((x) => (x.id === asst.id ? fn(x) : x))); | |
| 60 | + abort.current = new AbortController(); | |
| 61 | + try { | |
| 62 | + const res = await fetch('/api/research', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, message }), signal: abort.current.signal }); | |
| 63 | + if (!res.ok || !res.body) { | |
| 64 | + const j = await res.json().catch(() => ({})); | |
| 65 | + throw new Error((j as { error?: string }).error ?? `HTTP ${res.status}`); | |
| 66 | + } | |
| 67 | + const reader = res.body.getReader(); | |
| 68 | + const dec = new TextDecoder(); | |
| 69 | + let buf = ''; | |
| 70 | + for (;;) { | |
| 71 | + const { value, done } = await reader.read(); | |
| 72 | + if (done) break; | |
| 73 | + buf += dec.decode(value, { stream: true }); | |
| 74 | + const parts = buf.split('\n\n'); | |
| 75 | + buf = parts.pop() ?? ''; | |
| 76 | + for (const p of parts) { | |
| 77 | + const line = p.replace(/^data: /, ''); | |
| 78 | + if (line === '[DONE]') continue; | |
| 79 | + let ev: Record<string, unknown>; | |
| 80 | + try { | |
| 81 | + ev = JSON.parse(line); | |
| 82 | + } catch { | |
| 83 | + continue; | |
| 84 | + } | |
| 85 | + switch (ev.type) { | |
| 86 | + case 'session': | |
| 87 | + setSessionId(ev.sessionId as string); | |
| 88 | + break; | |
| 89 | + case 'text': | |
| 90 | + update((a) => ({ ...a, content: a.content + (ev.text as string) })); | |
| 91 | + break; | |
| 92 | + case 'tool_call': | |
| 93 | + update((a) => ({ ...a, tools: [...a.tools, { id: ev.id as string, name: ev.name as string, input: ev.input }] })); | |
| 94 | + break; | |
| 95 | + case 'tool_result': | |
| 96 | + update((a) => ({ ...a, tools: a.tools.map((t) => (t.id === ev.id ? { ...t, ok: ev.ok as boolean, ms: ev.ms as number, summary: ev.summary as string, preview: ev.preview } : t)) })); | |
| 97 | + break; | |
| 98 | + case 'error': | |
| 99 | + update((a) => ({ ...a, error: ev.message as string })); | |
| 100 | + break; | |
| 101 | + case 'done': | |
| 102 | + update((a) => ({ ...a, streaming: false, usdEst: ev.usdEst as number })); | |
| 103 | + break; | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } catch (err) { | |
| 108 | + update((a) => ({ ...a, streaming: false, error: err instanceof Error ? err.message : String(err) })); | |
| 109 | + } finally { | |
| 110 | + setBusy(false); | |
| 111 | + update((a) => ({ ...a, streaming: false })); | |
| 112 | + fetch('/api/research').then((r) => r.json()).then((j) => setSessions(j.sessions ?? [])).catch(() => {}); | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + return ( | |
| 117 | + <div className="grid gap-4 lg:grid-cols-[220px_1fr]"> | |
| 118 | + <aside className="hidden lg:block"> | |
| 119 | + <button type="button" onClick={() => { setSessionId(null); setTurns([]); }} className="mb-2 w-full rounded-md border border-border px-3 py-1.5 text-left text-xs font-medium hover:bg-inset">+ New thread</button> | |
| 120 | + <ul className="space-y-0.5"> | |
| 121 | + {sessions.map((s) => ( | |
| 122 | + <li key={s.id}> | |
| 123 | + <button type="button" onClick={() => void openSession(s.id)} className={cn('w-full truncate rounded-md px-2 py-1.5 text-left text-xs', s.id === sessionId ? 'bg-inset text-fg' : 'text-muted hover:bg-inset hover:text-fg')} title={s.title ?? ''}> | |
| 124 | + {s.title ?? 'Untitled'} | |
| 125 | + </button> | |
| 126 | + </li> | |
| 127 | + ))} | |
| 128 | + </ul> | |
| 129 | + </aside> | |
| 130 | + <section className="card flex min-h-[70dvh] flex-col"> | |
| 131 | + <div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs text-muted"> | |
| 132 | + <span className="inline-flex items-center gap-1.5"><Sparkles className="h-3.5 w-3.5" /> {aiReady ? `Grounded research · ${model}` : 'AI provider not configured'}</span> | |
| 133 | + <span>Tool calls are shown inline</span> | |
| 134 | + </div> | |
| 135 | + <div className="flex-1 space-y-4 overflow-y-auto px-4 py-4"> | |
| 136 | + {turns.length === 0 ? ( | |
| 137 | + <div> | |
| 138 | + <p className="mb-3 text-sm text-muted">Try one of these:</p> | |
| 139 | + <div className="grid gap-2 sm:grid-cols-2"> | |
| 140 | + {suggestions.map((s) => ( | |
| 141 | + <button key={s} type="button" disabled={!aiReady || busy} onClick={() => void send(s)} className="rounded-md border border-border bg-sunken px-3 py-2 text-left text-[13px] hover:bg-inset disabled:opacity-50"> | |
| 142 | + {s} | |
| 143 | + </button> | |
| 144 | + ))} | |
| 145 | + </div> | |
| 146 | + </div> | |
| 147 | + ) : null} | |
| 148 | + {turns.map((t) => ( | |
| 149 | + <div key={t.id} className={cn('flex', t.role === 'user' ? 'justify-end' : 'justify-start')}> | |
| 150 | + <div className={cn('max-w-[92%] rounded-lg px-3.5 py-2.5 text-sm', t.role === 'user' ? 'bg-accent text-accent-fg' : 'bg-sunken')}> | |
| 151 | + {t.tools.length ? ( | |
| 152 | + <ul className="mb-2 space-y-1"> | |
| 153 | + {t.tools.map((tool) => ( | |
| 154 | + <ToolRow key={tool.id} tool={tool} /> | |
| 155 | + ))} | |
| 156 | + </ul> | |
| 157 | + ) : null} | |
| 158 | + {t.role === 'assistant' ? <Markdown text={t.content} /> : <p className="whitespace-pre-wrap">{t.content}</p>} | |
| 159 | + {t.streaming && !t.content ? <Loader2 className="mt-1 h-4 w-4 animate-spin text-muted" /> : null} | |
| 160 | + {t.error ? <p className="mt-2 rounded-md bg-loss-bg px-2 py-1 text-xs text-loss">{t.error}</p> : null} | |
| 161 | + {t.role === 'assistant' && !t.streaming && t.usdEst !== undefined ? <p className="mt-2 text-[10px] text-subtle">Grounded in {t.tools.length} quer{t.tools.length === 1 ? 'y' : 'ies'} · est. cost ${t.usdEst.toFixed(4)}</p> : null} | |
| 162 | + </div> | |
| 163 | + </div> | |
| 164 | + ))} | |
| 165 | + <div ref={bottom} /> | |
| 166 | + </div> | |
| 167 | + <form | |
| 168 | + className="flex items-end gap-2 border-t border-border p-3" | |
| 169 | + onSubmit={(e) => { | |
| 170 | + e.preventDefault(); | |
| 171 | + void send(input); | |
| 172 | + }} | |
| 173 | + > | |
| 174 | + <textarea | |
| 175 | + value={input} | |
| 176 | + onChange={(e) => setInput(e.target.value)} | |
| 177 | + onKeyDown={(e) => { | |
| 178 | + if (e.key === 'Enter' && !e.shiftKey) { | |
| 179 | + e.preventDefault(); | |
| 180 | + void send(input); | |
| 181 | + } | |
| 182 | + }} | |
| 183 | + rows={2} | |
| 184 | + placeholder={aiReady ? 'Ask about an asset, a category, an index, movers or a screen…' : 'AI provider not configured'} | |
| 185 | + disabled={!aiReady} | |
| 186 | + className="min-h-[44px] flex-1 resize-none rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" | |
| 187 | + /> | |
| 188 | + <button type="submit" disabled={!aiReady || busy || !input.trim()} aria-label="Send" className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-accent text-accent-fg disabled:opacity-50"> | |
| 189 | + {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />} | |
| 190 | + </button> | |
| 191 | + </form> | |
| 192 | + <p className="border-t border-border px-4 py-2 text-[11px] text-subtle">Analytical data, not investment advice. Valuations are estimates with confidence; listing prices are not confirmed transactions.</p> | |
| 193 | + </section> | |
| 194 | + </div> | |
| 195 | + ); | |
| 196 | +} | |
| 197 | + | |
| 198 | +function ToolRow({ tool }: { tool: ToolTrace }) { | |
| 199 | + const [open, setOpen] = useState(false); | |
| 200 | + const args = tool.input && typeof tool.input === 'object' ? Object.entries(tool.input as Record<string, unknown>).filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ') : ''; | |
| 201 | + return ( | |
| 202 | + <li className="rounded-md border border-border bg-elevated text-[11px]"> | |
| 203 | + <button type="button" onClick={() => setOpen(!open)} className="flex w-full items-center gap-2 px-2 py-1 text-left font-mono"> | |
| 204 | + <span className={cn('h-1.5 w-1.5 rounded-full', tool.ok === undefined ? 'animate-pulse bg-subtle' : tool.ok ? 'bg-gain' : 'bg-loss')} /> | |
| 205 | + <span className="truncate">Queried: {tool.name}({args})</span> | |
| 206 | + <span className="ml-auto shrink-0 text-subtle">{tool.summary ?? (tool.ok === undefined ? '…' : '')}{tool.ms !== undefined ? ` · ${tool.ms} ms` : ''}</span> | |
| 207 | + <ChevronDown className={cn('h-3 w-3 shrink-0 text-subtle transition-transform', open && 'rotate-180')} /> | |
| 208 | + </button> | |
| 209 | + {open && tool.preview !== undefined ? <pre className="max-h-48 overflow-auto border-t border-border px-2 py-1 font-mono text-[10px] text-muted">{JSON.stringify(tool.preview, null, 1)}</pre> : null} | |
| 210 | + </li> | |
| 211 | + ); | |
| 212 | +} | |
added
apps/web/src/components/scanner/scanner-client.tsx
+350 −0
@@ -0,0 +1,350 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useCallback, useRef, useState } from 'react'; | |
| 5 | +import { Camera, ImagePlus, Link2, Loader2, Type, X } from 'lucide-react'; | |
| 6 | +import { cn, fmtMoney, fmtPct, fmtDate, confidenceLabel } from '@/lib/format'; | |
| 7 | +import { Badge, Card, CardHeader, EmptyState, Stat } from '@/components/ui/primitives'; | |
| 8 | + | |
| 9 | +type Mode = 'photo' | 'url' | 'text'; | |
| 10 | + | |
| 11 | +interface Candidate { | |
| 12 | + assetId: string; | |
| 13 | + slug: string; | |
| 14 | + title: string; | |
| 15 | + categorySlug: string; | |
| 16 | + heroImageUrl: string | null; | |
| 17 | + year: number | null; | |
| 18 | + setName: string | null; | |
| 19 | + number: string | null; | |
| 20 | + score: number; | |
| 21 | + rivUsd: number | null; | |
| 22 | + rivLowUsd: number | null; | |
| 23 | + rivHighUsd: number | null; | |
| 24 | + rivConfidence: number | null; | |
| 25 | + rivSampleSize: number; | |
| 26 | + latestSaleUsd: number | null; | |
| 27 | + latestSaleAt: string | null; | |
| 28 | + salesCount: number; | |
| 29 | + activeListings: number; | |
| 30 | + minAskUsd: number | null; | |
| 31 | +} | |
| 32 | +interface ScanResponse { | |
| 33 | + sessionId: string; | |
| 34 | + mode: Mode; | |
| 35 | + guess: Record<string, unknown> & { confidence?: number; rationale?: string; warnings?: string[]; likelyGradeRange?: string | null; conditionNotes?: string | null } | null; | |
| 36 | + guessConfidence: number | null; | |
| 37 | + candidates: Candidate[]; | |
| 38 | + best: (Candidate & { context: { sales: Array<Record<string, unknown>>; listings: Array<Record<string, unknown>>; variants: Array<Record<string, unknown>> } }) | null; | |
| 39 | + listing: { sourceId: string; sourceUrl: string; rawTitle: string; price: number | null; currency: string | null; grader: string | null; grade: string | null; imageUrls: string[]; discountToRiv: number | null; verdict: string } | null; | |
| 40 | + model: string | null; | |
| 41 | + usdEst: number; | |
| 42 | + durationMs: number; | |
| 43 | + notes: string[]; | |
| 44 | + quota: { used: number; limit: number }; | |
| 45 | + error?: string; | |
| 46 | +} | |
| 47 | + | |
| 48 | +async function downscale(file: File, max = 1600, quality = 0.86): Promise<{ data: string; mediaType: 'image/jpeg'; thumb: string }> { | |
| 49 | + const bitmap = await createImageBitmap(file); | |
| 50 | + const scale = Math.min(1, max / Math.max(bitmap.width, bitmap.height)); | |
| 51 | + const w = Math.round(bitmap.width * scale); | |
| 52 | + const h = Math.round(bitmap.height * scale); | |
| 53 | + const canvas = document.createElement('canvas'); | |
| 54 | + canvas.width = w; | |
| 55 | + canvas.height = h; | |
| 56 | + const ctx = canvas.getContext('2d')!; | |
| 57 | + ctx.drawImage(bitmap, 0, 0, w, h); | |
| 58 | + const dataUrl = canvas.toDataURL('image/jpeg', quality); | |
| 59 | + const tscale = Math.min(1, 240 / Math.max(w, h)); | |
| 60 | + const tc = document.createElement('canvas'); | |
| 61 | + tc.width = Math.round(w * tscale); | |
| 62 | + tc.height = Math.round(h * tscale); | |
| 63 | + tc.getContext('2d')!.drawImage(canvas, 0, 0, tc.width, tc.height); | |
| 64 | + return { data: dataUrl.split(',')[1]!, mediaType: 'image/jpeg', thumb: tc.toDataURL('image/jpeg', 0.7) }; | |
| 65 | +} | |
| 66 | + | |
| 67 | +export function ScannerClient({ aiReady }: { aiReady: boolean }) { | |
| 68 | + const [mode, setMode] = useState<Mode>('photo'); | |
| 69 | + const [images, setImages] = useState<Array<{ data: string; mediaType: 'image/jpeg'; thumb: string; name: string }>>([]); | |
| 70 | + const [url, setUrl] = useState(''); | |
| 71 | + const [text, setText] = useState(''); | |
| 72 | + const [busy, setBusy] = useState(false); | |
| 73 | + const [error, setError] = useState<string | null>(null); | |
| 74 | + const [result, setResult] = useState<ScanResponse | null>(null); | |
| 75 | + const [chosen, setChosen] = useState<string | null>(null); | |
| 76 | + const fileRef = useRef<HTMLInputElement>(null); | |
| 77 | + const camRef = useRef<HTMLInputElement>(null); | |
| 78 | + | |
| 79 | + const addFiles = useCallback(async (files: FileList | null) => { | |
| 80 | + if (!files) return; | |
| 81 | + const next = [...images]; | |
| 82 | + for (const f of Array.from(files).slice(0, 5 - next.length)) { | |
| 83 | + if (!f.type.startsWith('image/')) continue; | |
| 84 | + const d = await downscale(f); | |
| 85 | + next.push({ ...d, name: f.name }); | |
| 86 | + } | |
| 87 | + setImages(next.slice(0, 5)); | |
| 88 | + }, [images]); | |
| 89 | + | |
| 90 | + async function submit() { | |
| 91 | + setBusy(true); | |
| 92 | + setError(null); | |
| 93 | + setResult(null); | |
| 94 | + setChosen(null); | |
| 95 | + try { | |
| 96 | + const res = await fetch('/api/scanner', { | |
| 97 | + method: 'POST', | |
| 98 | + headers: { 'content-type': 'application/json' }, | |
| 99 | + body: JSON.stringify({ | |
| 100 | + mode, | |
| 101 | + images: mode === 'photo' ? images.map((i) => ({ data: i.data, mediaType: i.mediaType })) : undefined, | |
| 102 | + thumbnails: mode === 'photo' ? images.slice(0, 3).map((i) => i.thumb) : undefined, | |
| 103 | + url: mode === 'url' ? url.trim() : undefined, | |
| 104 | + text: mode === 'text' ? text.trim() : undefined, | |
| 105 | + }), | |
| 106 | + }); | |
| 107 | + const json = (await res.json()) as ScanResponse; | |
| 108 | + if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`); | |
| 109 | + setResult(json); | |
| 110 | + } catch (err) { | |
| 111 | + setError(err instanceof Error ? err.message : String(err)); | |
| 112 | + } finally { | |
| 113 | + setBusy(false); | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + async function choose(assetId: string | null) { | |
| 118 | + if (!result) return; | |
| 119 | + setChosen(assetId); | |
| 120 | + await fetch(`/api/scanner/${result.sessionId}/choose`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }) }).catch(() => {}); | |
| 121 | + } | |
| 122 | + | |
| 123 | + const canSubmit = !busy && aiReady !== false && ((mode === 'photo' && images.length > 0) || (mode === 'url' && /^https?:\/\//.test(url.trim())) || (mode === 'text' && text.trim().length > 3)); | |
| 124 | + | |
| 125 | + return ( | |
| 126 | + <div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_1fr]"> | |
| 127 | + <Card className="self-start"> | |
| 128 | + <div className="flex border-b border-border"> | |
| 129 | + {( | |
| 130 | + [ | |
| 131 | + ['photo', 'Photos', Camera], | |
| 132 | + ['url', 'URL', Link2], | |
| 133 | + ['text', 'Describe', Type], | |
| 134 | + ] as const | |
| 135 | + ).map(([m, label, Icon]) => ( | |
| 136 | + <button key={m} type="button" onClick={() => setMode(m)} className={cn('flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-[13px] font-medium', mode === m ? 'border-b-2 border-accent text-fg' : 'text-muted hover:text-fg')}> | |
| 137 | + <Icon className="h-3.5 w-3.5" /> {label} | |
| 138 | + </button> | |
| 139 | + ))} | |
| 140 | + </div> | |
| 141 | + <div className="space-y-3 p-4"> | |
| 142 | + {!aiReady ? <p className="rounded-md bg-alert-bg px-3 py-2 text-xs text-alert">AI provider not configured on this server. URL parsing still works; identification is disabled.</p> : null} | |
| 143 | + {mode === 'photo' ? ( | |
| 144 | + <> | |
| 145 | + <div | |
| 146 | + className="flex min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border-strong bg-sunken p-4 text-center" | |
| 147 | + onDragOver={(e) => e.preventDefault()} | |
| 148 | + onDrop={(e) => { | |
| 149 | + e.preventDefault(); | |
| 150 | + void addFiles(e.dataTransfer.files); | |
| 151 | + }} | |
| 152 | + > | |
| 153 | + <p className="text-xs text-muted">Drop up to 5 photos: front, back, label, seal, serial.</p> | |
| 154 | + <div className="flex gap-2"> | |
| 155 | + <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset" onClick={() => fileRef.current?.click()}> | |
| 156 | + <ImagePlus className="h-3.5 w-3.5" /> Upload | |
| 157 | + </button> | |
| 158 | + <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset md:hidden" onClick={() => camRef.current?.click()}> | |
| 159 | + <Camera className="h-3.5 w-3.5" /> Take photo | |
| 160 | + </button> | |
| 161 | + </div> | |
| 162 | + <input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void addFiles(e.target.files)} /> | |
| 163 | + <input ref={camRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => void addFiles(e.target.files)} /> | |
| 164 | + </div> | |
| 165 | + {images.length ? ( | |
| 166 | + <ul className="grid grid-cols-5 gap-2"> | |
| 167 | + {images.map((img, i) => ( | |
| 168 | + <li key={i} className="relative aspect-square overflow-hidden rounded-sm border border-border bg-inset"> | |
| 169 | + {/* eslint-disable-next-line @next/next/no-img-element */} | |
| 170 | + <img src={img.thumb} alt={img.name} className="h-full w-full object-cover" /> | |
| 171 | + <button type="button" aria-label="Remove" className="absolute right-0.5 top-0.5 rounded-sm bg-black/60 p-0.5 text-white" onClick={() => setImages(images.filter((_, j) => j !== i))}> | |
| 172 | + <X className="h-3 w-3" /> | |
| 173 | + </button> | |
| 174 | + </li> | |
| 175 | + ))} | |
| 176 | + </ul> | |
| 177 | + ) : null} | |
| 178 | + </> | |
| 179 | + ) : mode === 'url' ? ( | |
| 180 | + <label className="block text-xs text-muted"> | |
| 181 | + Marketplace or auction URL | |
| 182 | + <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…" className="mt-1 w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" /> | |
| 183 | + <span className="mt-1 block text-[11px] text-subtle">Supported sources are those with a lookup-capable connector; others fall back to text identification.</span> | |
| 184 | + </label> | |
| 185 | + ) : ( | |
| 186 | + <label className="block text-xs text-muted"> | |
| 187 | + Describe the item | |
| 188 | + <textarea value={text} onChange={(e) => setText(e.target.value)} rows={5} placeholder="e.g. 1999 Pokémon Base Set Charizard holo, 1st edition stamp, PSA 9 slab #12345678" className="mt-1 w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" /> | |
| 189 | + </label> | |
| 190 | + )} | |
| 191 | + <button type="button" disabled={!canSubmit} onClick={() => void submit()} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-fg disabled:opacity-50"> | |
| 192 | + {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {busy ? 'Identifying…' : 'Identify & price'} | |
| 193 | + </button> | |
| 194 | + {error ? <p className="rounded-md bg-loss-bg px-3 py-2 text-xs text-loss">{error}</p> : null} | |
| 195 | + <p className="text-[11px] leading-relaxed text-subtle">Photos are downscaled in your browser before upload and are not stored; small thumbnails are kept for quality review. Valuations are estimates, not offers, appraisals or authentication.</p> | |
| 196 | + </div> | |
| 197 | + </Card> | |
| 198 | + | |
| 199 | + <div className="min-w-0 space-y-4"> | |
| 200 | + {!result && !busy ? <EmptyState title="Results appear here" description="Identification, the closest catalog matches, RareIndex Valuation, comparable sales and live listings." /> : null} | |
| 201 | + {busy ? ( | |
| 202 | + <Card className="p-6"> | |
| 203 | + <div className="flex items-center gap-3 text-sm text-muted"> | |
| 204 | + <Loader2 className="h-4 w-4 animate-spin" /> Analysing{mode === 'photo' ? ' photos' : mode === 'url' ? ' the listing' : ' the description'} and searching the catalog… | |
| 205 | + </div> | |
| 206 | + </Card> | |
| 207 | + ) : null} | |
| 208 | + {result ? <ScanResults r={result} chosen={chosen} onChoose={choose} /> : null} | |
| 209 | + </div> | |
| 210 | + </div> | |
| 211 | + ); | |
| 212 | +} | |
| 213 | + | |
| 214 | +function ScanResults({ r, chosen, onChoose }: { r: ScanResponse; chosen: string | null; onChoose: (id: string | null) => void }) { | |
| 215 | + const g = r.guess; | |
| 216 | + const best = r.best; | |
| 217 | + return ( | |
| 218 | + <> | |
| 219 | + {g ? ( | |
| 220 | + <Card> | |
| 221 | + <CardHeader | |
| 222 | + title="Identification" | |
| 223 | + subtitle={r.model ? `Model ${r.model} · ${(r.durationMs / 1000).toFixed(1)} s` : undefined} | |
| 224 | + action={<Badge tone={(g.confidence ?? 0) >= 0.75 ? 'gain' : (g.confidence ?? 0) >= 0.5 ? 'alert' : 'loss'}>Confidence {confidenceLabel(g.confidence)} · {Math.round((g.confidence ?? 0) * 100)}%</Badge>} | |
| 225 | + /> | |
| 226 | + <div className="grid gap-x-6 gap-y-2 p-4 text-sm sm:grid-cols-2"> | |
| 227 | + {( | |
| 228 | + [ | |
| 229 | + ['Category', g.categorySlug], | |
| 230 | + ['Name', g.name], | |
| 231 | + ['Brand / franchise', [g.brand, g.franchise].filter(Boolean).join(' · ')], | |
| 232 | + ['Set', g.set], | |
| 233 | + ['Number / reference', g.number], | |
| 234 | + ['Year', g.year], | |
| 235 | + ['Variant', g.variant], | |
| 236 | + ['Language', g.language], | |
| 237 | + ['Grade', g.grader ? `${String(g.grader).toUpperCase()} ${g.grade ?? ''}`.trim() : null], | |
| 238 | + ['Certification #', g.certificationNumber], | |
| 239 | + ['Likely grade range', g.likelyGradeRange], | |
| 240 | + ['Condition notes', g.conditionNotes], | |
| 241 | + ] as Array<[string, unknown]> | |
| 242 | + ).map(([k, v]) => ( | |
| 243 | + <div key={k} className="flex justify-between gap-3 border-b border-border py-1 last:border-0"> | |
| 244 | + <span className="text-xs text-subtle">{k}</span> | |
| 245 | + <span className="text-right">{v === null || v === undefined || v === '' ? <span className="text-subtle">—</span> : String(v)}</span> | |
| 246 | + </div> | |
| 247 | + ))} | |
| 248 | + </div> | |
| 249 | + {g.rationale ? <p className="border-t border-border px-4 py-3 text-xs text-muted">{g.rationale}</p> : null} | |
| 250 | + {g.warnings?.length ? ( | |
| 251 | + <ul className="border-t border-border px-4 py-3 text-xs text-alert"> | |
| 252 | + {g.warnings.map((w) => ( | |
| 253 | + <li key={w}>⚠ {w}</li> | |
| 254 | + ))} | |
| 255 | + </ul> | |
| 256 | + ) : null} | |
| 257 | + </Card> | |
| 258 | + ) : null} | |
| 259 | + | |
| 260 | + {r.listing ? ( | |
| 261 | + <Card> | |
| 262 | + <CardHeader title="Parsed listing" subtitle={r.listing.sourceId} action={<a href={r.listing.sourceUrl} target="_blank" rel="noreferrer noopener" className="text-muted hover:text-fg">Open source ↗</a>} /> | |
| 263 | + <div className="grid gap-4 p-4 sm:grid-cols-3"> | |
| 264 | + <Stat label="Asking price" value={r.listing.price !== null ? fmtMoney(r.listing.price, r.listing.currency ?? 'USD') : '—'} sub={r.listing.rawTitle} /> | |
| 265 | + <Stat label="vs RareIndex Valuation" value={r.listing.discountToRiv !== null ? <span className={r.listing.discountToRiv < 0 ? 'text-gain' : 'text-loss'}>{fmtPct(r.listing.discountToRiv, 1)}</span> : '—'} sub={{ below_fair_value: 'Below fair value range', in_range: 'Within fair value range', above_fair_value: 'Above fair value range', unknown: 'No valuation to compare' }[r.listing.verdict]} /> | |
| 266 | + <Stat label="Grade on listing" value={r.listing.grader ? `${r.listing.grader.toUpperCase()} ${r.listing.grade ?? ''}` : 'Raw / unknown'} /> | |
| 267 | + </div> | |
| 268 | + </Card> | |
| 269 | + ) : null} | |
| 270 | + | |
| 271 | + {best ? ( | |
| 272 | + <Card> | |
| 273 | + <CardHeader title="Best match" subtitle={`Match score ${Math.round(best.score * 100)}%`} action={<Link href={`/asset/${best.slug}`} className="font-medium text-fg hover:underline">Open asset →</Link>} /> | |
| 274 | + <div className="flex gap-4 p-4"> | |
| 275 | + {best.heroImageUrl ? ( | |
| 276 | + // eslint-disable-next-line @next/next/no-img-element | |
| 277 | + <img src={best.heroImageUrl} alt="" className="h-28 w-20 shrink-0 rounded-sm object-cover" /> | |
| 278 | + ) : null} | |
| 279 | + <div className="min-w-0 flex-1"> | |
| 280 | + <p className="text-sm font-semibold">{best.title}</p> | |
| 281 | + <p className="text-xs text-muted">{[best.setName, best.number, best.year].filter(Boolean).join(' · ')}</p> | |
| 282 | + <div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4"> | |
| 283 | + <Stat label="RareIndex Valuation" value={best.rivUsd ? fmtMoney(best.rivUsd) : '—'} sub={best.rivUsd ? `${fmtMoney(best.rivLowUsd)} – ${fmtMoney(best.rivHighUsd)} · ${confidenceLabel(best.rivConfidence)} · ${best.rivSampleSize} sales` : 'Insufficient evidence'} /> | |
| 284 | + <Stat label="Latest sale" value={best.latestSaleUsd ? fmtMoney(best.latestSaleUsd) : '—'} sub={best.latestSaleAt ? fmtDate(best.latestSaleAt) : undefined} /> | |
| 285 | + <Stat label="Sales tracked" value={best.salesCount} /> | |
| 286 | + <Stat label="Active listings" value={best.activeListings} sub={best.minAskUsd ? `from ${fmtMoney(best.minAskUsd)}` : undefined} /> | |
| 287 | + </div> | |
| 288 | + </div> | |
| 289 | + </div> | |
| 290 | + {best.context.sales.length ? ( | |
| 291 | + <div className="border-t border-border"> | |
| 292 | + <p className="px-4 pt-3 text-[11px] font-semibold uppercase tracking-wider text-subtle">Recent comparable sales</p> | |
| 293 | + <ul className="divide-y divide-border px-4 pb-2 text-xs"> | |
| 294 | + {best.context.sales.map((s) => ( | |
| 295 | + <li key={String(s.id)} className="flex items-center justify-between gap-3 py-1.5"> | |
| 296 | + <span className="truncate text-muted">{fmtDate(String(s.saleDate))} · {String(s.sourceId)} {s.grader ? `· ${String(s.grader).toUpperCase()} ${String(s.grade ?? '')}` : ''}</span> | |
| 297 | + <a href={String(s.sourceUrl)} target="_blank" rel="noreferrer noopener" className="num font-medium hover:underline">{fmtMoney(Number(s.priceUsd))}</a> | |
| 298 | + </li> | |
| 299 | + ))} | |
| 300 | + </ul> | |
| 301 | + </div> | |
| 302 | + ) : null} | |
| 303 | + {best.context.listings.length ? ( | |
| 304 | + <div className="border-t border-border"> | |
| 305 | + <p className="px-4 pt-3 text-[11px] font-semibold uppercase tracking-wider text-subtle">Live listings (asks, not sales)</p> | |
| 306 | + <ul className="divide-y divide-border px-4 pb-2 text-xs"> | |
| 307 | + {best.context.listings.map((l) => ( | |
| 308 | + <li key={String(l.id)} className="flex items-center justify-between gap-3 py-1.5"> | |
| 309 | + <span className="truncate text-muted">{String(l.sourceId)} · {String(l.rawTitle)}</span> | |
| 310 | + <a href={String(l.sourceUrl)} target="_blank" rel="noreferrer noopener" className="num font-medium hover:underline">{fmtMoney(Number(l.priceUsd))}</a> | |
| 311 | + </li> | |
| 312 | + ))} | |
| 313 | + </ul> | |
| 314 | + </div> | |
| 315 | + ) : null} | |
| 316 | + <div className="flex items-center justify-between border-t border-border px-4 py-2 text-xs"> | |
| 317 | + <span className="text-subtle">Is this the right item?</span> | |
| 318 | + <div className="flex gap-2"> | |
| 319 | + <button type="button" onClick={() => onChoose(best.assetId)} className={cn('rounded-md border px-2 py-1', chosen === best.assetId ? 'border-gain bg-gain-bg text-gain' : 'border-border hover:bg-inset')}>Yes</button> | |
| 320 | + <button type="button" onClick={() => onChoose(null)} className={cn('rounded-md border px-2 py-1', chosen === null && chosen !== undefined && r.candidates.length ? 'border-border hover:bg-inset' : 'border-border hover:bg-inset')}>Not this</button> | |
| 321 | + </div> | |
| 322 | + </div> | |
| 323 | + </Card> | |
| 324 | + ) : null} | |
| 325 | + | |
| 326 | + {r.candidates.length > (best ? 1 : 0) ? ( | |
| 327 | + <Card> | |
| 328 | + <CardHeader title="Other candidates" subtitle="Pick the correct one to improve future identifications" /> | |
| 329 | + <ul className="divide-y divide-border"> | |
| 330 | + {r.candidates.filter((c) => c.assetId !== best?.assetId).map((c) => ( | |
| 331 | + <li key={c.assetId} className="flex items-center gap-3 px-4 py-2 text-sm"> | |
| 332 | + <Link href={`/asset/${c.slug}`} className="min-w-0 flex-1 truncate hover:underline">{c.title}</Link> | |
| 333 | + <span className="num text-xs text-muted">{c.rivUsd ? fmtMoney(c.rivUsd) : '—'}</span> | |
| 334 | + <span className="num text-xs text-subtle">{Math.round(c.score * 100)}%</span> | |
| 335 | + <button type="button" onClick={() => onChoose(c.assetId)} className={cn('rounded-md border px-2 py-0.5 text-xs', chosen === c.assetId ? 'border-gain bg-gain-bg text-gain' : 'border-border hover:bg-inset')}>This one</button> | |
| 336 | + </li> | |
| 337 | + ))} | |
| 338 | + </ul> | |
| 339 | + </Card> | |
| 340 | + ) : null} | |
| 341 | + | |
| 342 | + <ul className="space-y-1 text-[11px] leading-relaxed text-subtle"> | |
| 343 | + {r.notes.map((n) => ( | |
| 344 | + <li key={n}>· {n}</li> | |
| 345 | + ))} | |
| 346 | + <li>· Scans today: {r.quota.used}/{r.quota.limit}.</li> | |
| 347 | + </ul> | |
| 348 | + </> | |
| 349 | + ); | |
| 350 | +} | |
added
apps/web/src/lib/admin/actions.ts
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { cookies } from 'next/headers'; | |
| 4 | +import { redirect } from 'next/navigation'; | |
| 5 | +import { revalidatePath } from 'next/cache'; | |
| 6 | +import { z } from 'zod'; | |
| 7 | +import { getDb, connectors, connectorRuns, sales, normalizedRecords, taxonomyProposals, categories, auditLog, events, eq, sql } from '@rareindex/database'; | |
| 8 | +import { newId } from '@rareindex/shared'; | |
| 9 | +import { ADMIN_COOKIE, adminCookieValue, isAdmin, tokenMatches } from './auth'; | |
| 10 | +import { enqueue, QUEUES } from './queue'; | |
| 11 | + | |
| 12 | +async function guard(): Promise<void> { | |
| 13 | + if (!(await isAdmin())) throw new Error('Admin authentication required'); | |
| 14 | +} | |
| 15 | + | |
| 16 | +async function audit(entityType: string, entityId: string, action: string, reason: string, details: Record<string, unknown> = {}) { | |
| 17 | + await getDb().insert(auditLog).values({ id: newId('event'), entityType, entityId, action, reason, actor: 'admin', details }); | |
| 18 | +} | |
| 19 | + | |
| 20 | +export async function adminLogin(formData: FormData): Promise<void> { | |
| 21 | + const token = String(formData.get('token') ?? ''); | |
| 22 | + const next = String(formData.get('next') ?? '/admin'); | |
| 23 | + if (!process.env.ADMIN_TOKEN) redirect('/admin/login?error=ADMIN_TOKEN+is+not+set+on+the+server'); | |
| 24 | + if (!tokenMatches(token)) redirect('/admin/login?error=Invalid+token'); | |
| 25 | + const jar = await cookies(); | |
| 26 | + jar.set(ADMIN_COOKIE, adminCookieValue()!, { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 12 }); | |
| 27 | + redirect(next.startsWith('/admin') ? next : '/admin'); | |
| 28 | +} | |
| 29 | + | |
| 30 | +export async function adminLogout(): Promise<void> { | |
| 31 | + const jar = await cookies(); | |
| 32 | + jar.delete(ADMIN_COOKIE); | |
| 33 | + redirect('/admin/login'); | |
| 34 | +} | |
| 35 | + | |
| 36 | +const ConnectorAction = z.enum(['run', 'probe', 'recrawl', 'retry', 'pause', 'resume']); | |
| 37 | + | |
| 38 | +/** Connector control-center actions (§144): run/recrawl/probe enqueue `crawl.run`; pause/resume flip status. */ | |
| 39 | +export async function connectorAction(formData: FormData): Promise<void> { | |
| 40 | + await guard(); | |
| 41 | + const id = String(formData.get('id') ?? ''); | |
| 42 | + const action = ConnectorAction.parse(formData.get('action')); | |
| 43 | + const db = getDb(); | |
| 44 | + const [c] = await db.select().from(connectors).where(eq(connectors.id, id)).limit(1); | |
| 45 | + if (!c) throw new Error(`unknown connector ${id}`); | |
| 46 | + if (action === 'pause' || action === 'resume') { | |
| 47 | + await db.update(connectors).set({ status: action === 'pause' ? 'paused' : 'active', updatedAt: new Date() }).where(eq(connectors.id, id)); | |
| 48 | + await audit('connector', id, action, `admin ${action}`); | |
| 49 | + } else { | |
| 50 | + const mode = action === 'recrawl' ? 'backfill' : action === 'probe' ? 'probe' : 'incremental'; | |
| 51 | + const payload: Record<string, unknown> = { connectorId: id, mode, trigger: action === 'retry' ? 'retry' : 'manual', requestedAt: new Date().toISOString() }; | |
| 52 | + if (action === 'probe') payload.limit = 25; | |
| 53 | + if (action === 'retry') { | |
| 54 | + const [last] = await db.select().from(connectorRuns).where(eq(connectorRuns.connectorId, id)).orderBy(sql`started_at desc`).limit(1); | |
| 55 | + if (last) payload.cursor = last.cursor; | |
| 56 | + } | |
| 57 | + const jobId = await enqueue(QUEUES.crawlRun, payload, { singletonKey: `${id}:${mode}`, priority: 10 }); | |
| 58 | + await db.insert(events).values({ id: newId('event'), type: 'crawl_requested', entityType: 'connector', entityId: id, payload: { ...payload, jobId } }); | |
| 59 | + await audit('connector', id, action, `admin enqueued crawl.run (${mode})`, { jobId }); | |
| 60 | + } | |
| 61 | + revalidatePath('/admin/connectors'); | |
| 62 | + revalidatePath(`/admin/connectors/${id}`); | |
| 63 | +} | |
| 64 | + | |
| 65 | +export async function updateConnectorConfig(formData: FormData): Promise<void> { | |
| 66 | + await guard(); | |
| 67 | + const id = String(formData.get('id') ?? ''); | |
| 68 | + const raw = String(formData.get('config') ?? '{}'); | |
| 69 | + let config: Record<string, unknown>; | |
| 70 | + try { | |
| 71 | + config = z.record(z.string(), z.unknown()).parse(JSON.parse(raw)); | |
| 72 | + } catch (err) { | |
| 73 | + throw new Error(`config must be a JSON object: ${err instanceof Error ? err.message : String(err)}`); | |
| 74 | + } | |
| 75 | + const refresh = Number(formData.get('refresh') ?? NaN); | |
| 76 | + const priority = String(formData.get('priority') ?? 'medium'); | |
| 77 | + await getDb() | |
| 78 | + .update(connectors) | |
| 79 | + .set({ config, ...(Number.isFinite(refresh) && refresh > 0 ? { refreshFrequencyMinutes: Math.round(refresh) } : {}), ...(['high', 'medium', 'low'].includes(priority) ? { priority } : {}), updatedAt: new Date() }) | |
| 80 | + .where(eq(connectors.id, id)); | |
| 81 | + await audit('connector', id, 'edited', 'admin updated config/schedule', { config, refresh, priority }); | |
| 82 | + revalidatePath(`/admin/connectors/${id}`); | |
| 83 | +} | |
| 84 | + | |
| 85 | +export async function saleStatusAction(formData: FormData): Promise<void> { | |
| 86 | + await guard(); | |
| 87 | + const id = String(formData.get('id') ?? ''); | |
| 88 | + const status = z.enum(['valid', 'flagged', 'excluded']).parse(formData.get('status')); | |
| 89 | + const reason = String(formData.get('reason') ?? '').trim() || `admin set status ${status}`; | |
| 90 | + const db = getDb(); | |
| 91 | + const [s] = await db.select({ status: sales.status, flags: sales.flags }).from(sales).where(eq(sales.id, id)).limit(1); | |
| 92 | + if (!s) throw new Error('sale not found'); | |
| 93 | + const flags = status === 'valid' ? s.flags.filter((f) => !f.startsWith('admin:')) : [...new Set([...s.flags, `admin:${status}`])]; | |
| 94 | + await db.update(sales).set({ status, flags }).where(eq(sales.id, id)); | |
| 95 | + await audit('sale', id, status === 'valid' ? 'restored' : status, reason, { from: s.status, to: status }); | |
| 96 | + revalidatePath('/admin/data-quality'); | |
| 97 | +} | |
| 98 | + | |
| 99 | +export async function manualMatchAction(formData: FormData): Promise<void> { | |
| 100 | + await guard(); | |
| 101 | + const recordId = String(formData.get('recordId') ?? ''); | |
| 102 | + const assetId = String(formData.get('assetId') ?? ''); | |
| 103 | + const db = getDb(); | |
| 104 | + if (!assetId) { | |
| 105 | + await db.update(normalizedRecords).set({ status: 'rejected', rejectReason: 'admin: rejected', processedAt: new Date() }).where(eq(normalizedRecords.id, recordId)); | |
| 106 | + await audit('normalized_record', recordId, 'rejected', 'admin rejected unmatched record'); | |
| 107 | + } else { | |
| 108 | + await db.update(normalizedRecords).set({ status: 'matched', assetId, matchMethod: 'manual', matchConfidence: 1, rejectReason: null, processedAt: null }).where(eq(normalizedRecords.id, recordId)); | |
| 109 | + await audit('normalized_record', recordId, 'merged', 'admin manual match', { assetId }); | |
| 110 | + await enqueue(QUEUES.normalize, { normalizedRecordId: recordId, reason: 'manual_match' }).catch(() => null); | |
| 111 | + } | |
| 112 | + revalidatePath('/admin/data-quality'); | |
| 113 | +} | |
| 114 | + | |
| 115 | +export async function taxonomyDecision(formData: FormData): Promise<void> { | |
| 116 | + await guard(); | |
| 117 | + const id = String(formData.get('id') ?? ''); | |
| 118 | + const decision = z.enum(['approved', 'rejected']).parse(formData.get('decision')); | |
| 119 | + const db = getDb(); | |
| 120 | + const [p] = await db.select().from(taxonomyProposals).where(eq(taxonomyProposals.id, id)).limit(1); | |
| 121 | + if (!p) throw new Error('proposal not found'); | |
| 122 | + if (decision === 'approved') { | |
| 123 | + const parent = String(formData.get('parent') ?? p.parentSlug ?? '') || null; | |
| 124 | + const [parentRow] = parent ? await db.select().from(categories).where(eq(categories.slug, parent)).limit(1) : []; | |
| 125 | + const [{ n }] = (await db.execute(sql`select count(*)::int as n from categories`)) as unknown as [{ n: number }]; | |
| 126 | + await db | |
| 127 | + .insert(categories) | |
| 128 | + .values({ | |
| 129 | + slug: p.proposedSlug, | |
| 130 | + parentSlug: parentRow?.slug ?? null, | |
| 131 | + familySlug: parentRow?.familySlug ?? p.proposedSlug, | |
| 132 | + name: p.name, | |
| 133 | + level: parentRow ? parentRow.level + 1 : 0, | |
| 134 | + phase: 3, | |
| 135 | + conditionScale: parentRow?.conditionScale ?? 'general', | |
| 136 | + graders: parentRow?.graders ?? [], | |
| 137 | + indexTicker: parentRow?.indexTicker ?? null, | |
| 138 | + sortOrder: n + 1, | |
| 139 | + description: `Added from taxonomy proposal ${p.id}`, | |
| 140 | + }) | |
| 141 | + .onConflictDoNothing(); | |
| 142 | + } | |
| 143 | + await db.update(taxonomyProposals).set({ status: decision, decidedBy: 'admin', decidedAt: new Date().toISOString() }).where(eq(taxonomyProposals.id, id)); | |
| 144 | + await audit('taxonomy_proposal', id, decision, `admin ${decision} ${p.proposedSlug}`); | |
| 145 | + revalidatePath('/admin/taxonomy'); | |
| 146 | +} | |
| 147 | + | |
| 148 | +export async function proposeTaxonomyNode(formData: FormData): Promise<void> { | |
| 149 | + await guard(); | |
| 150 | + const slug = String(formData.get('slug') ?? '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_'); | |
| 151 | + const name = String(formData.get('name') ?? '').trim(); | |
| 152 | + const parent = String(formData.get('parent') ?? '').trim() || null; | |
| 153 | + if (!slug || !name) throw new Error('slug and name required'); | |
| 154 | + await getDb().insert(taxonomyProposals).values({ id: newId('event').replace('evt_', 'tp_'), proposedSlug: slug, name, parentSlug: parent, evidence: { source: 'admin' } }); | |
| 155 | + revalidatePath('/admin/taxonomy'); | |
| 156 | +} | |
added
apps/web/src/lib/admin/auth.ts
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { createHmac, timingSafeEqual } from 'node:crypto'; | |
| 3 | +import { cookies } from 'next/headers'; | |
| 4 | +import { redirect } from 'next/navigation'; | |
| 5 | + | |
| 6 | +export const ADMIN_COOKIE = 'ri_admin'; | |
| 7 | + | |
| 8 | +/** Cookie value = HMAC(SESSION_SECRET, ADMIN_TOKEN): the token itself never travels after login. */ | |
| 9 | +export function adminCookieValue(): string | null { | |
| 10 | + const token = process.env.ADMIN_TOKEN; | |
| 11 | + if (!token) return null; | |
| 12 | + return createHmac('sha256', process.env.SESSION_SECRET ?? 'dev-only').update(`admin:${token}`).digest('hex'); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function isValidAdminCookie(value: string | undefined | null): boolean { | |
| 16 | + const expected = adminCookieValue(); | |
| 17 | + if (!expected || !value || value.length !== expected.length) return false; | |
| 18 | + return timingSafeEqual(Buffer.from(value), Buffer.from(expected)); | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function tokenMatches(token: string): boolean { | |
| 22 | + const expected = process.env.ADMIN_TOKEN; | |
| 23 | + if (!expected || token.length !== expected.length) return false; | |
| 24 | + return timingSafeEqual(Buffer.from(token), Buffer.from(expected)); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export async function isAdmin(): Promise<boolean> { | |
| 28 | + const jar = await cookies(); | |
| 29 | + return isValidAdminCookie(jar.get(ADMIN_COOKIE)?.value); | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** Server-component guard (the proxy already blocks unauthenticated requests; this is defence in depth). */ | |
| 33 | +export async function requireAdmin(): Promise<void> { | |
| 34 | + if (!(await isAdmin())) redirect('/admin/login'); | |
| 35 | +} | |
added
apps/web/src/lib/admin/queries.ts
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { getDb, sql } from '@rareindex/database'; | |
| 3 | + | |
| 4 | +type Row = Record<string, unknown>; | |
| 5 | +const run = async <T = Row>(q: ReturnType<typeof sql>): Promise<T[]> => (await getDb().execute(q)) as unknown as T[]; | |
| 6 | +const one = async <T = Row>(q: ReturnType<typeof sql>): Promise<T | null> => (await run<T>(q))[0] ?? null; | |
| 7 | + | |
| 8 | +export async function dashboard() { | |
| 9 | + const counts = await one(sql`select | |
| 10 | + (select count(*)::int from assets) as assets, | |
| 11 | + (select count(*)::int from asset_variants) as variants, | |
| 12 | + (select count(*)::int from sales) as sales, | |
| 13 | + (select count(*)::int from sales where status = 'flagged') as sales_flagged, | |
| 14 | + (select count(*)::int from sales where status = 'excluded') as sales_excluded, | |
| 15 | + (select count(*)::int from listings where availability = 'available') as listings_live, | |
| 16 | + (select count(*)::int from price_observations) as observations, | |
| 17 | + (select count(*)::int from raw_records) as raw_records, | |
| 18 | + (select count(*)::int from raw_records where processed_at is null) as raw_unprocessed, | |
| 19 | + (select count(*)::int from raw_records where process_error is not null) as raw_errors, | |
| 20 | + (select count(*)::int from normalized_records where status = 'pending') as normalized_pending, | |
| 21 | + (select count(*)::int from normalized_records where status = 'unmatched') as normalized_unmatched, | |
| 22 | + (select count(*)::int from connectors where status = 'active') as connectors_active, | |
| 23 | + (select count(*)::int from connectors where status = 'paused') as connectors_paused, | |
| 24 | + (select count(*)::int from connector_health where status in ('failing','degraded')) as connectors_unhealthy, | |
| 25 | + (select count(*)::int from taxonomy_proposals where status = 'pending') as proposals_pending, | |
| 26 | + (select max(sale_date) from sales) as latest_sale, | |
| 27 | + (select max(fetched_at) from raw_records) as latest_fetch, | |
| 28 | + (select max(computed_at) from valuations) as latest_valuation, | |
| 29 | + (select max(date) from index_values) as latest_index, | |
| 30 | + (select coalesce(sum(usd_est),0) from costs where occurred_at >= now() - interval '24 hours') as cost_24h_usd, | |
| 31 | + (select coalesce(sum(credits),0) from costs where occurred_at >= now() - interval '24 hours' and kind = 'firecrawl') as firecrawl_credits_24h, | |
| 32 | + (select coalesce(sum(credits),0) from costs where occurred_at >= now() - interval '24 hours' and kind = 'scrapfly') as scrapfly_credits_24h`); | |
| 33 | + const recentRuns = await run(sql`select r.id, r.connector_id, r.status, r.trigger, r.started_at, r.finished_at, r.pages_attempted, r.pages_success, r.records_raw, r.records_normalized, r.records_duplicate, r.error from connector_runs r order by r.started_at desc limit 12`); | |
| 34 | + const recentEvents = await run(sql`select type, count(*)::int as n from events where created_at >= now() - interval '24 hours' group by type order by n desc`); | |
| 35 | + return { counts, recentRuns, recentEvents }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export async function connectorsOverview() { | |
| 39 | + return run(sql`with last_run as ( | |
| 40 | + select distinct on (connector_id) * from connector_runs order by connector_id, started_at desc), | |
| 41 | + day as ( | |
| 42 | + select connector_id, sum(pages_attempted)::int as pages, sum(records_raw)::int as records, sum(records_duplicate)::int as duplicates, sum(cost_credits)::float as credits, | |
| 43 | + greatest(1, extract(epoch from (max(coalesce(finished_at, now())) - min(started_at))) / 60.0) as minutes | |
| 44 | + from connector_runs where started_at >= now() - interval '24 hours' group by connector_id), | |
| 45 | + conf as ( | |
| 46 | + select connector_id, avg((payload->>'confidence')::float) as parser_confidence, count(*)::int as normalized_24h from normalized_records where created_at >= now() - interval '24 hours' group by connector_id) | |
| 47 | + select c.id, c.display_name, c.source_id, c.status, c.priority, c.engine_priority, c.categories, c.refresh_frequency_minutes, c.schema_version, c.connector_version, c.last_run_at, c.last_success_at, c.next_run_at, | |
| 48 | + h.status as health_status, h.computed_at as health_at, h.health, | |
| 49 | + lr.id as last_run_id, lr.status as last_run_status, lr.error as last_error, lr.started_at as last_started_at, lr.finished_at as last_finished_at, lr.pages_attempted as last_pages, lr.records_raw as last_records, lr.anomalies as last_anomalies, | |
| 50 | + d.pages as pages_24h, d.records as records_24h, d.duplicates as duplicates_24h, d.credits as credits_24h, (coalesce(d.pages,0) / coalesce(d.minutes,1)) as pages_per_min, | |
| 51 | + cf.parser_confidence, cf.normalized_24h | |
| 52 | + from connectors c | |
| 53 | + left join connector_health h on h.connector_id = c.id | |
| 54 | + left join last_run lr on lr.connector_id = c.id | |
| 55 | + left join day d on d.connector_id = c.id | |
| 56 | + left join conf cf on cf.connector_id = c.id | |
| 57 | + order by c.priority = 'high' desc, c.id`); | |
| 58 | +} | |
| 59 | + | |
| 60 | +export async function connectorDetail(id: string) { | |
| 61 | + const connector = await one(sql`select * from connectors where id = ${id}`); | |
| 62 | + if (!connector) return null; | |
| 63 | + const source = await one(sql`select * from sources where id = ${connector.source_id as string}`); | |
| 64 | + const health = await one(sql`select * from connector_health where connector_id = ${id}`); | |
| 65 | + const runs = await run(sql`select * from connector_runs where connector_id = ${id} order by started_at desc limit 25`); | |
| 66 | + const rawSample = await run(sql`select id, kind, url, external_id, engine, fetched_at, http_status, processed_at, process_error, parser_version from raw_records where connector_id = ${id} order by fetched_at desc limit 10`); | |
| 67 | + const normalized = await run(sql`select status, count(*)::int as n from normalized_records where connector_id = ${id} group by status`); | |
| 68 | + const normalizedSample = await run(sql`select id, kind, status, match_method, match_confidence, asset_id, reject_reason, created_at, payload->>'rawTitle' as raw_title, payload->>'price' as price, payload->>'currency' as currency from normalized_records where connector_id = ${id} order by created_at desc limit 10`); | |
| 69 | + const costs = await run(sql`select date_trunc('day', occurred_at)::date as day, kind, sum(credits)::float as credits, sum(usd_est)::float as usd from costs where connector_id = ${id} and occurred_at >= now() - interval '30 days' group by 1, 2 order by 1 desc`); | |
| 70 | + const outputs = await one(sql`select (select count(*)::int from sales where connector_id = ${id}) as sales, (select count(*)::int from listings where connector_id = ${id}) as listings, (select count(*)::int from price_observations where connector_id = ${id}) as observations`); | |
| 71 | + const anomalies = runs.flatMap((r) => (Array.isArray(r.anomalies) ? (r.anomalies as string[]) : [])).slice(0, 30); | |
| 72 | + return { connector, source, health, runs, rawSample, normalized, normalizedSample, costs, outputs, anomalies }; | |
| 73 | +} | |
| 74 | + | |
| 75 | +export async function costsOverview(days = 30) { | |
| 76 | + const byDay = await run(sql`select date_trunc('day', occurred_at)::date as day, kind, sum(credits)::float as credits, sum(usd_est)::float as usd, count(*)::int as events from costs where occurred_at >= now() - (${days}::int || ' days')::interval group by 1, 2 order by 1`); | |
| 77 | + const byKind = await run(sql`select kind, coalesce(provider,'') as provider, sum(credits)::float as credits, sum(usd_est)::float as usd, count(*)::int as events from costs where occurred_at >= now() - (${days}::int || ' days')::interval group by 1, 2 order by usd desc`); | |
| 78 | + const byConnector = await run(sql`select coalesce(connector_id,'—') as connector_id, sum(credits)::float as credits, sum(usd_est)::float as usd, count(*)::int as events from costs where occurred_at >= now() - (${days}::int || ' days')::interval group by 1 order by usd desc, credits desc limit 30`); | |
| 79 | + const byCategory = await run(sql`select coalesce(category_slug,'—') as category_slug, sum(usd_est)::float as usd, sum(credits)::float as credits from costs where occurred_at >= now() - (${days}::int || ' days')::interval group by 1 order by usd desc limit 30`); | |
| 80 | + const byEndpoint = await run(sql`select coalesce(endpoint,'—') as endpoint, coalesce(metadata->>'model','') as model, sum(usd_est)::float as usd, count(*)::int as events, sum((metadata->'usage'->>'inputTokens')::float) as input_tokens, sum((metadata->'usage'->>'outputTokens')::float) as output_tokens from costs where kind = 'ai' and occurred_at >= now() - (${days}::int || ' days')::interval group by 1, 2 order by usd desc limit 30`); | |
| 81 | + const totals = await one(sql`select sum(usd_est)::float as usd, sum(credits) filter (where kind='firecrawl')::float as firecrawl_credits, sum(credits) filter (where kind='scrapfly')::float as scrapfly_credits, sum(usd_est) filter (where kind='ai')::float as ai_usd, count(*)::int as events from costs where occurred_at >= now() - (${days}::int || ' days')::interval`); | |
| 82 | + const perRecord = await one(sql`select (select count(*)::int from raw_records where fetched_at >= now() - (${days}::int || ' days')::interval) as raw_records`); | |
| 83 | + return { byDay, byKind, byConnector, byCategory, byEndpoint, totals, perRecord }; | |
| 84 | +} | |
| 85 | + | |
| 86 | +export async function taxonomyQueue() { | |
| 87 | + const pending = await run(sql`select * from taxonomy_proposals where status = 'pending' order by volume_estimate desc nulls last, created_at`); | |
| 88 | + const decided = await run(sql`select * from taxonomy_proposals where status <> 'pending' order by decided_at desc nulls last limit 30`); | |
| 89 | + const families = await run(sql`select slug, name from categories where level = 0 and active order by sort_order`); | |
| 90 | + return { pending, decided, families }; | |
| 91 | +} | |
| 92 | + | |
| 93 | +export async function dataQuality() { | |
| 94 | + const flagged = await run(sql`select s.id, s.asset_id, a.slug as asset_slug, a.title, s.status, s.flags, s.price, s.currency, s.price_usd, s.sale_date, s.source_id, s.source_url, s.raw_title, s.confidence, s.data_quality, | |
| 95 | + (select reason from audit_log l where l.entity_type = 'sale' and l.entity_id = s.id order by l.created_at desc limit 1) as last_reason | |
| 96 | + from sales s join assets a on a.id = s.asset_id where s.status in ('flagged','excluded') order by s.created_at desc limit 100`); | |
| 97 | + const unmatched = await run(sql`select n.id, n.connector_id, n.kind, n.reject_reason, n.match_confidence, n.created_at, n.payload->>'rawTitle' as raw_title, n.payload->>'price' as price, n.payload->>'currency' as currency, n.payload->>'sourceUrl' as source_url, n.payload->'attributes'->>'categorySlug' as category_slug | |
| 98 | + from normalized_records n where n.status in ('unmatched','rejected') order by n.created_at desc limit 100`); | |
| 99 | + const duplicates = await run(sql`select cross_listing_group_id, count(*)::int as n, min(raw_title) as sample_title, array_agg(source_id) as sources from listings where cross_listing_group_id is not null group by cross_listing_group_id having count(*) > 1 order by n desc limit 30`); | |
| 100 | + const quality = await one(sql`select avg(data_quality)::float as sales_avg, percentile_cont(0.1) within group (order by data_quality) as sales_p10, (select avg(data_quality)::float from assets) as assets_avg, (select count(*)::int from sales where confidence < 0.6) as low_confidence_sales from sales`); | |
| 101 | + return { flagged, unmatched, duplicates, quality }; | |
| 102 | +} | |
| 103 | + | |
| 104 | +export async function matchCandidatesForRecord(recordId: string) { | |
| 105 | + const rec = await one(sql`select id, payload from normalized_records where id = ${recordId}`); | |
| 106 | + if (!rec) return { record: null, candidates: [] }; | |
| 107 | + const payload = rec.payload as { rawTitle?: string; attributes?: { categorySlug?: string } }; | |
| 108 | + const title = payload.rawTitle ?? ''; | |
| 109 | + const candidates = await run(sql`select id, slug, title, category_slug, similarity(title, ${title}) as score from assets where title % ${title} ${payload.attributes?.categorySlug ? sql`and category_slug = ${payload.attributes.categorySlug}` : sql``} order by score desc limit 10`); | |
| 110 | + return { record: rec, candidates }; | |
| 111 | +} | |
| 112 | + | |
| 113 | +export async function auditLog(opts: { entityType?: string; limit?: number } = {}) { | |
| 114 | + return run(sql`select * from audit_log where true ${opts.entityType ? sql`and entity_type = ${opts.entityType}` : sql``} order by created_at desc limit ${opts.limit ?? 200}`); | |
| 115 | +} | |
| 116 | + | |
| 117 | +export async function eventsLog(opts: { type?: string; limit?: number } = {}) { | |
| 118 | + const rows = await run(sql`select * from events where true ${opts.type ? sql`and type = ${opts.type}` : sql``} order by created_at desc limit ${opts.limit ?? 200}`); | |
| 119 | + const types = await run(sql`select type, count(*)::int as n from events where created_at >= now() - interval '7 days' group by type order by n desc`); | |
| 120 | + return { rows, types }; | |
| 121 | +} | |
added
apps/web/src/lib/admin/queue.ts
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { PgBoss } from 'pg-boss'; | |
| 3 | +import { logger } from '@rareindex/shared'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Thin pg-boss producer used by the admin console to enqueue pipeline jobs. Consumers live in | |
| 7 | + * workers/ (agent D). Queue names and payloads are the contract: | |
| 8 | + * crawl.run { connectorId, mode: 'incremental'|'backfill'|'probe', trigger: 'manual'|'retry'|'schedule', limit? } | |
| 9 | + * pipeline.normalize { connectorId? } | |
| 10 | + * valuation.run { assetId? } | |
| 11 | + */ | |
| 12 | +export const QUEUES = { | |
| 13 | + crawlRun: 'crawl.run', | |
| 14 | + normalize: 'pipeline.normalize', | |
| 15 | + valuation: 'valuation.run', | |
| 16 | + indices: 'indices.run', | |
| 17 | +} as const; | |
| 18 | + | |
| 19 | +let boss: PgBoss | null = null; | |
| 20 | +let starting: Promise<PgBoss> | null = null; | |
| 21 | + | |
| 22 | +export async function getBoss(): Promise<PgBoss> { | |
| 23 | + if (boss) return boss; | |
| 24 | + if (!starting) { | |
| 25 | + starting = (async () => { | |
| 26 | + const b = new PgBoss({ connectionString: process.env.DATABASE_URL ?? 'postgres://localhost:5432/rareindex', application_name: 'rareindex-web-admin', max: 2, supervise: false, schedule: false }); | |
| 27 | + b.on('error', (err: Error) => logger.warn({ err: err.message }, 'pg-boss error')); | |
| 28 | + await b.start(); | |
| 29 | + boss = b; | |
| 30 | + return b; | |
| 31 | + })(); | |
| 32 | + } | |
| 33 | + return starting; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export async function enqueue(queue: string, data: Record<string, unknown>, opts: { singletonKey?: string; priority?: number } = {}): Promise<string | null> { | |
| 37 | + const b = await getBoss(); | |
| 38 | + await b.createQueue(queue).catch(() => {}); | |
| 39 | + return b.send(queue, data, { ...(opts.singletonKey ? { singletonKey: opts.singletonKey } : {}), ...(opts.priority !== undefined ? { priority: opts.priority } : {}), retryLimit: 2, expireInSeconds: 3600 }); | |
| 40 | +} | |
| 41 | + | |
| 42 | +export async function queueDepths(): Promise<Array<{ name: string; queued: number; active: number; failed: number }>> { | |
| 43 | + try { | |
| 44 | + const b = await getBoss(); | |
| 45 | + const out: Array<{ name: string; queued: number; active: number; failed: number }> = []; | |
| 46 | + for (const name of Object.values(QUEUES)) { | |
| 47 | + const q = await b.getQueue(name).catch(() => null); | |
| 48 | + if (!q) continue; | |
| 49 | + const stats = (await (b as unknown as { getQueueStats?: (n: string) => Promise<{ queuedCount?: number; activeCount?: number; failedCount?: number }> }).getQueueStats?.(name).catch(() => null)) ?? null; | |
| 50 | + out.push({ name, queued: stats?.queuedCount ?? 0, active: stats?.activeCount ?? 0, failed: stats?.failedCount ?? 0 }); | |
| 51 | + } | |
| 52 | + return out; | |
| 53 | + } catch (err) { | |
| 54 | + logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'queue depth unavailable'); | |
| 55 | + return []; | |
| 56 | + } | |
| 57 | +} | |
added
apps/web/src/lib/ai/candidates.ts
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { getDb, sql } from '@rareindex/database'; | |
| 3 | +import { descendants } from '@rareindex/taxonomy'; | |
| 4 | +import type { Identification } from '@rareindex/ai'; | |
| 5 | + | |
| 6 | +export interface Candidate { | |
| 7 | + assetId: string; | |
| 8 | + slug: string; | |
| 9 | + title: string; | |
| 10 | + categorySlug: string; | |
| 11 | + heroImageUrl: string | null; | |
| 12 | + year: number | null; | |
| 13 | + setName: string | null; | |
| 14 | + number: string | null; | |
| 15 | + variant: string | null; | |
| 16 | + score: number; | |
| 17 | + rivUsd: number | null; | |
| 18 | + rivLowUsd: number | null; | |
| 19 | + rivHighUsd: number | null; | |
| 20 | + rivConfidence: number | null; | |
| 21 | + rivSampleSize: number; | |
| 22 | + latestSaleUsd: number | null; | |
| 23 | + latestSaleAt: string | null; | |
| 24 | + salesCount: number; | |
| 25 | + activeListings: number; | |
| 26 | + minAskUsd: number | null; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Candidate assets for an identification guess (§114): identifier hits first, then trigram/FTS | |
| 31 | + * over titles restricted to the guessed category subtree. Pure DB, no AI. | |
| 32 | + */ | |
| 33 | +export async function findCandidates(guess: Pick<Identification, 'categorySlug' | 'name' | 'set' | 'number' | 'year' | 'variant' | 'brand' | 'searchQueries' | 'certificationNumber'>, identifiers: Record<string, string> = {}, limit = 8): Promise<Candidate[]> { | |
| 34 | + const db = getDb(); | |
| 35 | + const scope = guess.categorySlug ? [guess.categorySlug, ...descendants(guess.categorySlug)] : null; | |
| 36 | + const scopeSql = scope ? sql`and a.category_slug in (${sql.join(scope.map((s) => sql`${s}`), sql`, `)})` : sql``; | |
| 37 | + const found = new Map<string, Candidate>(); | |
| 38 | + | |
| 39 | + const cols = sql`a.id as "assetId", a.slug, a.title, a.category_slug as "categorySlug", a.hero_image_url as "heroImageUrl", a.year, a.set_name as "setName", a.number, a.variant, | |
| 40 | + s.riv_usd as "rivUsd", s.riv_low_usd as "rivLowUsd", s.riv_high_usd as "rivHighUsd", s.riv_confidence as "rivConfidence", coalesce(s.riv_sample_size,0) as "rivSampleSize", s.latest_sale_usd as "latestSaleUsd", s.latest_sale_at as "latestSaleAt", coalesce(s.sales_count,0) as "salesCount", coalesce(s.active_listings,0) as "activeListings", s.min_ask_usd as "minAskUsd"`; | |
| 41 | + | |
| 42 | + // 1) deterministic identifiers | |
| 43 | + const idEntries = Object.entries(identifiers).filter(([, v]) => v); | |
| 44 | + for (const [k, v] of idEntries) { | |
| 45 | + const rows = (await db.execute(sql`select ${cols}, 1.0 as score from assets a left join asset_stats s on s.asset_id = a.id where a.identifiers ->> ${k} = ${v} limit 3`)) as unknown as Candidate[]; | |
| 46 | + for (const r of rows) found.set(r.assetId, { ...r, score: 1 }); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 2) text queries: model-proposed search strings + a composed one | |
| 50 | + const composed = [guess.year, guess.brand, guess.set, guess.name, guess.number, guess.variant].filter(Boolean).join(' '); | |
| 51 | + const queries = [...new Set([composed, ...(guess.searchQueries ?? [])].map((q) => q.trim()).filter((q) => q.length >= 3))].slice(0, 4); | |
| 52 | + for (const q of queries) { | |
| 53 | + const tsq = q | |
| 54 | + .split(/\s+/) | |
| 55 | + .map((t) => t.replace(/[^\p{L}\p{N}./-]/gu, '')) | |
| 56 | + .filter(Boolean) | |
| 57 | + .map((t) => `${t}:*`) | |
| 58 | + .join(' | '); | |
| 59 | + const rows = (await db.execute(sql`select ${cols}, | |
| 60 | + (similarity(a.title, ${q}) * 0.6 + coalesce(ts_rank(a.search, to_tsquery('simple', ${tsq})), 0) * 0.4 | |
| 61 | + + case when ${guess.number ?? null}::text is not null and a.number = ${guess.number ?? null}::text then 0.25 else 0 end | |
| 62 | + + case when ${guess.year ?? null}::int is not null and a.year = ${guess.year ?? null}::int then 0.1 else 0 end) as score | |
| 63 | + from assets a left join asset_stats s on s.asset_id = a.id | |
| 64 | + where (a.title % ${q} or a.search @@ to_tsquery('simple', ${tsq})) ${scopeSql} | |
| 65 | + order by score desc limit ${limit}`)) as unknown as Candidate[]; | |
| 66 | + for (const r of rows) { | |
| 67 | + const prev = found.get(r.assetId); | |
| 68 | + if (!prev || prev.score < Number(r.score)) found.set(r.assetId, { ...r, score: Number(r.score) }); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + return [...found.values()].sort((a, b) => b.score - a.score).slice(0, limit); | |
| 72 | +} | |
| 73 | + | |
| 74 | +export async function assetContext(assetId: string) { | |
| 75 | + const db = getDb(); | |
| 76 | + const sales = (await db.execute(sql`select id, sale_date as "saleDate", price, currency, price_usd as "priceUsd", grader, grade, source_id as "sourceId", source_url as "sourceUrl", raw_title as "rawTitle" from sales where asset_id = ${assetId} and status = 'valid' order by sale_date desc limit 8`)) as unknown as Array<Record<string, unknown>>; | |
| 77 | + const listings = (await db.execute(sql`select id, price, currency, price_usd as "priceUsd", source_id as "sourceId", source_url as "sourceUrl", grader, grade, condition, raw_title as "rawTitle", discount_to_riv as "discountToRiv" from listings where asset_id = ${assetId} and availability = 'available' order by price_usd asc nulls last limit 8`)) as unknown as Array<Record<string, unknown>>; | |
| 78 | + const variants = (await db.execute(sql`select v.id, v.label, v.grader, v.grade, vs.riv_usd as "rivUsd", vs.riv_confidence as "rivConfidence", vs.riv_sample_size as "rivSampleSize", vs.sales_count as "salesCount" from asset_variants v left join variant_stats vs on vs.variant_id = v.id where v.asset_id = ${assetId} order by vs.sales_count desc nulls last limit 12`)) as unknown as Array<Record<string, unknown>>; | |
| 79 | + return { sales, listings, variants }; | |
| 80 | +} | |
added
apps/web/src/lib/ai/lookup.ts
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { createHmac } from 'node:crypto'; | |
| 3 | +import type { NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { NormalizedRecordSchema } from '@rareindex/shared'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * URL lookup is delegated to the API service (apps/api `/internal/lookup`): connector modules are | |
| 8 | + * loaded dynamically from disk, which the Next.js bundler cannot do. Shared secret = SESSION_SECRET. | |
| 9 | + */ | |
| 10 | +export function apiBaseUrl(): string { | |
| 11 | + return process.env.RI_API_URL ?? `http://127.0.0.1:${process.env.API_PORT ?? 8211}`; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function internalToken(): string { | |
| 15 | + return createHmac('sha256', process.env.SESSION_SECRET ?? 'dev-only').update('internal').digest('hex'); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export interface LookupResult { | |
| 19 | + connectorId: string; | |
| 20 | + sourceId: string; | |
| 21 | + records: NormalizedRecord[]; | |
| 22 | + durationMs: number; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export async function lookupViaApi(url: string, timeoutMs = 90_000): Promise<LookupResult | null | 'unavailable'> { | |
| 26 | + const ctrl = new AbortController(); | |
| 27 | + const t = setTimeout(() => ctrl.abort(), timeoutMs); | |
| 28 | + try { | |
| 29 | + const res = await fetch(`${apiBaseUrl()}/internal/lookup`, { | |
| 30 | + method: 'POST', | |
| 31 | + headers: { 'content-type': 'application/json', 'x-internal-token': internalToken() }, | |
| 32 | + body: JSON.stringify({ url }), | |
| 33 | + signal: ctrl.signal, | |
| 34 | + cache: 'no-store', | |
| 35 | + }); | |
| 36 | + if (!res.ok) return 'unavailable'; | |
| 37 | + const json = (await res.json()) as { data: { connectorId: string; sourceId: string; records: unknown[] } | null; meta: { supported: boolean; durationMs?: number } }; | |
| 38 | + if (!json.data) return null; | |
| 39 | + const records = json.data.records.map((r) => NormalizedRecordSchema.parse(r)); | |
| 40 | + return { connectorId: json.data.connectorId, sourceId: json.data.sourceId, records, durationMs: json.meta.durationMs ?? 0 }; | |
| 41 | + } catch { | |
| 42 | + return 'unavailable'; | |
| 43 | + } finally { | |
| 44 | + clearTimeout(t); | |
| 45 | + } | |
| 46 | +} | |
added
apps/web/src/lib/ai/request.ts
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { createHmac, randomUUID } from 'node:crypto'; | |
| 3 | +import { cookies, headers } from 'next/headers'; | |
| 4 | +import { getDb, aiQuotas, sql } from '@rareindex/database'; | |
| 5 | + | |
| 6 | +const SECRET = () => process.env.SESSION_SECRET ?? 'dev-only'; | |
| 7 | + | |
| 8 | +/** Stable, non-reversible identifier for the caller's IP (quota keys, abuse review). */ | |
| 9 | +export async function clientIpHash(): Promise<string> { | |
| 10 | + const h = await headers(); | |
| 11 | + const ip = (h.get('x-forwarded-for')?.split(',')[0] ?? h.get('x-real-ip') ?? '0.0.0.0').trim(); | |
| 12 | + return createHmac('sha256', SECRET()).update(ip).digest('hex').slice(0, 32); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export const ANON_COOKIE = 'ri_anon'; | |
| 16 | + | |
| 17 | +/** Anonymous visitor id (httpOnly cookie) used to thread research sessions and scanner history. */ | |
| 18 | +export async function getAnonId(create = true): Promise<string | null> { | |
| 19 | + const jar = await cookies(); | |
| 20 | + const existing = jar.get(ANON_COOKIE)?.value; | |
| 21 | + if (existing && /^[a-f0-9-]{36}$/.test(existing)) return existing; | |
| 22 | + if (!create) return null; | |
| 23 | + const id = randomUUID(); | |
| 24 | + try { | |
| 25 | + jar.set(ANON_COOKIE, id, { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 24 * 365 }); | |
| 26 | + } catch { | |
| 27 | + /* cannot set cookies during render; caller falls back to null */ | |
| 28 | + return null; | |
| 29 | + } | |
| 30 | + return id; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * Signed-in user id, if any. Integration point for the account module: it reads the session | |
| 35 | + * cookie (`ri_session`, falling back to `session`) and validates it against the `sessions` table. | |
| 36 | + * If the auth module uses another cookie name or token format, adapt this one function only. | |
| 37 | + */ | |
| 38 | +export async function currentUserId(): Promise<string | null> { | |
| 39 | + try { | |
| 40 | + const jar = await cookies(); | |
| 41 | + const token = jar.get('ri_session')?.value ?? jar.get('session')?.value; | |
| 42 | + if (!token || token.length > 200) return null; | |
| 43 | + const [row] = await getDb().execute(sql`select user_id from sessions where id = ${token} and expires_at > now() limit 1`); | |
| 44 | + return ((row as { user_id?: string } | undefined)?.user_id) ?? null; | |
| 45 | + } catch { | |
| 46 | + return null; | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +export const QUOTAS: Record<string, { anonymous: number; user: number }> = { | |
| 51 | + scanner: { anonymous: 20, user: 100 }, | |
| 52 | + research: { anonymous: 40, user: 300 }, | |
| 53 | +}; | |
| 54 | + | |
| 55 | +/** Consume one unit of a daily quota. Returns remaining (−1 when exceeded). */ | |
| 56 | +export async function consumeQuota(feature: keyof typeof QUOTAS, opts: { userId: string | null; ipHash: string }): Promise<{ ok: boolean; used: number; limit: number }> { | |
| 57 | + const limit = opts.userId ? QUOTAS[feature]!.user : QUOTAS[feature]!.anonymous; | |
| 58 | + const key = opts.userId ? `user:${opts.userId}` : `ip:${opts.ipHash}`; | |
| 59 | + const date = new Date().toISOString().slice(0, 10); | |
| 60 | + const db = getDb(); | |
| 61 | + const [row] = await db.execute(sql`select count from ai_quotas where key = ${key} and feature = ${feature} and date = ${date} limit 1`); | |
| 62 | + const used = Number((row as { count?: number } | undefined)?.count ?? 0); | |
| 63 | + if (used >= limit) return { ok: false, used, limit }; | |
| 64 | + if (row) await db.execute(sql`update ai_quotas set count = count + 1 where key = ${key} and feature = ${feature} and date = ${date}`); | |
| 65 | + else await db.insert(aiQuotas).values({ key, feature, date, count: 1 }); | |
| 66 | + return { ok: true, used: used + 1, limit }; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export function aiErrorMessage(err: unknown): { status: number; message: string } { | |
| 70 | + const e = err as { code?: string; message?: string; status?: number }; | |
| 71 | + if (e?.code === 'ai_not_configured') return { status: 503, message: 'AI provider not configured. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY) on the server.' }; | |
| 72 | + if (e?.code === 'ai_refusal') return { status: 422, message: `The model declined this request${e.message ? `: ${e.message}` : ''}.` }; | |
| 73 | + if (typeof e?.status === 'number' && e.status === 429) return { status: 429, message: 'The AI provider is rate limiting requests. Try again shortly.' }; | |
| 74 | + return { status: 500, message: e?.message ?? 'Unexpected error' }; | |
| 75 | +} | |
added
apps/web/src/lib/ai/research-tools.ts
+259 −0
@@ -0,0 +1,259 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { getDb, sql } from '@rareindex/database'; | |
| 4 | +import { CATEGORIES, descendants } from '@rareindex/taxonomy'; | |
| 5 | +import type { ToolDefinition } from '@rareindex/ai'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Structured tools for AI Research (§128). The model can only call these; each one is a fixed, | |
| 9 | + * parameterised query — never free-form SQL. Results are compact and always carry provenance | |
| 10 | + * columns (slug/url/date) so the assistant can cite them. | |
| 11 | + */ | |
| 12 | +type Row = Record<string, unknown>; | |
| 13 | +const run = async (q: ReturnType<typeof sql>): Promise<Row[]> => (await getDb().execute(q)) as unknown as Row[]; | |
| 14 | + | |
| 15 | +const WINDOWS = { '7d': 'change_7d', '30d': 'change_30d', '90d': 'change_90d', '1y': 'change_1y' } as const; | |
| 16 | +type Window = keyof typeof WINDOWS; | |
| 17 | + | |
| 18 | +function scope(category?: string | null) { | |
| 19 | + if (!category) return sql``; | |
| 20 | + const slugs = [category, ...descendants(category)]; | |
| 21 | + return sql`and a.category_slug in (${sql.join(slugs.map((s) => sql`${s}`), sql`, `)})`; | |
| 22 | +} | |
| 23 | + | |
| 24 | +const ASSET_COLS = sql`a.slug, a.title, a.category_slug, a.year, a.hero_image_url, s.riv_usd, s.riv_low_usd, s.riv_high_usd, s.riv_confidence, s.riv_sample_size, s.latest_sale_usd, s.latest_sale_at, s.change_7d, s.change_30d, s.change_90d, s.change_1y, s.sales_count, s.sales_30d, s.active_listings, s.min_ask_usd, s.liquidity_score, s.rarity_score, s.trending_score, s.value_opportunity`; | |
| 25 | + | |
| 26 | +export const TOOL_SCHEMAS = { | |
| 27 | + search_assets: z.object({ query: z.string().min(1).max(200), category: z.string().nullable().optional(), limit: z.number().int().min(1).max(25).default(10) }), | |
| 28 | + get_asset: z.object({ slug: z.string() }), | |
| 29 | + get_asset_sales: z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) }), | |
| 30 | + get_asset_listings: z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) }), | |
| 31 | + get_price_history: z.object({ slug: z.string(), days: z.number().int().min(7).max(3650).default(365) }), | |
| 32 | + list_categories: z.object({ family: z.string().nullable().optional() }), | |
| 33 | + get_category_market: z.object({ category: z.string() }), | |
| 34 | + get_index: z.object({ ticker: z.string() }), | |
| 35 | + get_index_history: z.object({ ticker: z.string(), days: z.number().int().min(7).max(3650).default(365) }), | |
| 36 | + top_movers: z.object({ category: z.string().nullable().optional(), window: z.enum(['7d', '30d', '90d', '1y']).default('30d'), direction: z.enum(['gainers', 'losers']).default('gainers'), min_sales: z.number().int().min(0).default(3), limit: z.number().int().min(1).max(25).default(10) }), | |
| 37 | + compare: z.object({ items: z.array(z.string()).min(2).max(6).describe('asset slugs, category slugs or index tickers'), window: z.enum(['7d', '30d', '90d', '1y']).default('1y') }), | |
| 38 | + screen: z.object({ | |
| 39 | + category: z.string().nullable().optional(), | |
| 40 | + min_price_usd: z.number().nullable().optional(), | |
| 41 | + max_price_usd: z.number().nullable().optional(), | |
| 42 | + min_change: z.number().nullable().optional().describe('minimum return over the window as a fraction, e.g. 0.3'), | |
| 43 | + max_change: z.number().nullable().optional(), | |
| 44 | + window: z.enum(['7d', '30d', '90d', '1y']).default('1y'), | |
| 45 | + min_sales: z.number().int().min(0).default(3), | |
| 46 | + max_sales_1y: z.number().int().nullable().optional(), | |
| 47 | + min_liquidity: z.number().min(0).max(100).nullable().optional(), | |
| 48 | + sort: z.enum(['change', 'value', 'liquidity', 'sales', 'trending', 'opportunity']).default('change'), | |
| 49 | + limit: z.number().int().min(1).max(50).default(20), | |
| 50 | + }), | |
| 51 | + record_sales: z.object({ category: z.string().nullable().optional(), limit: z.number().int().min(1).max(25).default(10) }), | |
| 52 | + radar: z.object({ kind: z.string().nullable().optional(), limit: z.number().int().min(1).max(25).default(10) }), | |
| 53 | + platform_coverage: z.object({}), | |
| 54 | +} as const; | |
| 55 | + | |
| 56 | +export type ToolName = keyof typeof TOOL_SCHEMAS; | |
| 57 | + | |
| 58 | +const DESCRIPTIONS: Record<ToolName, string> = { | |
| 59 | + search_assets: 'Full-text search over canonical assets (cards, watches, sneakers, LEGO, games…). Returns slugs, valuations and activity. Use before any asset-specific tool when you only have a name.', | |
| 60 | + get_asset: 'Asset detail: attributes, RareIndex Valuation with low/high/confidence/sample size, changes, sales/listings counts, variants (grades) with their own valuations.', | |
| 61 | + get_asset_sales: 'Recent observed sales for an asset (date, price, currency, USD, grade, source URL).', | |
| 62 | + get_asset_listings: 'Current listings (asks, not sales) for an asset with discount vs RIV.', | |
| 63 | + get_price_history: 'Daily RIV / latest-sale / median / volume series for an asset over N days.', | |
| 64 | + list_categories: 'Taxonomy with tracked asset counts; optionally children of a family.', | |
| 65 | + get_category_market: 'Category market snapshot: index value, tracked assets, sales, volume, median sale, listings, changes; plus top gainers/losers and most valuable.', | |
| 66 | + get_index: 'RareIndex index (RARE or RARE-XXX) latest value, changes, constituents, transactions, market cap estimate with confidence.', | |
| 67 | + get_index_history: 'Index daily values over N days.', | |
| 68 | + top_movers: 'Best or worst performing assets over a window, optionally within a category, requiring a minimum number of sales.', | |
| 69 | + compare: 'Compare returns over a window across asset slugs, category slugs and/or index tickers (mixed allowed). Returns start/end values and return.', | |
| 70 | + screen: 'Screen assets by category, price range, return over window, sales counts, liquidity; sort by change/value/liquidity/sales/trending/opportunity.', | |
| 71 | + record_sales: 'Highest verified sales (optionally within a category).', | |
| 72 | + radar: 'Rare Radar findings: first listing in years, ultra-low population, unusual price discrepancy, record sale…', | |
| 73 | + platform_coverage: 'Counts of assets, sales, listings, sources and the latest data timestamps — use to explain coverage limits.', | |
| 74 | +}; | |
| 75 | + | |
| 76 | +export function toolDefinitions(): ToolDefinition[] { | |
| 77 | + return (Object.keys(TOOL_SCHEMAS) as ToolName[]).map((name) => ({ | |
| 78 | + name, | |
| 79 | + description: DESCRIPTIONS[name], | |
| 80 | + inputSchema: z.toJSONSchema(TOOL_SCHEMAS[name], { target: 'draft-2020-12', io: 'input' }) as Record<string, unknown>, | |
| 81 | + })); | |
| 82 | +} | |
| 83 | + | |
| 84 | +async function assetBySlug(slug: string) { | |
| 85 | + const [a] = await run(sql`select a.id, ${ASSET_COLS}, a.brand, a.franchise, a.set_name, a.number, a.variant, a.edition, a.language, a.identifiers from assets a left join asset_stats s on s.asset_id = a.id where a.slug = ${slug} or a.id = ${slug} limit 1`); | |
| 86 | + return a ?? null; | |
| 87 | +} | |
| 88 | + | |
| 89 | +export async function executeTool(name: string, rawInput: unknown): Promise<unknown> { | |
| 90 | + if (!(name in TOOL_SCHEMAS)) throw new Error(`unknown tool ${name}`); | |
| 91 | + const schema = TOOL_SCHEMAS[name as ToolName]; | |
| 92 | + const input = schema.parse(rawInput ?? {}) as z.infer<typeof schema>; | |
| 93 | + switch (name as ToolName) { | |
| 94 | + case 'search_assets': { | |
| 95 | + const i = input as z.infer<typeof TOOL_SCHEMAS.search_assets>; | |
| 96 | + const tsq = i.query.split(/\s+/).map((t) => t.replace(/[^\p{L}\p{N}./-]/gu, '')).filter(Boolean).map((t) => `${t}:*`).join(' & '); | |
| 97 | + const rows = await run(sql`select ${ASSET_COLS}, (coalesce(ts_rank(a.search, to_tsquery('simple', ${tsq})),0)*2 + similarity(a.title, ${i.query})) as score | |
| 98 | + from assets a left join asset_stats s on s.asset_id = a.id where (a.search @@ to_tsquery('simple', ${tsq}) or a.title % ${i.query}) ${scope(i.category)} order by score desc, s.sales_count desc nulls last limit ${i.limit}`); | |
| 99 | + return { count: rows.length, results: rows }; | |
| 100 | + } | |
| 101 | + case 'get_asset': { | |
| 102 | + const i = input as z.infer<typeof TOOL_SCHEMAS.get_asset>; | |
| 103 | + const a = await assetBySlug(i.slug); | |
| 104 | + if (!a) return { error: 'asset not found', hint: 'use search_assets first' }; | |
| 105 | + const variants = await run(sql`select v.label, v.grader, v.grade, vs.riv_usd, vs.riv_confidence, vs.riv_sample_size, vs.sales_count, vs.latest_sale_usd, vs.change_30d, vs.change_1y from asset_variants v left join variant_stats vs on vs.variant_id = v.id where v.asset_id = ${a.id as string} order by vs.sales_count desc nulls last limit 15`); | |
| 106 | + const rest = { ...a }; | |
| 107 | + delete rest.id; | |
| 108 | + return { ...rest, url: `/asset/${a.slug as string}`, variants }; | |
| 109 | + } | |
| 110 | + case 'get_asset_sales': { | |
| 111 | + const i = input as z.infer<typeof TOOL_SCHEMAS.get_asset_sales>; | |
| 112 | + const a = await assetBySlug(i.slug); | |
| 113 | + if (!a) return { error: 'asset not found' }; | |
| 114 | + const sales = await run(sql`select sale_date, price, currency, price_usd, grader, grade, condition, sale_type, source_id, source_url, auction_house from sales where asset_id = ${a.id as string} and status = 'valid' order by sale_date desc limit ${i.limit}`); | |
| 115 | + return { asset: a.title, count: sales.length, sales }; | |
| 116 | + } | |
| 117 | + case 'get_asset_listings': { | |
| 118 | + const i = input as z.infer<typeof TOOL_SCHEMAS.get_asset_listings>; | |
| 119 | + const a = await assetBySlug(i.slug); | |
| 120 | + if (!a) return { error: 'asset not found' }; | |
| 121 | + const listings = await run(sql`select price, currency, price_usd, grader, grade, condition, seller, source_id, source_url, ends_at, discount_to_riv, listing_type from listings where asset_id = ${a.id as string} and availability = 'available' order by price_usd asc nulls last limit ${i.limit}`); | |
| 122 | + return { asset: a.title, note: 'asking prices, not transactions', count: listings.length, listings }; | |
| 123 | + } | |
| 124 | + case 'get_price_history': { | |
| 125 | + const i = input as z.infer<typeof TOOL_SCHEMAS.get_price_history>; | |
| 126 | + const a = await assetBySlug(i.slug); | |
| 127 | + if (!a) return { error: 'asset not found' }; | |
| 128 | + const rows = await run(sql`select date, riv_usd, latest_sale_usd, median_usd, sales_count, volume_usd, listings_count from price_snapshots where asset_id = ${a.id as string} and variant_id = '' and date >= current_date - ${i.days}::int order by date`); | |
| 129 | + return { asset: a.title, points: thin(rows, 120) }; | |
| 130 | + } | |
| 131 | + case 'list_categories': { | |
| 132 | + const i = input as z.infer<typeof TOOL_SCHEMAS.list_categories>; | |
| 133 | + const rows = await run(sql`select c.slug, c.name, c.parent_slug, c.family_slug, c.level, c.phase, c.index_ticker, (select count(*)::int from assets a where a.category_slug = c.slug) as tracked_assets from categories c where c.active ${i.family ? sql`and (c.family_slug = ${i.family})` : sql`and c.level = 0`} order by c.sort_order`); | |
| 134 | + return { count: rows.length, categories: rows }; | |
| 135 | + } | |
| 136 | + case 'get_category_market': { | |
| 137 | + const i = input as z.infer<typeof TOOL_SCHEMAS.get_category_market>; | |
| 138 | + const cat = CATEGORIES.find((c) => c.slug === i.category); | |
| 139 | + if (!cat) return { error: 'unknown category', hint: 'call list_categories' }; | |
| 140 | + const [snap] = await run(sql`select * from category_snapshots where category_slug = ${i.category} order by date desc limit 1`); | |
| 141 | + const [counts] = await run(sql`select count(*)::int as tracked_assets, count(s.riv_usd)::int as valued_assets, coalesce(sum(s.sales_count),0)::int as sales_total, coalesce(sum(s.active_listings),0)::int as active_listings, percentile_cont(0.5) within group (order by s.riv_usd) as median_riv_usd from assets a left join asset_stats s on s.asset_id = a.id where true ${scope(i.category)}`); | |
| 142 | + const gainers = await run(sql`select a.slug, a.title, s.riv_usd, s.change_30d, s.sales_count from assets a join asset_stats s on s.asset_id = a.id where s.change_30d is not null and s.riv_sample_size >= 3 ${scope(i.category)} order by s.change_30d desc limit 5`); | |
| 143 | + const losers = await run(sql`select a.slug, a.title, s.riv_usd, s.change_30d, s.sales_count from assets a join asset_stats s on s.asset_id = a.id where s.change_30d is not null and s.riv_sample_size >= 3 ${scope(i.category)} order by s.change_30d asc limit 5`); | |
| 144 | + const valuable = await run(sql`select a.slug, a.title, s.riv_usd, s.riv_confidence, s.sales_count from assets a join asset_stats s on s.asset_id = a.id where s.riv_usd is not null ${scope(i.category)} order by s.riv_usd desc limit 5`); | |
| 145 | + return { category: { slug: cat.slug, name: cat.name, index: cat.index }, snapshot: snap ?? null, counts, gainers, losers, most_valuable: valuable, url: `/markets/${cat.slug}` }; | |
| 146 | + } | |
| 147 | + case 'get_index': { | |
| 148 | + const i = input as z.infer<typeof TOOL_SCHEMAS.get_index>; | |
| 149 | + const t = i.ticker.toUpperCase(); | |
| 150 | + const [idx] = await run(sql`select i.ticker, i.name, i.description, i.methodology, i.weighting, i.base_date, i.base_value, i.min_constituents from indices i where i.ticker = ${t}`); | |
| 151 | + if (!idx) return { error: 'unknown index' }; | |
| 152 | + const [latest] = await run(sql`select v.* from index_values v join indices i on i.id = v.index_id where i.ticker = ${t} order by v.date desc limit 1`); | |
| 153 | + if (!latest) return { ...idx, published: false, note: 'Index not yet published: not enough priced constituents.' }; | |
| 154 | + const change = async (days: number) => { | |
| 155 | + const [p] = await run(sql`select value from index_values v join indices i on i.id = v.index_id where i.ticker = ${t} and v.date <= ${latest.date as string}::date - ${days}::int order by v.date desc limit 1`); | |
| 156 | + return p && Number(p.value) > 0 ? (Number(latest.value) - Number(p.value)) / Number(p.value) : null; | |
| 157 | + }; | |
| 158 | + return { ...idx, published: true, as_of: latest.date, value: latest.value, constituents: latest.constituents_count, transactions: latest.transactions, volume_usd: latest.volume_usd, median_sale_usd: latest.median_sale_usd, market_cap_est_usd: latest.market_cap_est_usd, market_cap_confidence: latest.market_cap_confidence, liquidity_score: latest.liquidity_score, momentum: latest.momentum, change_1d: await change(1), change_7d: await change(7), change_30d: await change(30), change_1y: await change(365), url: `/rareindex/${t}` }; | |
| 159 | + } | |
| 160 | + case 'get_index_history': { | |
| 161 | + const i = input as z.infer<typeof TOOL_SCHEMAS.get_index_history>; | |
| 162 | + const rows = await run(sql`select v.date, v.value, v.transactions, v.volume_usd from index_values v join indices i on i.id = v.index_id where i.ticker = ${i.ticker.toUpperCase()} and v.date >= current_date - ${i.days}::int order by v.date`); | |
| 163 | + return { ticker: i.ticker.toUpperCase(), points: thin(rows, 120) }; | |
| 164 | + } | |
| 165 | + case 'top_movers': { | |
| 166 | + const i = input as z.infer<typeof TOOL_SCHEMAS.top_movers>; | |
| 167 | + const col = sql.raw(`s.${WINDOWS[i.window as Window]}`); | |
| 168 | + const order = i.direction === 'gainers' ? sql`desc` : sql`asc`; | |
| 169 | + const rows = await run(sql`select ${ASSET_COLS} from assets a join asset_stats s on s.asset_id = a.id where ${col} is not null and s.riv_sample_size >= ${i.min_sales} ${scope(i.category)} order by ${col} ${order} limit ${i.limit}`); | |
| 170 | + return { window: i.window, direction: i.direction, count: rows.length, results: rows }; | |
| 171 | + } | |
| 172 | + case 'compare': { | |
| 173 | + const i = input as z.infer<typeof TOOL_SCHEMAS.compare>; | |
| 174 | + const days = { '7d': 7, '30d': 30, '90d': 90, '1y': 365 }[i.window as Window]; | |
| 175 | + const out: Row[] = []; | |
| 176 | + for (const item of i.items) { | |
| 177 | + const up = item.toUpperCase(); | |
| 178 | + if (up === 'RARE' || up.startsWith('RARE-')) { | |
| 179 | + const [end] = await run(sql`select v.date, v.value from index_values v join indices x on x.id = v.index_id where x.ticker = ${up} order by v.date desc limit 1`); | |
| 180 | + const [start] = end ? await run(sql`select v.date, v.value from index_values v join indices x on x.id = v.index_id where x.ticker = ${up} and v.date <= ${end.date as string}::date - ${days}::int order by v.date desc limit 1`) : []; | |
| 181 | + out.push({ item: up, type: 'index', start_date: start?.date ?? null, start_value: start?.value ?? null, end_date: end?.date ?? null, end_value: end?.value ?? null, return: ret(start?.value, end?.value) }); | |
| 182 | + continue; | |
| 183 | + } | |
| 184 | + const cat = CATEGORIES.find((c) => c.slug === item); | |
| 185 | + if (cat) { | |
| 186 | + const [end] = await run(sql`select date, index_value, median_sale_usd from category_snapshots where category_slug = ${item} and index_value is not null order by date desc limit 1`); | |
| 187 | + const [start] = end ? await run(sql`select date, index_value from category_snapshots where category_slug = ${item} and index_value is not null and date <= ${end.date as string}::date - ${days}::int order by date desc limit 1`) : []; | |
| 188 | + out.push({ item, type: 'category', start_date: start?.date ?? null, start_value: start?.index_value ?? null, end_date: end?.date ?? null, end_value: end?.index_value ?? null, return: ret(start?.index_value, end?.index_value) }); | |
| 189 | + continue; | |
| 190 | + } | |
| 191 | + const a = await assetBySlug(item); | |
| 192 | + if (!a) { | |
| 193 | + out.push({ item, type: 'unknown', error: 'not found' }); | |
| 194 | + continue; | |
| 195 | + } | |
| 196 | + const [end] = await run(sql`select date, riv_usd from price_snapshots where asset_id = ${a.id as string} and variant_id = '' and riv_usd is not null order by date desc limit 1`); | |
| 197 | + const [start] = end ? await run(sql`select date, riv_usd from price_snapshots where asset_id = ${a.id as string} and variant_id = '' and riv_usd is not null and date <= ${end.date as string}::date - ${days}::int order by date desc limit 1`) : []; | |
| 198 | + out.push({ item, type: 'asset', title: a.title, start_date: start?.date ?? null, start_value: start?.riv_usd ?? null, end_date: end?.date ?? null, end_value: end?.riv_usd ?? null, return: ret(start?.riv_usd, end?.riv_usd), riv_usd: a.riv_usd, sales_count: a.sales_count }); | |
| 199 | + } | |
| 200 | + return { window: i.window, items: out, note: 'return = null means insufficient history for the window' }; | |
| 201 | + } | |
| 202 | + case 'screen': { | |
| 203 | + const i = input as z.infer<typeof TOOL_SCHEMAS.screen>; | |
| 204 | + const col = sql.raw(`s.${WINDOWS[i.window as Window]}`); | |
| 205 | + const sortCol = { change: col, value: sql`s.riv_usd`, liquidity: sql`s.liquidity_score`, sales: sql`s.sales_count`, trending: sql`s.trending_score`, opportunity: sql`s.value_opportunity` }[i.sort as string] ?? col; | |
| 206 | + const rows = await run(sql`select ${ASSET_COLS} from assets a join asset_stats s on s.asset_id = a.id where s.riv_sample_size >= ${i.min_sales} ${scope(i.category)} | |
| 207 | + ${i.min_price_usd != null ? sql`and s.riv_usd >= ${i.min_price_usd}` : sql``} ${i.max_price_usd != null ? sql`and s.riv_usd <= ${i.max_price_usd}` : sql``} | |
| 208 | + ${i.min_change != null ? sql`and ${col} >= ${i.min_change}` : sql``} ${i.max_change != null ? sql`and ${col} <= ${i.max_change}` : sql``} | |
| 209 | + ${i.max_sales_1y != null ? sql`and s.sales_1y <= ${i.max_sales_1y}` : sql``} ${i.min_liquidity != null ? sql`and s.liquidity_score >= ${i.min_liquidity}` : sql``} | |
| 210 | + order by ${sortCol} desc nulls last limit ${i.limit}`); | |
| 211 | + return { filters: i, count: rows.length, results: rows }; | |
| 212 | + } | |
| 213 | + case 'record_sales': { | |
| 214 | + const i = input as z.infer<typeof TOOL_SCHEMAS.record_sales>; | |
| 215 | + const rows = await run(sql`select a.slug, a.title, a.category_slug, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade, sa.source_id, sa.source_url, sa.auction_house from sales sa join assets a on a.id = sa.asset_id where sa.status = 'valid' and sa.confidence >= 0.8 and sa.is_bundle = false ${scope(i.category)} order by sa.price_usd desc limit ${i.limit}`); | |
| 216 | + return { count: rows.length, sales: rows }; | |
| 217 | + } | |
| 218 | + case 'radar': { | |
| 219 | + const i = input as z.infer<typeof TOOL_SCHEMAS.radar>; | |
| 220 | + const rows = await run(sql`select r.kind, r.score, r.evidence, r.detected_at, a.slug, a.title from radar_findings r join assets a on a.id = r.asset_id where (r.expires_at is null or r.expires_at > now()) ${i.kind ? sql`and r.kind = ${i.kind}` : sql``} order by r.detected_at desc limit ${i.limit}`); | |
| 221 | + return { count: rows.length, findings: rows }; | |
| 222 | + } | |
| 223 | + case 'platform_coverage': { | |
| 224 | + const [r] = await run(sql`select (select count(*)::int from assets) as assets, (select count(*)::int from assets a join asset_stats s on s.asset_id=a.id where s.riv_usd is not null) as valued_assets, (select count(*)::int from sales where status='valid') as sales, (select max(sale_date) from sales) as latest_sale, (select count(*)::int from listings where availability='available') as listings, (select count(*)::int from sources where active) as sources, (select max(date) from index_values) as latest_index_date`); | |
| 225 | + const families = await run(sql`select a.family_slug, count(*)::int as assets, coalesce(sum(s.sales_count),0)::int as sales from assets a left join asset_stats s on s.asset_id = a.id group by a.family_slug order by assets desc`); | |
| 226 | + return { ...r, families }; | |
| 227 | + } | |
| 228 | + } | |
| 229 | + return { error: 'unhandled' }; | |
| 230 | +} | |
| 231 | + | |
| 232 | +function ret(start: unknown, end: unknown): number | null { | |
| 233 | + const s = Number(start); | |
| 234 | + const e = Number(end); | |
| 235 | + return Number.isFinite(s) && Number.isFinite(e) && s > 0 ? (e - s) / s : null; | |
| 236 | +} | |
| 237 | + | |
| 238 | +/** Keep tool payloads small: at most n evenly spaced points. */ | |
| 239 | +function thin<T>(rows: T[], n: number): T[] { | |
| 240 | + if (rows.length <= n) return rows; | |
| 241 | + const step = rows.length / n; | |
| 242 | + const out: T[] = []; | |
| 243 | + for (let i = 0; i < n; i++) out.push(rows[Math.floor(i * step)]!); | |
| 244 | + if (out[out.length - 1] !== rows[rows.length - 1]) out.push(rows[rows.length - 1]!); | |
| 245 | + return out; | |
| 246 | +} | |
| 247 | + | |
| 248 | +/** One-line summary of a tool result for the transparent trace shown to users. */ | |
| 249 | +export function summarizeToolResult(name: string, output: unknown): string { | |
| 250 | + if (!output || typeof output !== 'object') return ''; | |
| 251 | + const o = output as Record<string, unknown>; | |
| 252 | + if (o.error) return `error: ${String(o.error)}`; | |
| 253 | + if (typeof o.count === 'number') return `${o.count} rows`; | |
| 254 | + if (Array.isArray(o.points)) return `${o.points.length} points`; | |
| 255 | + if (Array.isArray(o.items)) return `${o.items.length} items`; | |
| 256 | + if (name === 'get_asset' && o.title) return String(o.title); | |
| 257 | + if (name === 'get_index') return o.published ? `value ${o.value}` : 'not published'; | |
| 258 | + return 'ok'; | |
| 259 | +} | |
added
apps/web/src/lib/ai/research.ts
+135 −0
@@ -0,0 +1,135 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { getDb, researchSessions, researchMessages, eq, desc, and, sql } from '@rareindex/database'; | |
| 3 | +import { newId } from '@rareindex/shared'; | |
| 4 | +import { getRouter, type ChatMessage, type StreamDelta } from '@rareindex/ai'; | |
| 5 | +import { executeTool, summarizeToolResult, toolDefinitions } from './research-tools'; | |
| 6 | + | |
| 7 | +export const RESEARCH_SYSTEM = `You are RareIndex Research, the analyst interface of RareIndex.io — a market-data terminal for collectibles (trading cards, sports cards, comics, video games, sneakers, watches, LEGO, toys, coins, art…). | |
| 8 | + | |
| 9 | +Rules: | |
| 10 | +1. Answer ONLY from tool results. Never state a price, count, return, population or date that did not come from a tool in this conversation. If tools return nothing, say "Data unavailable" and explain what RareIndex does or does not track yet (use platform_coverage). | |
| 11 | +2. Start by calling the right tools; chain them (search_assets → get_asset → get_asset_sales…). Prefer one well-parameterised call over many. | |
| 12 | +3. Every figure you cite must carry its context: sample size, confidence label, as-of date, and the currency (USD unless stated). Listing prices are asks, not sales. RIV is an estimate. | |
| 13 | +4. Cite assets as markdown links: [Title](/asset/slug); categories as [Name](/markets/slug); indices as [TICKER](/rareindex/TICKER). | |
| 14 | +5. Use compact markdown tables for comparisons (≤ 8 rows). Keep prose short and analytical. No investment advice; describe data, not recommendations. | |
| 15 | +6. Windows: 7d/30d/90d/1y. If the user asks for a horizon RareIndex cannot support, say so. | |
| 16 | +7. You may explain methodology briefly (RIV = ensemble of latest/median/volume-weighted/trimmed/exponentially-weighted prices with outlier flags; indices are chain-linked from constituent valuations and published only above a minimum breadth).`; | |
| 17 | + | |
| 18 | +export const SUGGESTED_PROMPTS = [ | |
| 19 | + 'Find Pokémon cards under $5,000 that have increased more than 30% in the last year.', | |
| 20 | + 'Compare RARE-WATCH versus RARE-TCG over the last 90 days.', | |
| 21 | + 'Find sealed Nintendo games with fewer than 20 public sales in the last year.', | |
| 22 | + 'Which collectible category has the strongest momentum this month?', | |
| 23 | + 'What are the highest verified sales tracked by RareIndex?', | |
| 24 | + 'How much data does RareIndex currently cover?', | |
| 25 | +]; | |
| 26 | + | |
| 27 | +export interface ResearchTurnInput { | |
| 28 | + sessionId: string | null; | |
| 29 | + anonId: string | null; | |
| 30 | + userId: string | null; | |
| 31 | + message: string; | |
| 32 | + signal?: AbortSignal; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export type ResearchEvent = | |
| 36 | + | { type: 'session'; sessionId: string } | |
| 37 | + | { type: 'text'; text: string } | |
| 38 | + | { type: 'thinking'; text: string } | |
| 39 | + | { type: 'tool_call'; id: string; name: string; input: unknown } | |
| 40 | + | { type: 'tool_result'; id: string; name: string; ok: boolean; ms: number; summary: string; preview: unknown } | |
| 41 | + | { type: 'done'; usdEst: number; model: string | null } | |
| 42 | + | { type: 'error'; message: string }; | |
| 43 | + | |
| 44 | +const MAX_HISTORY = 16; | |
| 45 | + | |
| 46 | +export async function loadSession(sessionId: string, owner: { anonId: string | null; userId: string | null }) { | |
| 47 | + const db = getDb(); | |
| 48 | + const [s] = await db.select().from(researchSessions).where(eq(researchSessions.id, sessionId)).limit(1); | |
| 49 | + if (!s) return null; | |
| 50 | + if (s.userId && s.userId !== owner.userId) return null; | |
| 51 | + if (!s.userId && s.anonId && s.anonId !== owner.anonId) return null; | |
| 52 | + const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt); | |
| 53 | + return { session: s, messages }; | |
| 54 | +} | |
| 55 | + | |
| 56 | +export async function listSessions(owner: { anonId: string | null; userId: string | null }, limit = 20) { | |
| 57 | + const db = getDb(); | |
| 58 | + const where = owner.userId ? eq(researchSessions.userId, owner.userId) : owner.anonId ? and(eq(researchSessions.anonId, owner.anonId), sql`${researchSessions.userId} is null`) : null; | |
| 59 | + if (!where) return []; | |
| 60 | + return db.select().from(researchSessions).where(and(where, eq(researchSessions.archived, false))).orderBy(desc(researchSessions.updatedAt)).limit(limit); | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** Run one research turn: persists the user message, streams the agent, persists the answer + tool trace. */ | |
| 64 | +export async function* researchTurn(input: ResearchTurnInput): AsyncGenerator<ResearchEvent> { | |
| 65 | + const db = getDb(); | |
| 66 | + const router = getRouter(); | |
| 67 | + let sessionId = input.sessionId; | |
| 68 | + let history: ChatMessage[] = []; | |
| 69 | + if (sessionId) { | |
| 70 | + const loaded = await loadSession(sessionId, { anonId: input.anonId, userId: input.userId }); | |
| 71 | + if (!loaded) sessionId = null; | |
| 72 | + else history = loaded.messages.slice(-MAX_HISTORY).map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })); | |
| 73 | + } | |
| 74 | + if (!sessionId) { | |
| 75 | + sessionId = newId('event').replace('evt_', 'rs_'); | |
| 76 | + await db.insert(researchSessions).values({ id: sessionId, userId: input.userId, anonId: input.anonId, title: input.message.slice(0, 80), model: router.configured ? router.modelFor('research') : null }); | |
| 77 | + } | |
| 78 | + yield { type: 'session', sessionId }; | |
| 79 | + await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'user', content: input.message }); | |
| 80 | + | |
| 81 | + let answer = ''; | |
| 82 | + let usdEst = 0; | |
| 83 | + let model: string | null = null; | |
| 84 | + const trace: Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }> = []; | |
| 85 | + const pending = new Map<string, { name: string; input: unknown }>(); | |
| 86 | + const usage: Record<string, number> = {}; | |
| 87 | + try { | |
| 88 | + const stream = router.runTools('research', { | |
| 89 | + system: RESEARCH_SYSTEM, | |
| 90 | + messages: [...history, { role: 'user', content: input.message }], | |
| 91 | + tools: toolDefinitions(), | |
| 92 | + execute: executeTool, | |
| 93 | + maxIterations: 10, | |
| 94 | + maxTokens: 6000, | |
| 95 | + effort: 'medium', | |
| 96 | + signal: input.signal, | |
| 97 | + cost: { endpoint: 'research', userId: input.userId, metadata: { sessionId } }, | |
| 98 | + }); | |
| 99 | + for await (const d of stream as AsyncIterable<StreamDelta>) { | |
| 100 | + if (d.type === 'text') { | |
| 101 | + answer += d.text; | |
| 102 | + yield d; | |
| 103 | + } else if (d.type === 'thinking') { | |
| 104 | + yield d; | |
| 105 | + } else if (d.type === 'tool_call') { | |
| 106 | + pending.set(d.id, { name: d.name, input: d.input }); | |
| 107 | + yield d; | |
| 108 | + } else if (d.type === 'tool_result') { | |
| 109 | + const summary = d.isError ? `error: ${JSON.stringify(d.output).slice(0, 120)}` : summarizeToolResult(d.name, d.output); | |
| 110 | + trace.push({ name: d.name, input: pending.get(d.id)?.input, ok: !d.isError, ms: d.durationMs, summary }); | |
| 111 | + yield { type: 'tool_result', id: d.id, name: d.name, ok: !d.isError, ms: d.durationMs, summary, preview: preview(d.output) }; | |
| 112 | + } else if (d.type === 'usage') { | |
| 113 | + usdEst += d.usdEst; | |
| 114 | + model = d.model; | |
| 115 | + for (const [k, v] of Object.entries(d.usage)) usage[k] = (usage[k] ?? 0) + v; | |
| 116 | + } | |
| 117 | + } | |
| 118 | + } catch (err) { | |
| 119 | + const e = err as { code?: string; message?: string }; | |
| 120 | + const message = e?.code === 'ai_not_configured' ? 'AI provider not configured on this server.' : e?.code === 'ai_refusal' ? `The model declined this request${e.message ? `: ${e.message}` : '.'}` : (e?.message ?? 'Unexpected error'); | |
| 121 | + yield { type: 'error', message }; | |
| 122 | + answer = answer || `_${message}_`; | |
| 123 | + } | |
| 124 | + await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'assistant', content: answer, toolCalls: trace, usage, usdEst, model }); | |
| 125 | + await db.update(researchSessions).set({ messageCount: sql`${researchSessions.messageCount} + 2`, usdEst: sql`${researchSessions.usdEst} + ${usdEst}`, updatedAt: new Date(), model }).where(eq(researchSessions.id, sessionId)); | |
| 126 | + yield { type: 'done', usdEst, model }; | |
| 127 | +} | |
| 128 | + | |
| 129 | +function preview(output: unknown): unknown { | |
| 130 | + if (!output || typeof output !== 'object') return output; | |
| 131 | + const o = output as Record<string, unknown>; | |
| 132 | + const arrKey = ['results', 'sales', 'listings', 'items', 'points', 'categories', 'findings', 'variants', 'gainers'].find((k) => Array.isArray(o[k])); | |
| 133 | + if (arrKey) return { [arrKey]: (o[arrKey] as unknown[]).slice(0, 5), truncated: (o[arrKey] as unknown[]).length > 5 }; | |
| 134 | + return Object.fromEntries(Object.entries(o).slice(0, 12)); | |
| 135 | +} | |
added
apps/web/src/lib/ai/scanner.ts
+169 −0
@@ -0,0 +1,169 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { getDb, scannerSessions, eq } from '@rareindex/database'; | |
| 3 | +import { newId, logger, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { getGrader, parseGradeFromTitle } from '@rareindex/taxonomy'; | |
| 5 | +import { identifyCollectible, getRouter, type ImageInput, type Identification } from '@rareindex/ai'; | |
| 6 | +import { lookupViaApi } from './lookup'; | |
| 7 | +import { findCandidates, assetContext, type Candidate } from './candidates'; | |
| 8 | + | |
| 9 | +export 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 | +} | |
| 22 | + | |
| 23 | +export 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 | +} | |
| 38 | + | |
| 39 | +interface 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 | +} | |
| 49 | + | |
| 50 | +/** Full scanner flow (§114). Everything shown is evidence-backed; AI output carries confidence. */ | |
| 51 | +export 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 ?? ''; | |
| 61 | + | |
| 62 | + 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 | + } | |
| 77 | + | |
| 78 | + 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 | + } | |
| 92 | + | |
| 93 | + // 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 | + } | |
| 101 | + | |
| 102 | + 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.'); | |
| 106 | + | |
| 107 | + 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 | + } | |
| 111 | + | |
| 112 | + 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 | + }); | |
| 133 | + | |
| 134 | + return { sessionId: id, mode: input.mode, guess, guessConfidence: guess?.confidence ?? null, candidates, best, listing, model, usdEst, durationMs, notes }; | |
| 135 | +} | |
| 136 | + | |
| 137 | +export 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 | +} | |
| 140 | + | |
| 141 | +/** Resolve a marketplace URL through the connector framework (lookup → normalize), via the API service. */ | |
| 142 | +export 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 | +} | |
| 166 | + | |
| 167 | +export function aiConfigured(): boolean { | |
| 168 | + return getRouter().configured; | |
| 169 | +} | |
added
apps/web/src/proxy.ts
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +import { NextResponse, type NextRequest } from 'next/server'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Request guard for the admin surface (§144). Runs before rendering: `/admin/*` and `/api/admin/*` | |
| 5 | + * require the `ri_admin` cookie whose value is HMAC-SHA256(SESSION_SECRET, "admin:" + ADMIN_TOKEN). | |
| 6 | + * Web Crypto is used because this runs on the edge-compatible runtime. | |
| 7 | + */ | |
| 8 | +const ADMIN_COOKIE = 'ri_admin'; | |
| 9 | + | |
| 10 | +async function expectedCookie(): Promise<string | null> { | |
| 11 | + const token = process.env.ADMIN_TOKEN; | |
| 12 | + if (!token) return null; | |
| 13 | + const enc = new TextEncoder(); | |
| 14 | + const key = await crypto.subtle.importKey('raw', enc.encode(process.env.SESSION_SECRET ?? 'dev-only'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); | |
| 15 | + const sig = await crypto.subtle.sign('HMAC', key, enc.encode(`admin:${token}`)); | |
| 16 | + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join(''); | |
| 17 | +} | |
| 18 | + | |
| 19 | +export async function proxy(req: NextRequest) { | |
| 20 | + const { pathname } = req.nextUrl; | |
| 21 | + if (pathname === '/admin/login' || pathname === '/api/admin/login') return NextResponse.next(); | |
| 22 | + const expected = await expectedCookie(); | |
| 23 | + const got = req.cookies.get(ADMIN_COOKIE)?.value; | |
| 24 | + const ok = Boolean(expected && got && got.length === expected.length && constantTimeEqual(got, expected)); | |
| 25 | + if (ok) return NextResponse.next(); | |
| 26 | + if (pathname.startsWith('/api/')) return NextResponse.json({ error: 'Admin authentication required' }, { status: 401 }); | |
| 27 | + const url = req.nextUrl.clone(); | |
| 28 | + url.pathname = '/admin/login'; | |
| 29 | + url.searchParams.set('next', pathname); | |
| 30 | + return NextResponse.redirect(url); | |
| 31 | +} | |
| 32 | + | |
| 33 | +function constantTimeEqual(a: string, b: string): boolean { | |
| 34 | + let out = 0; | |
| 35 | + for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| 36 | + return out === 0; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export const config = { | |
| 40 | + matcher: ['/admin/:path*', '/api/admin/:path*'], | |
| 41 | +}; | |
modified
apps/web/tsconfig.json
+2 −2
@@ -8,8 +8,8 @@ | ||
| 8 | 8 | "noUncheckedIndexedAccess": true, |
| 9 | 9 | "noEmit": true, |
| 10 | 10 | "esModuleInterop": true, |
| 11 | − "module": "esnext", | |
| 12 | − "moduleResolution": "bundler", | |
| 11 | + "module": "nodenext", | |
| 12 | + "moduleResolution": "nodenext", | |
| 13 | 13 | "resolveJsonModule": true, |
| 14 | 14 | "isolatedModules": true, |
| 15 | 15 | "jsx": "react-jsx", |
added
docs/API.md
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +# RareIndex Public API (v1) | |
| 2 | + | |
| 3 | +Base URL (production): `https://www.rareindex.io/api/v1` — the web app proxies `/api/v1/*` to the | |
| 4 | +Fastify service (`apps/api`, port `API_PORT`, default 8211). Direct access in development: | |
| 5 | +`http://localhost:8211/v1`. Machine-readable contract: `GET /v1/openapi.json` (also `docs/openapi.json`). | |
| 6 | + | |
| 7 | +## Authentication & tiers | |
| 8 | + | |
| 9 | +`Authorization: Bearer ri_live_<prefix>_<secret>`. Requests without a key use the **public** tier. | |
| 10 | +Only the SHA-256 hash of a key is stored (`api_keys.key_hash`); mint keys with | |
| 11 | + | |
| 12 | +``` | |
| 13 | +pnpm --filter @rareindex/api create-key -- --name "Research desk" --tier professional [--user usr_…] | |
| 14 | +``` | |
| 15 | + | |
| 16 | +| Tier | req/min | req/day | | |
| 17 | +|---|---|---| | |
| 18 | +| public (no key) | 20 | 500 | | |
| 19 | +| free | 60 | 1 000 | | |
| 20 | +| hobby | 120 | 10 000 | | |
| 21 | +| professional | 600 | 100 000 | | |
| 22 | +| research | 600 | 250 000 | | |
| 23 | +| enterprise | 3 000 | 5 000 000 | | |
| 24 | + | |
| 25 | +Rate-limit headers: `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset`; every response | |
| 26 | +carries `x-request-id`. Usage is counted per key/day/endpoint in `api_usage`. | |
| 27 | + | |
| 28 | +## Conventions | |
| 29 | + | |
| 30 | +- Envelope: `{ "data": …, "meta": { "count", "cursor", "as_of", "attribution", … } }`. | |
| 31 | +- Pagination: `limit` (≤ 200) + opaque `cursor` (from `meta.cursor`, `null` when exhausted). | |
| 32 | +- Errors: RFC 9457 `application/problem+json` (`title`, `status`, `detail`, `instance`, `request_id`). | |
| 33 | +- Export: add `?format=csv` on list endpoints for a flat CSV download. | |
| 34 | +- Caching: read endpoints send `cache-control` and weak ETags; gzip/brotli negotiated. | |
| 35 | +- Money: `price` + `currency` are the native transaction values; `price_usd` uses the FX rate of the | |
| 36 | + transaction date. Valuations (`riv_usd`, `riv_low_usd`, `riv_high_usd`) always come with | |
| 37 | + `riv_confidence` and `riv_sample_size`. Listing prices are asks, never transactions. | |
| 38 | +- Attribution is required (see `meta.attribution`). | |
| 39 | + | |
| 40 | +## Endpoints | |
| 41 | + | |
| 42 | +| Method & path | Purpose | | |
| 43 | +|---|---| | |
| 44 | +| `GET /v1/assets/search?q&category&limit&cursor` | Search canonical assets (FTS + trigram + identifiers) | | |
| 45 | +| `GET /v1/assets/{idOrSlug}` | Asset detail: attributes, stats, variants with their own valuations, latest valuation, sources | | |
| 46 | +| `GET /v1/assets/{id}/sales?variant&include_flagged` | Observed sales (valid only by default) | | |
| 47 | +| `GET /v1/assets/{id}/listings?availability` | Listings (asks) with `discount_to_riv` | | |
| 48 | +| `GET /v1/assets/{id}/history?variant&from&to` | Daily series: RIV, latest sale, median, sales count, volume, listings, min ask | | |
| 49 | +| `GET /v1/categories` | Taxonomy with tracked asset counts | | |
| 50 | +| `GET /v1/indices` | RARE + subindices: latest value, 1d/7d/30d/YTD/1y changes, breadth, market cap estimate + confidence; `published=false` until breadth threshold | | |
| 51 | +| `GET /v1/indices/{ticker}/history?from&to` | Index history | | |
| 52 | +| `GET /v1/markets` | Latest category snapshot per family | | |
| 53 | +| `GET /v1/markets/{slug}` | Market detail: counts, gainers/losers (≥ 3 sales), most valuable, most liquid, recent sales, history | | |
| 54 | +| `GET /v1/trending?category` | Assets ranked by trending score | | |
| 55 | +| `GET /v1/sales/latest?category&min_usd` | Latest observed sales across the platform | | |
| 56 | +| `GET /v1/records` | Highest verified sale per family (bundles and low-confidence excluded) | | |
| 57 | +| `GET /v1/stats` | Platform counts | | |
| 58 | +| `GET /healthz` | Liveness (DB ping) | | |
| 59 | + | |
| 60 | +## Internal endpoints (loopback only) | |
| 61 | + | |
| 62 | +`POST /internal/lookup { url }` and `GET /internal/lookup/sources` are used by the web Scanner to | |
| 63 | +resolve a marketplace URL through the connector framework (connector modules are loaded | |
| 64 | +dynamically, which the Next.js bundler cannot do). Auth: header `x-internal-token` = | |
| 65 | +HMAC-SHA256(`SESSION_SECRET`, `"internal"`), and the caller must be 127.0.0.1. | |
| 66 | + | |
| 67 | +## Running | |
| 68 | + | |
| 69 | +``` | |
| 70 | +pnpm --filter @rareindex/api dev # tsx watch, port 8211 | |
| 71 | +pnpm --filter @rareindex/api start | |
| 72 | +pnpm --filter @rareindex/api test # fastify.inject integration tests against DATABASE_URL | |
| 73 | +pnpm --filter @rareindex/api exec tsx scripts/gen-openapi.ts # refresh docs/openapi.json | |
| 74 | +``` | |
| 75 | + | |
| 76 | +Environment: `DATABASE_URL`, `API_PORT`, `API_HOST` (default 127.0.0.1 — put it behind the web proxy), | |
| 77 | +`SESSION_SECRET` (shared with the web app for internal calls), `FIRECRAWL_API_KEY`/`SCRAPFLY_API_KEY` | |
| 78 | +(URL lookups), `LOG_LEVEL`. | |
added
docs/PENDING-SCHEMA-ai.md
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +# Pending schema changes — agent F2 (ai / scanner / research) | |
| 2 | + | |
| 3 | +New file `packages/database/src/schema/ai.ts`, exported from `schema/index.ts`. Applied locally with | |
| 4 | +`drizzle-kit push` on `rareindex_ai`; **no migration generated** (to be folded into the next | |
| 5 | +`pnpm db:generate` at integration). | |
| 6 | + | |
| 7 | +| Table | Purpose | | |
| 8 | +|---|---| | |
| 9 | +| `scanner_sessions` | Scanner submissions (photo/url/text), model guess + confidence, candidates shown, user pick (feedback loop), thumbnails, cost | | |
| 10 | +| `research_sessions` | AI Research threads keyed by anonymous cookie (`anon_id`) or `user_id` | | |
| 11 | +| `research_messages` | Messages with transparent tool-call trace, usage and cost | | |
| 12 | +| `ai_quotas` | Per-day counters for anonymous AI features (key = ip hash or user id) | | |
| 13 | + | |
| 14 | +No changes to existing tables. AI spend is written to the existing `costs` table (`kind='ai'`). | |
added
docs/openapi.json
+2075 −0
@@ -0,0 +1,2075 @@ | ||
| 1 | +{ | |
| 2 | + "openapi": "3.1.0", | |
| 3 | + "info": { | |
| 4 | + "title": "RareIndex Public API", | |
| 5 | + "version": "1.0.0", | |
| 6 | + "description": "Structured market data for collectible assets: canonical assets, observed sales, live listings, price history, category markets and the RareIndex indices. Valuations are estimates with confidence and sample size; listing prices are not confirmed transactions. Attribution to RareIndex and the original sources is required.", | |
| 7 | + "contact": { | |
| 8 | + "name": "RareIndex", | |
| 9 | + "url": "https://www.rareindex.io/api-docs", | |
| 10 | + "email": "api@rareindex.io" | |
| 11 | + }, | |
| 12 | + "termsOfService": "https://www.rareindex.io/about#terms" | |
| 13 | + }, | |
| 14 | + "servers": [ | |
| 15 | + { | |
| 16 | + "url": "https://www.rareindex.io/api", | |
| 17 | + "description": "Production (proxied by the web app)" | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "url": "http://localhost:8211", | |
| 21 | + "description": "Local API service" | |
| 22 | + } | |
| 23 | + ], | |
| 24 | + "security": [ | |
| 25 | + { | |
| 26 | + "bearerAuth": [] | |
| 27 | + }, | |
| 28 | + {} | |
| 29 | + ], | |
| 30 | + "tags": [ | |
| 31 | + { | |
| 32 | + "name": "assets" | |
| 33 | + }, | |
| 34 | + { | |
| 35 | + "name": "markets" | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "name": "indices" | |
| 39 | + }, | |
| 40 | + { | |
| 41 | + "name": "sales" | |
| 42 | + }, | |
| 43 | + { | |
| 44 | + "name": "reference" | |
| 45 | + } | |
| 46 | + ], | |
| 47 | + "paths": { | |
| 48 | + "/v1/assets/search": { | |
| 49 | + "get": { | |
| 50 | + "tags": [ | |
| 51 | + "assets" | |
| 52 | + ], | |
| 53 | + "summary": "Search canonical assets", | |
| 54 | + "parameters": [ | |
| 55 | + { | |
| 56 | + "name": "q", | |
| 57 | + "in": "query", | |
| 58 | + "schema": { | |
| 59 | + "type": "string" | |
| 60 | + }, | |
| 61 | + "description": "Natural language query, e.g. \"1999 Charizard PSA 10\"" | |
| 62 | + }, | |
| 63 | + { | |
| 64 | + "name": "category", | |
| 65 | + "in": "query", | |
| 66 | + "schema": { | |
| 67 | + "type": "string" | |
| 68 | + }, | |
| 69 | + "description": "Category or family slug" | |
| 70 | + }, | |
| 71 | + { | |
| 72 | + "name": "limit", | |
| 73 | + "in": "query", | |
| 74 | + "schema": { | |
| 75 | + "type": "integer", | |
| 76 | + "minimum": 1, | |
| 77 | + "maximum": 200, | |
| 78 | + "default": 50 | |
| 79 | + } | |
| 80 | + }, | |
| 81 | + { | |
| 82 | + "name": "cursor", | |
| 83 | + "in": "query", | |
| 84 | + "schema": { | |
| 85 | + "type": "string" | |
| 86 | + }, | |
| 87 | + "description": "Opaque cursor from meta.cursor" | |
| 88 | + }, | |
| 89 | + { | |
| 90 | + "name": "format", | |
| 91 | + "in": "query", | |
| 92 | + "schema": { | |
| 93 | + "type": "string", | |
| 94 | + "enum": [ | |
| 95 | + "json", | |
| 96 | + "csv" | |
| 97 | + ] | |
| 98 | + }, | |
| 99 | + "description": "csv returns a flat export of the rows" | |
| 100 | + } | |
| 101 | + ], | |
| 102 | + "responses": { | |
| 103 | + "200": { | |
| 104 | + "description": "OK", | |
| 105 | + "content": { | |
| 106 | + "application/json": { | |
| 107 | + "schema": { | |
| 108 | + "type": "object", | |
| 109 | + "properties": { | |
| 110 | + "data": { | |
| 111 | + "type": "array", | |
| 112 | + "items": { | |
| 113 | + "$ref": "#/components/schemas/AssetSummary" | |
| 114 | + } | |
| 115 | + }, | |
| 116 | + "meta": { | |
| 117 | + "$ref": "#/components/schemas/Meta" | |
| 118 | + } | |
| 119 | + } | |
| 120 | + } | |
| 121 | + } | |
| 122 | + } | |
| 123 | + }, | |
| 124 | + "400": { | |
| 125 | + "description": "Invalid query", | |
| 126 | + "content": { | |
| 127 | + "application/problem+json": { | |
| 128 | + "schema": { | |
| 129 | + "$ref": "#/components/schemas/Problem" | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + }, | |
| 134 | + "401": { | |
| 135 | + "description": "Invalid API key", | |
| 136 | + "content": { | |
| 137 | + "application/problem+json": { | |
| 138 | + "schema": { | |
| 139 | + "$ref": "#/components/schemas/Problem" | |
| 140 | + } | |
| 141 | + } | |
| 142 | + } | |
| 143 | + }, | |
| 144 | + "404": { | |
| 145 | + "description": "Not found", | |
| 146 | + "content": { | |
| 147 | + "application/problem+json": { | |
| 148 | + "schema": { | |
| 149 | + "$ref": "#/components/schemas/Problem" | |
| 150 | + } | |
| 151 | + } | |
| 152 | + } | |
| 153 | + }, | |
| 154 | + "429": { | |
| 155 | + "description": "Rate limit or quota exceeded", | |
| 156 | + "content": { | |
| 157 | + "application/problem+json": { | |
| 158 | + "schema": { | |
| 159 | + "$ref": "#/components/schemas/Problem" | |
| 160 | + } | |
| 161 | + } | |
| 162 | + } | |
| 163 | + } | |
| 164 | + } | |
| 165 | + } | |
| 166 | + }, | |
| 167 | + "/v1/assets/{id}": { | |
| 168 | + "get": { | |
| 169 | + "tags": [ | |
| 170 | + "assets" | |
| 171 | + ], | |
| 172 | + "summary": "Asset detail with variants, latest valuation and sources", | |
| 173 | + "parameters": [ | |
| 174 | + { | |
| 175 | + "name": "id", | |
| 176 | + "in": "path", | |
| 177 | + "required": true, | |
| 178 | + "schema": { | |
| 179 | + "type": "string" | |
| 180 | + }, | |
| 181 | + "description": "Asset id (rare_…) or slug" | |
| 182 | + } | |
| 183 | + ], | |
| 184 | + "responses": { | |
| 185 | + "200": { | |
| 186 | + "description": "OK", | |
| 187 | + "content": { | |
| 188 | + "application/json": { | |
| 189 | + "schema": { | |
| 190 | + "type": "object", | |
| 191 | + "properties": { | |
| 192 | + "data": { | |
| 193 | + "$ref": "#/components/schemas/AssetDetail" | |
| 194 | + }, | |
| 195 | + "meta": { | |
| 196 | + "$ref": "#/components/schemas/Meta" | |
| 197 | + } | |
| 198 | + } | |
| 199 | + } | |
| 200 | + } | |
| 201 | + } | |
| 202 | + }, | |
| 203 | + "400": { | |
| 204 | + "description": "Invalid query", | |
| 205 | + "content": { | |
| 206 | + "application/problem+json": { | |
| 207 | + "schema": { | |
| 208 | + "$ref": "#/components/schemas/Problem" | |
| 209 | + } | |
| 210 | + } | |
| 211 | + } | |
| 212 | + }, | |
| 213 | + "401": { | |
| 214 | + "description": "Invalid API key", | |
| 215 | + "content": { | |
| 216 | + "application/problem+json": { | |
| 217 | + "schema": { | |
| 218 | + "$ref": "#/components/schemas/Problem" | |
| 219 | + } | |
| 220 | + } | |
| 221 | + } | |
| 222 | + }, | |
| 223 | + "404": { | |
| 224 | + "description": "Not found", | |
| 225 | + "content": { | |
| 226 | + "application/problem+json": { | |
| 227 | + "schema": { | |
| 228 | + "$ref": "#/components/schemas/Problem" | |
| 229 | + } | |
| 230 | + } | |
| 231 | + } | |
| 232 | + }, | |
| 233 | + "429": { | |
| 234 | + "description": "Rate limit or quota exceeded", | |
| 235 | + "content": { | |
| 236 | + "application/problem+json": { | |
| 237 | + "schema": { | |
| 238 | + "$ref": "#/components/schemas/Problem" | |
| 239 | + } | |
| 240 | + } | |
| 241 | + } | |
| 242 | + } | |
| 243 | + } | |
| 244 | + } | |
| 245 | + }, | |
| 246 | + "/v1/assets/{id}/sales": { | |
| 247 | + "get": { | |
| 248 | + "tags": [ | |
| 249 | + "assets", | |
| 250 | + "sales" | |
| 251 | + ], | |
| 252 | + "summary": "Observed sales for an asset", | |
| 253 | + "parameters": [ | |
| 254 | + { | |
| 255 | + "name": "id", | |
| 256 | + "in": "path", | |
| 257 | + "required": true, | |
| 258 | + "schema": { | |
| 259 | + "type": "string" | |
| 260 | + } | |
| 261 | + }, | |
| 262 | + { | |
| 263 | + "name": "variant", | |
| 264 | + "in": "query", | |
| 265 | + "schema": { | |
| 266 | + "type": "string" | |
| 267 | + } | |
| 268 | + }, | |
| 269 | + { | |
| 270 | + "name": "include_flagged", | |
| 271 | + "in": "query", | |
| 272 | + "schema": { | |
| 273 | + "type": "boolean" | |
| 274 | + } | |
| 275 | + }, | |
| 276 | + { | |
| 277 | + "name": "limit", | |
| 278 | + "in": "query", | |
| 279 | + "schema": { | |
| 280 | + "type": "integer", | |
| 281 | + "minimum": 1, | |
| 282 | + "maximum": 200, | |
| 283 | + "default": 50 | |
| 284 | + } | |
| 285 | + }, | |
| 286 | + { | |
| 287 | + "name": "cursor", | |
| 288 | + "in": "query", | |
| 289 | + "schema": { | |
| 290 | + "type": "string" | |
| 291 | + }, | |
| 292 | + "description": "Opaque cursor from meta.cursor" | |
| 293 | + }, | |
| 294 | + { | |
| 295 | + "name": "format", | |
| 296 | + "in": "query", | |
| 297 | + "schema": { | |
| 298 | + "type": "string", | |
| 299 | + "enum": [ | |
| 300 | + "json", | |
| 301 | + "csv" | |
| 302 | + ] | |
| 303 | + }, | |
| 304 | + "description": "csv returns a flat export of the rows" | |
| 305 | + } | |
| 306 | + ], | |
| 307 | + "responses": { | |
| 308 | + "200": { | |
| 309 | + "description": "OK", | |
| 310 | + "content": { | |
| 311 | + "application/json": { | |
| 312 | + "schema": { | |
| 313 | + "type": "object", | |
| 314 | + "properties": { | |
| 315 | + "data": { | |
| 316 | + "type": "array", | |
| 317 | + "items": { | |
| 318 | + "$ref": "#/components/schemas/Sale" | |
| 319 | + } | |
| 320 | + }, | |
| 321 | + "meta": { | |
| 322 | + "$ref": "#/components/schemas/Meta" | |
| 323 | + } | |
| 324 | + } | |
| 325 | + } | |
| 326 | + } | |
| 327 | + } | |
| 328 | + }, | |
| 329 | + "400": { | |
| 330 | + "description": "Invalid query", | |
| 331 | + "content": { | |
| 332 | + "application/problem+json": { | |
| 333 | + "schema": { | |
| 334 | + "$ref": "#/components/schemas/Problem" | |
| 335 | + } | |
| 336 | + } | |
| 337 | + } | |
| 338 | + }, | |
| 339 | + "401": { | |
| 340 | + "description": "Invalid API key", | |
| 341 | + "content": { | |
| 342 | + "application/problem+json": { | |
| 343 | + "schema": { | |
| 344 | + "$ref": "#/components/schemas/Problem" | |
| 345 | + } | |
| 346 | + } | |
| 347 | + } | |
| 348 | + }, | |
| 349 | + "404": { | |
| 350 | + "description": "Not found", | |
| 351 | + "content": { | |
| 352 | + "application/problem+json": { | |
| 353 | + "schema": { | |
| 354 | + "$ref": "#/components/schemas/Problem" | |
| 355 | + } | |
| 356 | + } | |
| 357 | + } | |
| 358 | + }, | |
| 359 | + "429": { | |
| 360 | + "description": "Rate limit or quota exceeded", | |
| 361 | + "content": { | |
| 362 | + "application/problem+json": { | |
| 363 | + "schema": { | |
| 364 | + "$ref": "#/components/schemas/Problem" | |
| 365 | + } | |
| 366 | + } | |
| 367 | + } | |
| 368 | + } | |
| 369 | + } | |
| 370 | + } | |
| 371 | + }, | |
| 372 | + "/v1/assets/{id}/listings": { | |
| 373 | + "get": { | |
| 374 | + "tags": [ | |
| 375 | + "assets" | |
| 376 | + ], | |
| 377 | + "summary": "Listings for an asset (asks, not transactions)", | |
| 378 | + "parameters": [ | |
| 379 | + { | |
| 380 | + "name": "id", | |
| 381 | + "in": "path", | |
| 382 | + "required": true, | |
| 383 | + "schema": { | |
| 384 | + "type": "string" | |
| 385 | + } | |
| 386 | + }, | |
| 387 | + { | |
| 388 | + "name": "availability", | |
| 389 | + "in": "query", | |
| 390 | + "schema": { | |
| 391 | + "type": "string", | |
| 392 | + "enum": [ | |
| 393 | + "available", | |
| 394 | + "sold", | |
| 395 | + "ended", | |
| 396 | + "removed" | |
| 397 | + ], | |
| 398 | + "default": "available" | |
| 399 | + } | |
| 400 | + }, | |
| 401 | + { | |
| 402 | + "name": "limit", | |
| 403 | + "in": "query", | |
| 404 | + "schema": { | |
| 405 | + "type": "integer", | |
| 406 | + "minimum": 1, | |
| 407 | + "maximum": 200, | |
| 408 | + "default": 50 | |
| 409 | + } | |
| 410 | + }, | |
| 411 | + { | |
| 412 | + "name": "cursor", | |
| 413 | + "in": "query", | |
| 414 | + "schema": { | |
| 415 | + "type": "string" | |
| 416 | + }, | |
| 417 | + "description": "Opaque cursor from meta.cursor" | |
| 418 | + }, | |
| 419 | + { | |
| 420 | + "name": "format", | |
| 421 | + "in": "query", | |
| 422 | + "schema": { | |
| 423 | + "type": "string", | |
| 424 | + "enum": [ | |
| 425 | + "json", | |
| 426 | + "csv" | |
| 427 | + ] | |
| 428 | + }, | |
| 429 | + "description": "csv returns a flat export of the rows" | |
| 430 | + } | |
| 431 | + ], | |
| 432 | + "responses": { | |
| 433 | + "200": { | |
| 434 | + "description": "OK", | |
| 435 | + "content": { | |
| 436 | + "application/json": { | |
| 437 | + "schema": { | |
| 438 | + "type": "object", | |
| 439 | + "properties": { | |
| 440 | + "data": { | |
| 441 | + "type": "array", | |
| 442 | + "items": { | |
| 443 | + "$ref": "#/components/schemas/Listing" | |
| 444 | + } | |
| 445 | + }, | |
| 446 | + "meta": { | |
| 447 | + "$ref": "#/components/schemas/Meta" | |
| 448 | + } | |
| 449 | + } | |
| 450 | + } | |
| 451 | + } | |
| 452 | + } | |
| 453 | + }, | |
| 454 | + "400": { | |
| 455 | + "description": "Invalid query", | |
| 456 | + "content": { | |
| 457 | + "application/problem+json": { | |
| 458 | + "schema": { | |
| 459 | + "$ref": "#/components/schemas/Problem" | |
| 460 | + } | |
| 461 | + } | |
| 462 | + } | |
| 463 | + }, | |
| 464 | + "401": { | |
| 465 | + "description": "Invalid API key", | |
| 466 | + "content": { | |
| 467 | + "application/problem+json": { | |
| 468 | + "schema": { | |
| 469 | + "$ref": "#/components/schemas/Problem" | |
| 470 | + } | |
| 471 | + } | |
| 472 | + } | |
| 473 | + }, | |
| 474 | + "404": { | |
| 475 | + "description": "Not found", | |
| 476 | + "content": { | |
| 477 | + "application/problem+json": { | |
| 478 | + "schema": { | |
| 479 | + "$ref": "#/components/schemas/Problem" | |
| 480 | + } | |
| 481 | + } | |
| 482 | + } | |
| 483 | + }, | |
| 484 | + "429": { | |
| 485 | + "description": "Rate limit or quota exceeded", | |
| 486 | + "content": { | |
| 487 | + "application/problem+json": { | |
| 488 | + "schema": { | |
| 489 | + "$ref": "#/components/schemas/Problem" | |
| 490 | + } | |
| 491 | + } | |
| 492 | + } | |
| 493 | + } | |
| 494 | + } | |
| 495 | + } | |
| 496 | + }, | |
| 497 | + "/v1/assets/{id}/history": { | |
| 498 | + "get": { | |
| 499 | + "tags": [ | |
| 500 | + "assets" | |
| 501 | + ], | |
| 502 | + "summary": "Daily price history (RIV, latest sale, median, volume, listings)", | |
| 503 | + "parameters": [ | |
| 504 | + { | |
| 505 | + "name": "id", | |
| 506 | + "in": "path", | |
| 507 | + "required": true, | |
| 508 | + "schema": { | |
| 509 | + "type": "string" | |
| 510 | + } | |
| 511 | + }, | |
| 512 | + { | |
| 513 | + "name": "variant", | |
| 514 | + "in": "query", | |
| 515 | + "schema": { | |
| 516 | + "type": "string" | |
| 517 | + } | |
| 518 | + }, | |
| 519 | + { | |
| 520 | + "name": "from", | |
| 521 | + "in": "query", | |
| 522 | + "schema": { | |
| 523 | + "type": "string", | |
| 524 | + "format": "date" | |
| 525 | + } | |
| 526 | + }, | |
| 527 | + { | |
| 528 | + "name": "to", | |
| 529 | + "in": "query", | |
| 530 | + "schema": { | |
| 531 | + "type": "string", | |
| 532 | + "format": "date" | |
| 533 | + } | |
| 534 | + }, | |
| 535 | + { | |
| 536 | + "name": "format", | |
| 537 | + "in": "query", | |
| 538 | + "schema": { | |
| 539 | + "type": "string", | |
| 540 | + "enum": [ | |
| 541 | + "json", | |
| 542 | + "csv" | |
| 543 | + ] | |
| 544 | + } | |
| 545 | + } | |
| 546 | + ], | |
| 547 | + "responses": { | |
| 548 | + "200": { | |
| 549 | + "description": "OK", | |
| 550 | + "content": { | |
| 551 | + "application/json": { | |
| 552 | + "schema": { | |
| 553 | + "type": "object", | |
| 554 | + "properties": { | |
| 555 | + "data": { | |
| 556 | + "type": "array", | |
| 557 | + "items": { | |
| 558 | + "$ref": "#/components/schemas/PricePoint" | |
| 559 | + } | |
| 560 | + }, | |
| 561 | + "meta": { | |
| 562 | + "$ref": "#/components/schemas/Meta" | |
| 563 | + } | |
| 564 | + } | |
| 565 | + } | |
| 566 | + } | |
| 567 | + } | |
| 568 | + }, | |
| 569 | + "400": { | |
| 570 | + "description": "Invalid query", | |
| 571 | + "content": { | |
| 572 | + "application/problem+json": { | |
| 573 | + "schema": { | |
| 574 | + "$ref": "#/components/schemas/Problem" | |
| 575 | + } | |
| 576 | + } | |
| 577 | + } | |
| 578 | + }, | |
| 579 | + "401": { | |
| 580 | + "description": "Invalid API key", | |
| 581 | + "content": { | |
| 582 | + "application/problem+json": { | |
| 583 | + "schema": { | |
| 584 | + "$ref": "#/components/schemas/Problem" | |
| 585 | + } | |
| 586 | + } | |
| 587 | + } | |
| 588 | + }, | |
| 589 | + "404": { | |
| 590 | + "description": "Not found", | |
| 591 | + "content": { | |
| 592 | + "application/problem+json": { | |
| 593 | + "schema": { | |
| 594 | + "$ref": "#/components/schemas/Problem" | |
| 595 | + } | |
| 596 | + } | |
| 597 | + } | |
| 598 | + }, | |
| 599 | + "429": { | |
| 600 | + "description": "Rate limit or quota exceeded", | |
| 601 | + "content": { | |
| 602 | + "application/problem+json": { | |
| 603 | + "schema": { | |
| 604 | + "$ref": "#/components/schemas/Problem" | |
| 605 | + } | |
| 606 | + } | |
| 607 | + } | |
| 608 | + } | |
| 609 | + } | |
| 610 | + } | |
| 611 | + }, | |
| 612 | + "/v1/categories": { | |
| 613 | + "get": { | |
| 614 | + "tags": [ | |
| 615 | + "reference" | |
| 616 | + ], | |
| 617 | + "summary": "Taxonomy", | |
| 618 | + "parameters": [ | |
| 619 | + { | |
| 620 | + "name": "format", | |
| 621 | + "in": "query", | |
| 622 | + "schema": { | |
| 623 | + "type": "string", | |
| 624 | + "enum": [ | |
| 625 | + "json", | |
| 626 | + "csv" | |
| 627 | + ] | |
| 628 | + }, | |
| 629 | + "description": "csv returns a flat export of the rows" | |
| 630 | + } | |
| 631 | + ], | |
| 632 | + "responses": { | |
| 633 | + "200": { | |
| 634 | + "description": "OK", | |
| 635 | + "content": { | |
| 636 | + "application/json": { | |
| 637 | + "schema": { | |
| 638 | + "type": "object", | |
| 639 | + "properties": { | |
| 640 | + "data": { | |
| 641 | + "type": "array", | |
| 642 | + "items": { | |
| 643 | + "$ref": "#/components/schemas/Category" | |
| 644 | + } | |
| 645 | + }, | |
| 646 | + "meta": { | |
| 647 | + "$ref": "#/components/schemas/Meta" | |
| 648 | + } | |
| 649 | + } | |
| 650 | + } | |
| 651 | + } | |
| 652 | + } | |
| 653 | + }, | |
| 654 | + "400": { | |
| 655 | + "description": "Invalid query", | |
| 656 | + "content": { | |
| 657 | + "application/problem+json": { | |
| 658 | + "schema": { | |
| 659 | + "$ref": "#/components/schemas/Problem" | |
| 660 | + } | |
| 661 | + } | |
| 662 | + } | |
| 663 | + }, | |
| 664 | + "401": { | |
| 665 | + "description": "Invalid API key", | |
| 666 | + "content": { | |
| 667 | + "application/problem+json": { | |
| 668 | + "schema": { | |
| 669 | + "$ref": "#/components/schemas/Problem" | |
| 670 | + } | |
| 671 | + } | |
| 672 | + } | |
| 673 | + }, | |
| 674 | + "404": { | |
| 675 | + "description": "Not found", | |
| 676 | + "content": { | |
| 677 | + "application/problem+json": { | |
| 678 | + "schema": { | |
| 679 | + "$ref": "#/components/schemas/Problem" | |
| 680 | + } | |
| 681 | + } | |
| 682 | + } | |
| 683 | + }, | |
| 684 | + "429": { | |
| 685 | + "description": "Rate limit or quota exceeded", | |
| 686 | + "content": { | |
| 687 | + "application/problem+json": { | |
| 688 | + "schema": { | |
| 689 | + "$ref": "#/components/schemas/Problem" | |
| 690 | + } | |
| 691 | + } | |
| 692 | + } | |
| 693 | + } | |
| 694 | + } | |
| 695 | + } | |
| 696 | + }, | |
| 697 | + "/v1/indices": { | |
| 698 | + "get": { | |
| 699 | + "tags": [ | |
| 700 | + "indices" | |
| 701 | + ], | |
| 702 | + "summary": "RARE and subindices with latest values and changes", | |
| 703 | + "parameters": [ | |
| 704 | + { | |
| 705 | + "name": "format", | |
| 706 | + "in": "query", | |
| 707 | + "schema": { | |
| 708 | + "type": "string", | |
| 709 | + "enum": [ | |
| 710 | + "json", | |
| 711 | + "csv" | |
| 712 | + ] | |
| 713 | + }, | |
| 714 | + "description": "csv returns a flat export of the rows" | |
| 715 | + } | |
| 716 | + ], | |
| 717 | + "responses": { | |
| 718 | + "200": { | |
| 719 | + "description": "OK", | |
| 720 | + "content": { | |
| 721 | + "application/json": { | |
| 722 | + "schema": { | |
| 723 | + "type": "object", | |
| 724 | + "properties": { | |
| 725 | + "data": { | |
| 726 | + "type": "array", | |
| 727 | + "items": { | |
| 728 | + "$ref": "#/components/schemas/Index" | |
| 729 | + } | |
| 730 | + }, | |
| 731 | + "meta": { | |
| 732 | + "$ref": "#/components/schemas/Meta" | |
| 733 | + } | |
| 734 | + } | |
| 735 | + } | |
| 736 | + } | |
| 737 | + } | |
| 738 | + }, | |
| 739 | + "400": { | |
| 740 | + "description": "Invalid query", | |
| 741 | + "content": { | |
| 742 | + "application/problem+json": { | |
| 743 | + "schema": { | |
| 744 | + "$ref": "#/components/schemas/Problem" | |
| 745 | + } | |
| 746 | + } | |
| 747 | + } | |
| 748 | + }, | |
| 749 | + "401": { | |
| 750 | + "description": "Invalid API key", | |
| 751 | + "content": { | |
| 752 | + "application/problem+json": { | |
| 753 | + "schema": { | |
| 754 | + "$ref": "#/components/schemas/Problem" | |
| 755 | + } | |
| 756 | + } | |
| 757 | + } | |
| 758 | + }, | |
| 759 | + "404": { | |
| 760 | + "description": "Not found", | |
| 761 | + "content": { | |
| 762 | + "application/problem+json": { | |
| 763 | + "schema": { | |
| 764 | + "$ref": "#/components/schemas/Problem" | |
| 765 | + } | |
| 766 | + } | |
| 767 | + } | |
| 768 | + }, | |
| 769 | + "429": { | |
| 770 | + "description": "Rate limit or quota exceeded", | |
| 771 | + "content": { | |
| 772 | + "application/problem+json": { | |
| 773 | + "schema": { | |
| 774 | + "$ref": "#/components/schemas/Problem" | |
| 775 | + } | |
| 776 | + } | |
| 777 | + } | |
| 778 | + } | |
| 779 | + } | |
| 780 | + } | |
| 781 | + }, | |
| 782 | + "/v1/indices/{ticker}/history": { | |
| 783 | + "get": { | |
| 784 | + "tags": [ | |
| 785 | + "indices" | |
| 786 | + ], | |
| 787 | + "summary": "Index daily history", | |
| 788 | + "parameters": [ | |
| 789 | + { | |
| 790 | + "name": "ticker", | |
| 791 | + "in": "path", | |
| 792 | + "required": true, | |
| 793 | + "schema": { | |
| 794 | + "type": "string" | |
| 795 | + }, | |
| 796 | + "example": "RARE-TCG" | |
| 797 | + }, | |
| 798 | + { | |
| 799 | + "name": "from", | |
| 800 | + "in": "query", | |
| 801 | + "schema": { | |
| 802 | + "type": "string", | |
| 803 | + "format": "date" | |
| 804 | + } | |
| 805 | + }, | |
| 806 | + { | |
| 807 | + "name": "to", | |
| 808 | + "in": "query", | |
| 809 | + "schema": { | |
| 810 | + "type": "string", | |
| 811 | + "format": "date" | |
| 812 | + } | |
| 813 | + }, | |
| 814 | + { | |
| 815 | + "name": "format", | |
| 816 | + "in": "query", | |
| 817 | + "schema": { | |
| 818 | + "type": "string", | |
| 819 | + "enum": [ | |
| 820 | + "json", | |
| 821 | + "csv" | |
| 822 | + ] | |
| 823 | + } | |
| 824 | + } | |
| 825 | + ], | |
| 826 | + "responses": { | |
| 827 | + "200": { | |
| 828 | + "description": "OK", | |
| 829 | + "content": { | |
| 830 | + "application/json": { | |
| 831 | + "schema": { | |
| 832 | + "type": "object", | |
| 833 | + "properties": { | |
| 834 | + "data": { | |
| 835 | + "type": "array", | |
| 836 | + "items": { | |
| 837 | + "$ref": "#/components/schemas/IndexPoint" | |
| 838 | + } | |
| 839 | + }, | |
| 840 | + "meta": { | |
| 841 | + "$ref": "#/components/schemas/Meta" | |
| 842 | + } | |
| 843 | + } | |
| 844 | + } | |
| 845 | + } | |
| 846 | + } | |
| 847 | + }, | |
| 848 | + "400": { | |
| 849 | + "description": "Invalid query", | |
| 850 | + "content": { | |
| 851 | + "application/problem+json": { | |
| 852 | + "schema": { | |
| 853 | + "$ref": "#/components/schemas/Problem" | |
| 854 | + } | |
| 855 | + } | |
| 856 | + } | |
| 857 | + }, | |
| 858 | + "401": { | |
| 859 | + "description": "Invalid API key", | |
| 860 | + "content": { | |
| 861 | + "application/problem+json": { | |
| 862 | + "schema": { | |
| 863 | + "$ref": "#/components/schemas/Problem" | |
| 864 | + } | |
| 865 | + } | |
| 866 | + } | |
| 867 | + }, | |
| 868 | + "404": { | |
| 869 | + "description": "Not found", | |
| 870 | + "content": { | |
| 871 | + "application/problem+json": { | |
| 872 | + "schema": { | |
| 873 | + "$ref": "#/components/schemas/Problem" | |
| 874 | + } | |
| 875 | + } | |
| 876 | + } | |
| 877 | + }, | |
| 878 | + "429": { | |
| 879 | + "description": "Rate limit or quota exceeded", | |
| 880 | + "content": { | |
| 881 | + "application/problem+json": { | |
| 882 | + "schema": { | |
| 883 | + "$ref": "#/components/schemas/Problem" | |
| 884 | + } | |
| 885 | + } | |
| 886 | + } | |
| 887 | + } | |
| 888 | + } | |
| 889 | + } | |
| 890 | + }, | |
| 891 | + "/v1/markets": { | |
| 892 | + "get": { | |
| 893 | + "tags": [ | |
| 894 | + "markets" | |
| 895 | + ], | |
| 896 | + "summary": "Category markets overview (latest snapshot per family)", | |
| 897 | + "parameters": [ | |
| 898 | + { | |
| 899 | + "name": "format", | |
| 900 | + "in": "query", | |
| 901 | + "schema": { | |
| 902 | + "type": "string", | |
| 903 | + "enum": [ | |
| 904 | + "json", | |
| 905 | + "csv" | |
| 906 | + ] | |
| 907 | + }, | |
| 908 | + "description": "csv returns a flat export of the rows" | |
| 909 | + } | |
| 910 | + ], | |
| 911 | + "responses": { | |
| 912 | + "200": { | |
| 913 | + "description": "OK", | |
| 914 | + "content": { | |
| 915 | + "application/json": { | |
| 916 | + "schema": { | |
| 917 | + "type": "object", | |
| 918 | + "properties": { | |
| 919 | + "data": { | |
| 920 | + "type": "array", | |
| 921 | + "items": { | |
| 922 | + "$ref": "#/components/schemas/Market" | |
| 923 | + } | |
| 924 | + }, | |
| 925 | + "meta": { | |
| 926 | + "$ref": "#/components/schemas/Meta" | |
| 927 | + } | |
| 928 | + } | |
| 929 | + } | |
| 930 | + } | |
| 931 | + } | |
| 932 | + }, | |
| 933 | + "400": { | |
| 934 | + "description": "Invalid query", | |
| 935 | + "content": { | |
| 936 | + "application/problem+json": { | |
| 937 | + "schema": { | |
| 938 | + "$ref": "#/components/schemas/Problem" | |
| 939 | + } | |
| 940 | + } | |
| 941 | + } | |
| 942 | + }, | |
| 943 | + "401": { | |
| 944 | + "description": "Invalid API key", | |
| 945 | + "content": { | |
| 946 | + "application/problem+json": { | |
| 947 | + "schema": { | |
| 948 | + "$ref": "#/components/schemas/Problem" | |
| 949 | + } | |
| 950 | + } | |
| 951 | + } | |
| 952 | + }, | |
| 953 | + "404": { | |
| 954 | + "description": "Not found", | |
| 955 | + "content": { | |
| 956 | + "application/problem+json": { | |
| 957 | + "schema": { | |
| 958 | + "$ref": "#/components/schemas/Problem" | |
| 959 | + } | |
| 960 | + } | |
| 961 | + } | |
| 962 | + }, | |
| 963 | + "429": { | |
| 964 | + "description": "Rate limit or quota exceeded", | |
| 965 | + "content": { | |
| 966 | + "application/problem+json": { | |
| 967 | + "schema": { | |
| 968 | + "$ref": "#/components/schemas/Problem" | |
| 969 | + } | |
| 970 | + } | |
| 971 | + } | |
| 972 | + } | |
| 973 | + } | |
| 974 | + } | |
| 975 | + }, | |
| 976 | + "/v1/markets/{slug}": { | |
| 977 | + "get": { | |
| 978 | + "tags": [ | |
| 979 | + "markets" | |
| 980 | + ], | |
| 981 | + "summary": "Category market detail: movers, most valuable, most liquid, recent sales, history", | |
| 982 | + "parameters": [ | |
| 983 | + { | |
| 984 | + "name": "slug", | |
| 985 | + "in": "path", | |
| 986 | + "required": true, | |
| 987 | + "schema": { | |
| 988 | + "type": "string" | |
| 989 | + }, | |
| 990 | + "example": "pokemon" | |
| 991 | + } | |
| 992 | + ], | |
| 993 | + "responses": { | |
| 994 | + "200": { | |
| 995 | + "description": "OK", | |
| 996 | + "content": { | |
| 997 | + "application/json": { | |
| 998 | + "schema": { | |
| 999 | + "type": "object", | |
| 1000 | + "properties": { | |
| 1001 | + "data": { | |
| 1002 | + "$ref": "#/components/schemas/MarketDetail" | |
| 1003 | + }, | |
| 1004 | + "meta": { | |
| 1005 | + "$ref": "#/components/schemas/Meta" | |
| 1006 | + } | |
| 1007 | + } | |
| 1008 | + } | |
| 1009 | + } | |
| 1010 | + } | |
| 1011 | + }, | |
| 1012 | + "400": { | |
| 1013 | + "description": "Invalid query", | |
| 1014 | + "content": { | |
| 1015 | + "application/problem+json": { | |
| 1016 | + "schema": { | |
| 1017 | + "$ref": "#/components/schemas/Problem" | |
| 1018 | + } | |
| 1019 | + } | |
| 1020 | + } | |
| 1021 | + }, | |
| 1022 | + "401": { | |
| 1023 | + "description": "Invalid API key", | |
| 1024 | + "content": { | |
| 1025 | + "application/problem+json": { | |
| 1026 | + "schema": { | |
| 1027 | + "$ref": "#/components/schemas/Problem" | |
| 1028 | + } | |
| 1029 | + } | |
| 1030 | + } | |
| 1031 | + }, | |
| 1032 | + "404": { | |
| 1033 | + "description": "Not found", | |
| 1034 | + "content": { | |
| 1035 | + "application/problem+json": { | |
| 1036 | + "schema": { | |
| 1037 | + "$ref": "#/components/schemas/Problem" | |
| 1038 | + } | |
| 1039 | + } | |
| 1040 | + } | |
| 1041 | + }, | |
| 1042 | + "429": { | |
| 1043 | + "description": "Rate limit or quota exceeded", | |
| 1044 | + "content": { | |
| 1045 | + "application/problem+json": { | |
| 1046 | + "schema": { | |
| 1047 | + "$ref": "#/components/schemas/Problem" | |
| 1048 | + } | |
| 1049 | + } | |
| 1050 | + } | |
| 1051 | + } | |
| 1052 | + } | |
| 1053 | + } | |
| 1054 | + }, | |
| 1055 | + "/v1/trending": { | |
| 1056 | + "get": { | |
| 1057 | + "tags": [ | |
| 1058 | + "markets" | |
| 1059 | + ], | |
| 1060 | + "summary": "Trending assets", | |
| 1061 | + "parameters": [ | |
| 1062 | + { | |
| 1063 | + "name": "category", | |
| 1064 | + "in": "query", | |
| 1065 | + "schema": { | |
| 1066 | + "type": "string" | |
| 1067 | + } | |
| 1068 | + }, | |
| 1069 | + { | |
| 1070 | + "name": "limit", | |
| 1071 | + "in": "query", | |
| 1072 | + "schema": { | |
| 1073 | + "type": "integer", | |
| 1074 | + "minimum": 1, | |
| 1075 | + "maximum": 200, | |
| 1076 | + "default": 50 | |
| 1077 | + } | |
| 1078 | + }, | |
| 1079 | + { | |
| 1080 | + "name": "cursor", | |
| 1081 | + "in": "query", | |
| 1082 | + "schema": { | |
| 1083 | + "type": "string" | |
| 1084 | + }, | |
| 1085 | + "description": "Opaque cursor from meta.cursor" | |
| 1086 | + }, | |
| 1087 | + { | |
| 1088 | + "name": "format", | |
| 1089 | + "in": "query", | |
| 1090 | + "schema": { | |
| 1091 | + "type": "string", | |
| 1092 | + "enum": [ | |
| 1093 | + "json", | |
| 1094 | + "csv" | |
| 1095 | + ] | |
| 1096 | + }, | |
| 1097 | + "description": "csv returns a flat export of the rows" | |
| 1098 | + } | |
| 1099 | + ], | |
| 1100 | + "responses": { | |
| 1101 | + "200": { | |
| 1102 | + "description": "OK", | |
| 1103 | + "content": { | |
| 1104 | + "application/json": { | |
| 1105 | + "schema": { | |
| 1106 | + "type": "object", | |
| 1107 | + "properties": { | |
| 1108 | + "data": { | |
| 1109 | + "type": "array", | |
| 1110 | + "items": { | |
| 1111 | + "$ref": "#/components/schemas/AssetSummary" | |
| 1112 | + } | |
| 1113 | + }, | |
| 1114 | + "meta": { | |
| 1115 | + "$ref": "#/components/schemas/Meta" | |
| 1116 | + } | |
| 1117 | + } | |
| 1118 | + } | |
| 1119 | + } | |
| 1120 | + } | |
| 1121 | + }, | |
| 1122 | + "400": { | |
| 1123 | + "description": "Invalid query", | |
| 1124 | + "content": { | |
| 1125 | + "application/problem+json": { | |
| 1126 | + "schema": { | |
| 1127 | + "$ref": "#/components/schemas/Problem" | |
| 1128 | + } | |
| 1129 | + } | |
| 1130 | + } | |
| 1131 | + }, | |
| 1132 | + "401": { | |
| 1133 | + "description": "Invalid API key", | |
| 1134 | + "content": { | |
| 1135 | + "application/problem+json": { | |
| 1136 | + "schema": { | |
| 1137 | + "$ref": "#/components/schemas/Problem" | |
| 1138 | + } | |
| 1139 | + } | |
| 1140 | + } | |
| 1141 | + }, | |
| 1142 | + "404": { | |
| 1143 | + "description": "Not found", | |
| 1144 | + "content": { | |
| 1145 | + "application/problem+json": { | |
| 1146 | + "schema": { | |
| 1147 | + "$ref": "#/components/schemas/Problem" | |
| 1148 | + } | |
| 1149 | + } | |
| 1150 | + } | |
| 1151 | + }, | |
| 1152 | + "429": { | |
| 1153 | + "description": "Rate limit or quota exceeded", | |
| 1154 | + "content": { | |
| 1155 | + "application/problem+json": { | |
| 1156 | + "schema": { | |
| 1157 | + "$ref": "#/components/schemas/Problem" | |
| 1158 | + } | |
| 1159 | + } | |
| 1160 | + } | |
| 1161 | + } | |
| 1162 | + } | |
| 1163 | + } | |
| 1164 | + }, | |
| 1165 | + "/v1/sales/latest": { | |
| 1166 | + "get": { | |
| 1167 | + "tags": [ | |
| 1168 | + "sales" | |
| 1169 | + ], | |
| 1170 | + "summary": "Latest observed sales across the platform", | |
| 1171 | + "parameters": [ | |
| 1172 | + { | |
| 1173 | + "name": "category", | |
| 1174 | + "in": "query", | |
| 1175 | + "schema": { | |
| 1176 | + "type": "string" | |
| 1177 | + } | |
| 1178 | + }, | |
| 1179 | + { | |
| 1180 | + "name": "min_usd", | |
| 1181 | + "in": "query", | |
| 1182 | + "schema": { | |
| 1183 | + "type": "number" | |
| 1184 | + } | |
| 1185 | + }, | |
| 1186 | + { | |
| 1187 | + "name": "limit", | |
| 1188 | + "in": "query", | |
| 1189 | + "schema": { | |
| 1190 | + "type": "integer", | |
| 1191 | + "minimum": 1, | |
| 1192 | + "maximum": 200, | |
| 1193 | + "default": 50 | |
| 1194 | + } | |
| 1195 | + }, | |
| 1196 | + { | |
| 1197 | + "name": "cursor", | |
| 1198 | + "in": "query", | |
| 1199 | + "schema": { | |
| 1200 | + "type": "string" | |
| 1201 | + }, | |
| 1202 | + "description": "Opaque cursor from meta.cursor" | |
| 1203 | + }, | |
| 1204 | + { | |
| 1205 | + "name": "format", | |
| 1206 | + "in": "query", | |
| 1207 | + "schema": { | |
| 1208 | + "type": "string", | |
| 1209 | + "enum": [ | |
| 1210 | + "json", | |
| 1211 | + "csv" | |
| 1212 | + ] | |
| 1213 | + }, | |
| 1214 | + "description": "csv returns a flat export of the rows" | |
| 1215 | + } | |
| 1216 | + ], | |
| 1217 | + "responses": { | |
| 1218 | + "200": { | |
| 1219 | + "description": "OK", | |
| 1220 | + "content": { | |
| 1221 | + "application/json": { | |
| 1222 | + "schema": { | |
| 1223 | + "type": "object", | |
| 1224 | + "properties": { | |
| 1225 | + "data": { | |
| 1226 | + "type": "array", | |
| 1227 | + "items": { | |
| 1228 | + "$ref": "#/components/schemas/SaleWithAsset" | |
| 1229 | + } | |
| 1230 | + }, | |
| 1231 | + "meta": { | |
| 1232 | + "$ref": "#/components/schemas/Meta" | |
| 1233 | + } | |
| 1234 | + } | |
| 1235 | + } | |
| 1236 | + } | |
| 1237 | + } | |
| 1238 | + }, | |
| 1239 | + "400": { | |
| 1240 | + "description": "Invalid query", | |
| 1241 | + "content": { | |
| 1242 | + "application/problem+json": { | |
| 1243 | + "schema": { | |
| 1244 | + "$ref": "#/components/schemas/Problem" | |
| 1245 | + } | |
| 1246 | + } | |
| 1247 | + } | |
| 1248 | + }, | |
| 1249 | + "401": { | |
| 1250 | + "description": "Invalid API key", | |
| 1251 | + "content": { | |
| 1252 | + "application/problem+json": { | |
| 1253 | + "schema": { | |
| 1254 | + "$ref": "#/components/schemas/Problem" | |
| 1255 | + } | |
| 1256 | + } | |
| 1257 | + } | |
| 1258 | + }, | |
| 1259 | + "404": { | |
| 1260 | + "description": "Not found", | |
| 1261 | + "content": { | |
| 1262 | + "application/problem+json": { | |
| 1263 | + "schema": { | |
| 1264 | + "$ref": "#/components/schemas/Problem" | |
| 1265 | + } | |
| 1266 | + } | |
| 1267 | + } | |
| 1268 | + }, | |
| 1269 | + "429": { | |
| 1270 | + "description": "Rate limit or quota exceeded", | |
| 1271 | + "content": { | |
| 1272 | + "application/problem+json": { | |
| 1273 | + "schema": { | |
| 1274 | + "$ref": "#/components/schemas/Problem" | |
| 1275 | + } | |
| 1276 | + } | |
| 1277 | + } | |
| 1278 | + } | |
| 1279 | + } | |
| 1280 | + } | |
| 1281 | + }, | |
| 1282 | + "/v1/records": { | |
| 1283 | + "get": { | |
| 1284 | + "tags": [ | |
| 1285 | + "sales" | |
| 1286 | + ], | |
| 1287 | + "summary": "Record sale per family (verified transactions only)", | |
| 1288 | + "parameters": [ | |
| 1289 | + { | |
| 1290 | + "name": "format", | |
| 1291 | + "in": "query", | |
| 1292 | + "schema": { | |
| 1293 | + "type": "string", | |
| 1294 | + "enum": [ | |
| 1295 | + "json", | |
| 1296 | + "csv" | |
| 1297 | + ] | |
| 1298 | + }, | |
| 1299 | + "description": "csv returns a flat export of the rows" | |
| 1300 | + } | |
| 1301 | + ], | |
| 1302 | + "responses": { | |
| 1303 | + "200": { | |
| 1304 | + "description": "OK", | |
| 1305 | + "content": { | |
| 1306 | + "application/json": { | |
| 1307 | + "schema": { | |
| 1308 | + "type": "object", | |
| 1309 | + "properties": { | |
| 1310 | + "data": { | |
| 1311 | + "type": "array", | |
| 1312 | + "items": { | |
| 1313 | + "$ref": "#/components/schemas/SaleWithAsset" | |
| 1314 | + } | |
| 1315 | + }, | |
| 1316 | + "meta": { | |
| 1317 | + "$ref": "#/components/schemas/Meta" | |
| 1318 | + } | |
| 1319 | + } | |
| 1320 | + } | |
| 1321 | + } | |
| 1322 | + } | |
| 1323 | + }, | |
| 1324 | + "400": { | |
| 1325 | + "description": "Invalid query", | |
| 1326 | + "content": { | |
| 1327 | + "application/problem+json": { | |
| 1328 | + "schema": { | |
| 1329 | + "$ref": "#/components/schemas/Problem" | |
| 1330 | + } | |
| 1331 | + } | |
| 1332 | + } | |
| 1333 | + }, | |
| 1334 | + "401": { | |
| 1335 | + "description": "Invalid API key", | |
| 1336 | + "content": { | |
| 1337 | + "application/problem+json": { | |
| 1338 | + "schema": { | |
| 1339 | + "$ref": "#/components/schemas/Problem" | |
| 1340 | + } | |
| 1341 | + } | |
| 1342 | + } | |
| 1343 | + }, | |
| 1344 | + "404": { | |
| 1345 | + "description": "Not found", | |
| 1346 | + "content": { | |
| 1347 | + "application/problem+json": { | |
| 1348 | + "schema": { | |
| 1349 | + "$ref": "#/components/schemas/Problem" | |
| 1350 | + } | |
| 1351 | + } | |
| 1352 | + } | |
| 1353 | + }, | |
| 1354 | + "429": { | |
| 1355 | + "description": "Rate limit or quota exceeded", | |
| 1356 | + "content": { | |
| 1357 | + "application/problem+json": { | |
| 1358 | + "schema": { | |
| 1359 | + "$ref": "#/components/schemas/Problem" | |
| 1360 | + } | |
| 1361 | + } | |
| 1362 | + } | |
| 1363 | + } | |
| 1364 | + } | |
| 1365 | + } | |
| 1366 | + }, | |
| 1367 | + "/v1/stats": { | |
| 1368 | + "get": { | |
| 1369 | + "tags": [ | |
| 1370 | + "reference" | |
| 1371 | + ], | |
| 1372 | + "summary": "Platform counts", | |
| 1373 | + "responses": { | |
| 1374 | + "200": { | |
| 1375 | + "description": "OK", | |
| 1376 | + "content": { | |
| 1377 | + "application/json": { | |
| 1378 | + "schema": { | |
| 1379 | + "type": "object", | |
| 1380 | + "properties": { | |
| 1381 | + "data": { | |
| 1382 | + "$ref": "#/components/schemas/Stats" | |
| 1383 | + }, | |
| 1384 | + "meta": { | |
| 1385 | + "$ref": "#/components/schemas/Meta" | |
| 1386 | + } | |
| 1387 | + } | |
| 1388 | + } | |
| 1389 | + } | |
| 1390 | + } | |
| 1391 | + } | |
| 1392 | + } | |
| 1393 | + } | |
| 1394 | + } | |
| 1395 | + }, | |
| 1396 | + "components": { | |
| 1397 | + "securitySchemes": { | |
| 1398 | + "bearerAuth": { | |
| 1399 | + "type": "http", | |
| 1400 | + "scheme": "bearer", | |
| 1401 | + "description": "API key `ri_live_…`. Requests without a key use the public tier (20 req/min)." | |
| 1402 | + } | |
| 1403 | + }, | |
| 1404 | + "schemas": { | |
| 1405 | + "Meta": { | |
| 1406 | + "type": "object", | |
| 1407 | + "properties": { | |
| 1408 | + "count": { | |
| 1409 | + "type": "integer" | |
| 1410 | + }, | |
| 1411 | + "cursor": { | |
| 1412 | + "type": [ | |
| 1413 | + "string", | |
| 1414 | + "null" | |
| 1415 | + ] | |
| 1416 | + }, | |
| 1417 | + "as_of": { | |
| 1418 | + "type": "string", | |
| 1419 | + "format": "date-time" | |
| 1420 | + }, | |
| 1421 | + "attribution": { | |
| 1422 | + "type": "string" | |
| 1423 | + } | |
| 1424 | + }, | |
| 1425 | + "additionalProperties": true | |
| 1426 | + }, | |
| 1427 | + "Problem": { | |
| 1428 | + "type": "object", | |
| 1429 | + "properties": { | |
| 1430 | + "type": { | |
| 1431 | + "type": "string" | |
| 1432 | + }, | |
| 1433 | + "title": { | |
| 1434 | + "type": "string" | |
| 1435 | + }, | |
| 1436 | + "status": { | |
| 1437 | + "type": "integer" | |
| 1438 | + }, | |
| 1439 | + "detail": { | |
| 1440 | + "type": "string" | |
| 1441 | + }, | |
| 1442 | + "instance": { | |
| 1443 | + "type": "string" | |
| 1444 | + }, | |
| 1445 | + "request_id": { | |
| 1446 | + "type": "string" | |
| 1447 | + } | |
| 1448 | + } | |
| 1449 | + }, | |
| 1450 | + "AssetSummary": { | |
| 1451 | + "type": "object", | |
| 1452 | + "properties": { | |
| 1453 | + "id": { | |
| 1454 | + "type": "string" | |
| 1455 | + }, | |
| 1456 | + "slug": { | |
| 1457 | + "type": "string" | |
| 1458 | + }, | |
| 1459 | + "title": { | |
| 1460 | + "type": "string" | |
| 1461 | + }, | |
| 1462 | + "name": { | |
| 1463 | + "type": "string" | |
| 1464 | + }, | |
| 1465 | + "category_slug": { | |
| 1466 | + "type": "string" | |
| 1467 | + }, | |
| 1468 | + "family_slug": { | |
| 1469 | + "type": "string" | |
| 1470 | + }, | |
| 1471 | + "brand": { | |
| 1472 | + "type": [ | |
| 1473 | + "string", | |
| 1474 | + "null" | |
| 1475 | + ] | |
| 1476 | + }, | |
| 1477 | + "franchise": { | |
| 1478 | + "type": [ | |
| 1479 | + "string", | |
| 1480 | + "null" | |
| 1481 | + ] | |
| 1482 | + }, | |
| 1483 | + "set_name": { | |
| 1484 | + "type": [ | |
| 1485 | + "string", | |
| 1486 | + "null" | |
| 1487 | + ] | |
| 1488 | + }, | |
| 1489 | + "number": { | |
| 1490 | + "type": [ | |
| 1491 | + "string", | |
| 1492 | + "null" | |
| 1493 | + ] | |
| 1494 | + }, | |
| 1495 | + "year": { | |
| 1496 | + "type": [ | |
| 1497 | + "integer", | |
| 1498 | + "null" | |
| 1499 | + ] | |
| 1500 | + }, | |
| 1501 | + "variant": { | |
| 1502 | + "type": [ | |
| 1503 | + "string", | |
| 1504 | + "null" | |
| 1505 | + ] | |
| 1506 | + }, | |
| 1507 | + "hero_image_url": { | |
| 1508 | + "type": [ | |
| 1509 | + "string", | |
| 1510 | + "null" | |
| 1511 | + ] | |
| 1512 | + }, | |
| 1513 | + "riv_usd": { | |
| 1514 | + "type": [ | |
| 1515 | + "number", | |
| 1516 | + "null" | |
| 1517 | + ], | |
| 1518 | + "description": "RareIndex Valuation (estimate)" | |
| 1519 | + }, | |
| 1520 | + "riv_low_usd": { | |
| 1521 | + "type": [ | |
| 1522 | + "number", | |
| 1523 | + "null" | |
| 1524 | + ] | |
| 1525 | + }, | |
| 1526 | + "riv_high_usd": { | |
| 1527 | + "type": [ | |
| 1528 | + "number", | |
| 1529 | + "null" | |
| 1530 | + ] | |
| 1531 | + }, | |
| 1532 | + "riv_confidence": { | |
| 1533 | + "type": [ | |
| 1534 | + "number", | |
| 1535 | + "null" | |
| 1536 | + ] | |
| 1537 | + }, | |
| 1538 | + "riv_sample_size": { | |
| 1539 | + "type": "integer" | |
| 1540 | + }, | |
| 1541 | + "latest_sale_usd": { | |
| 1542 | + "type": [ | |
| 1543 | + "number", | |
| 1544 | + "null" | |
| 1545 | + ] | |
| 1546 | + }, | |
| 1547 | + "latest_sale_at": { | |
| 1548 | + "type": [ | |
| 1549 | + "string", | |
| 1550 | + "null" | |
| 1551 | + ] | |
| 1552 | + }, | |
| 1553 | + "change_7d": { | |
| 1554 | + "type": [ | |
| 1555 | + "number", | |
| 1556 | + "null" | |
| 1557 | + ] | |
| 1558 | + }, | |
| 1559 | + "change_30d": { | |
| 1560 | + "type": [ | |
| 1561 | + "number", | |
| 1562 | + "null" | |
| 1563 | + ] | |
| 1564 | + }, | |
| 1565 | + "change_1y": { | |
| 1566 | + "type": [ | |
| 1567 | + "number", | |
| 1568 | + "null" | |
| 1569 | + ] | |
| 1570 | + }, | |
| 1571 | + "sales_count": { | |
| 1572 | + "type": "integer" | |
| 1573 | + }, | |
| 1574 | + "sales_30d": { | |
| 1575 | + "type": "integer" | |
| 1576 | + }, | |
| 1577 | + "active_listings": { | |
| 1578 | + "type": "integer" | |
| 1579 | + }, | |
| 1580 | + "min_ask_usd": { | |
| 1581 | + "type": [ | |
| 1582 | + "number", | |
| 1583 | + "null" | |
| 1584 | + ] | |
| 1585 | + }, | |
| 1586 | + "liquidity_score": { | |
| 1587 | + "type": [ | |
| 1588 | + "number", | |
| 1589 | + "null" | |
| 1590 | + ] | |
| 1591 | + }, | |
| 1592 | + "rarity_score": { | |
| 1593 | + "type": [ | |
| 1594 | + "number", | |
| 1595 | + "null" | |
| 1596 | + ] | |
| 1597 | + }, | |
| 1598 | + "trending_score": { | |
| 1599 | + "type": [ | |
| 1600 | + "number", | |
| 1601 | + "null" | |
| 1602 | + ] | |
| 1603 | + } | |
| 1604 | + } | |
| 1605 | + }, | |
| 1606 | + "AssetDetail": { | |
| 1607 | + "allOf": [ | |
| 1608 | + { | |
| 1609 | + "$ref": "#/components/schemas/AssetSummary" | |
| 1610 | + }, | |
| 1611 | + { | |
| 1612 | + "type": "object", | |
| 1613 | + "properties": { | |
| 1614 | + "description": { | |
| 1615 | + "type": [ | |
| 1616 | + "string", | |
| 1617 | + "null" | |
| 1618 | + ] | |
| 1619 | + }, | |
| 1620 | + "identifiers": { | |
| 1621 | + "type": "object" | |
| 1622 | + }, | |
| 1623 | + "variants": { | |
| 1624 | + "type": "array", | |
| 1625 | + "items": { | |
| 1626 | + "type": "object" | |
| 1627 | + } | |
| 1628 | + }, | |
| 1629 | + "valuation": { | |
| 1630 | + "type": [ | |
| 1631 | + "object", | |
| 1632 | + "null" | |
| 1633 | + ] | |
| 1634 | + }, | |
| 1635 | + "sources": { | |
| 1636 | + "type": "array", | |
| 1637 | + "items": { | |
| 1638 | + "type": "object" | |
| 1639 | + } | |
| 1640 | + } | |
| 1641 | + } | |
| 1642 | + } | |
| 1643 | + ] | |
| 1644 | + }, | |
| 1645 | + "Sale": { | |
| 1646 | + "type": "object", | |
| 1647 | + "properties": { | |
| 1648 | + "id": { | |
| 1649 | + "type": "string" | |
| 1650 | + }, | |
| 1651 | + "source_id": { | |
| 1652 | + "type": "string" | |
| 1653 | + }, | |
| 1654 | + "source_url": { | |
| 1655 | + "type": "string" | |
| 1656 | + }, | |
| 1657 | + "sale_type": { | |
| 1658 | + "type": "string" | |
| 1659 | + }, | |
| 1660 | + "sale_date": { | |
| 1661 | + "type": "string" | |
| 1662 | + }, | |
| 1663 | + "price": { | |
| 1664 | + "type": "number" | |
| 1665 | + }, | |
| 1666 | + "currency": { | |
| 1667 | + "type": "string" | |
| 1668 | + }, | |
| 1669 | + "price_usd": { | |
| 1670 | + "type": "number" | |
| 1671 | + }, | |
| 1672 | + "grader": { | |
| 1673 | + "type": [ | |
| 1674 | + "string", | |
| 1675 | + "null" | |
| 1676 | + ] | |
| 1677 | + }, | |
| 1678 | + "grade": { | |
| 1679 | + "type": [ | |
| 1680 | + "string", | |
| 1681 | + "null" | |
| 1682 | + ] | |
| 1683 | + }, | |
| 1684 | + "condition": { | |
| 1685 | + "type": [ | |
| 1686 | + "string", | |
| 1687 | + "null" | |
| 1688 | + ] | |
| 1689 | + }, | |
| 1690 | + "status": { | |
| 1691 | + "type": "string" | |
| 1692 | + }, | |
| 1693 | + "confidence": { | |
| 1694 | + "type": "number" | |
| 1695 | + } | |
| 1696 | + } | |
| 1697 | + }, | |
| 1698 | + "SaleWithAsset": { | |
| 1699 | + "allOf": [ | |
| 1700 | + { | |
| 1701 | + "$ref": "#/components/schemas/Sale" | |
| 1702 | + }, | |
| 1703 | + { | |
| 1704 | + "type": "object", | |
| 1705 | + "properties": { | |
| 1706 | + "asset_id": { | |
| 1707 | + "type": "string" | |
| 1708 | + }, | |
| 1709 | + "asset_slug": { | |
| 1710 | + "type": "string" | |
| 1711 | + }, | |
| 1712 | + "title": { | |
| 1713 | + "type": "string" | |
| 1714 | + } | |
| 1715 | + } | |
| 1716 | + } | |
| 1717 | + ] | |
| 1718 | + }, | |
| 1719 | + "Listing": { | |
| 1720 | + "type": "object", | |
| 1721 | + "properties": { | |
| 1722 | + "id": { | |
| 1723 | + "type": "string" | |
| 1724 | + }, | |
| 1725 | + "source_id": { | |
| 1726 | + "type": "string" | |
| 1727 | + }, | |
| 1728 | + "source_url": { | |
| 1729 | + "type": "string" | |
| 1730 | + }, | |
| 1731 | + "listing_type": { | |
| 1732 | + "type": "string" | |
| 1733 | + }, | |
| 1734 | + "price": { | |
| 1735 | + "type": [ | |
| 1736 | + "number", | |
| 1737 | + "null" | |
| 1738 | + ] | |
| 1739 | + }, | |
| 1740 | + "currency": { | |
| 1741 | + "type": [ | |
| 1742 | + "string", | |
| 1743 | + "null" | |
| 1744 | + ] | |
| 1745 | + }, | |
| 1746 | + "price_usd": { | |
| 1747 | + "type": [ | |
| 1748 | + "number", | |
| 1749 | + "null" | |
| 1750 | + ] | |
| 1751 | + }, | |
| 1752 | + "availability": { | |
| 1753 | + "type": "string" | |
| 1754 | + }, | |
| 1755 | + "discount_to_riv": { | |
| 1756 | + "type": [ | |
| 1757 | + "number", | |
| 1758 | + "null" | |
| 1759 | + ] | |
| 1760 | + } | |
| 1761 | + } | |
| 1762 | + }, | |
| 1763 | + "PricePoint": { | |
| 1764 | + "type": "object", | |
| 1765 | + "properties": { | |
| 1766 | + "date": { | |
| 1767 | + "type": "string", | |
| 1768 | + "format": "date" | |
| 1769 | + }, | |
| 1770 | + "riv_usd": { | |
| 1771 | + "type": [ | |
| 1772 | + "number", | |
| 1773 | + "null" | |
| 1774 | + ] | |
| 1775 | + }, | |
| 1776 | + "latest_sale_usd": { | |
| 1777 | + "type": [ | |
| 1778 | + "number", | |
| 1779 | + "null" | |
| 1780 | + ] | |
| 1781 | + }, | |
| 1782 | + "median_usd": { | |
| 1783 | + "type": [ | |
| 1784 | + "number", | |
| 1785 | + "null" | |
| 1786 | + ] | |
| 1787 | + }, | |
| 1788 | + "sales_count": { | |
| 1789 | + "type": "integer" | |
| 1790 | + }, | |
| 1791 | + "volume_usd": { | |
| 1792 | + "type": [ | |
| 1793 | + "number", | |
| 1794 | + "null" | |
| 1795 | + ] | |
| 1796 | + }, | |
| 1797 | + "listings_count": { | |
| 1798 | + "type": "integer" | |
| 1799 | + }, | |
| 1800 | + "min_ask_usd": { | |
| 1801 | + "type": [ | |
| 1802 | + "number", | |
| 1803 | + "null" | |
| 1804 | + ] | |
| 1805 | + } | |
| 1806 | + } | |
| 1807 | + }, | |
| 1808 | + "Category": { | |
| 1809 | + "type": "object", | |
| 1810 | + "properties": { | |
| 1811 | + "slug": { | |
| 1812 | + "type": "string" | |
| 1813 | + }, | |
| 1814 | + "parent_slug": { | |
| 1815 | + "type": [ | |
| 1816 | + "string", | |
| 1817 | + "null" | |
| 1818 | + ] | |
| 1819 | + }, | |
| 1820 | + "family_slug": { | |
| 1821 | + "type": "string" | |
| 1822 | + }, | |
| 1823 | + "name": { | |
| 1824 | + "type": "string" | |
| 1825 | + }, | |
| 1826 | + "level": { | |
| 1827 | + "type": "integer" | |
| 1828 | + }, | |
| 1829 | + "phase": { | |
| 1830 | + "type": "integer" | |
| 1831 | + }, | |
| 1832 | + "index_ticker": { | |
| 1833 | + "type": [ | |
| 1834 | + "string", | |
| 1835 | + "null" | |
| 1836 | + ] | |
| 1837 | + }, | |
| 1838 | + "tracked_assets": { | |
| 1839 | + "type": "integer" | |
| 1840 | + } | |
| 1841 | + } | |
| 1842 | + }, | |
| 1843 | + "Index": { | |
| 1844 | + "type": "object", | |
| 1845 | + "properties": { | |
| 1846 | + "ticker": { | |
| 1847 | + "type": "string" | |
| 1848 | + }, | |
| 1849 | + "name": { | |
| 1850 | + "type": "string" | |
| 1851 | + }, | |
| 1852 | + "is_flagship": { | |
| 1853 | + "type": "boolean" | |
| 1854 | + }, | |
| 1855 | + "as_of": { | |
| 1856 | + "type": [ | |
| 1857 | + "string", | |
| 1858 | + "null" | |
| 1859 | + ] | |
| 1860 | + }, | |
| 1861 | + "value": { | |
| 1862 | + "type": [ | |
| 1863 | + "number", | |
| 1864 | + "null" | |
| 1865 | + ] | |
| 1866 | + }, | |
| 1867 | + "published": { | |
| 1868 | + "type": "boolean" | |
| 1869 | + }, | |
| 1870 | + "change_1d": { | |
| 1871 | + "type": [ | |
| 1872 | + "number", | |
| 1873 | + "null" | |
| 1874 | + ] | |
| 1875 | + }, | |
| 1876 | + "change_7d": { | |
| 1877 | + "type": [ | |
| 1878 | + "number", | |
| 1879 | + "null" | |
| 1880 | + ] | |
| 1881 | + }, | |
| 1882 | + "change_30d": { | |
| 1883 | + "type": [ | |
| 1884 | + "number", | |
| 1885 | + "null" | |
| 1886 | + ] | |
| 1887 | + }, | |
| 1888 | + "change_ytd": { | |
| 1889 | + "type": [ | |
| 1890 | + "number", | |
| 1891 | + "null" | |
| 1892 | + ] | |
| 1893 | + }, | |
| 1894 | + "change_1y": { | |
| 1895 | + "type": [ | |
| 1896 | + "number", | |
| 1897 | + "null" | |
| 1898 | + ] | |
| 1899 | + }, | |
| 1900 | + "constituents_count": { | |
| 1901 | + "type": [ | |
| 1902 | + "integer", | |
| 1903 | + "null" | |
| 1904 | + ] | |
| 1905 | + }, | |
| 1906 | + "transactions": { | |
| 1907 | + "type": [ | |
| 1908 | + "integer", | |
| 1909 | + "null" | |
| 1910 | + ] | |
| 1911 | + }, | |
| 1912 | + "market_cap_est_usd": { | |
| 1913 | + "type": [ | |
| 1914 | + "number", | |
| 1915 | + "null" | |
| 1916 | + ] | |
| 1917 | + }, | |
| 1918 | + "market_cap_confidence": { | |
| 1919 | + "type": [ | |
| 1920 | + "string", | |
| 1921 | + "null" | |
| 1922 | + ] | |
| 1923 | + } | |
| 1924 | + } | |
| 1925 | + }, | |
| 1926 | + "IndexPoint": { | |
| 1927 | + "type": "object", | |
| 1928 | + "properties": { | |
| 1929 | + "date": { | |
| 1930 | + "type": "string", | |
| 1931 | + "format": "date" | |
| 1932 | + }, | |
| 1933 | + "value": { | |
| 1934 | + "type": "number" | |
| 1935 | + }, | |
| 1936 | + "constituents_count": { | |
| 1937 | + "type": "integer" | |
| 1938 | + }, | |
| 1939 | + "transactions": { | |
| 1940 | + "type": "integer" | |
| 1941 | + }, | |
| 1942 | + "volume_usd": { | |
| 1943 | + "type": [ | |
| 1944 | + "number", | |
| 1945 | + "null" | |
| 1946 | + ] | |
| 1947 | + } | |
| 1948 | + } | |
| 1949 | + }, | |
| 1950 | + "Market": { | |
| 1951 | + "type": "object", | |
| 1952 | + "properties": { | |
| 1953 | + "slug": { | |
| 1954 | + "type": "string" | |
| 1955 | + }, | |
| 1956 | + "name": { | |
| 1957 | + "type": "string" | |
| 1958 | + }, | |
| 1959 | + "as_of": { | |
| 1960 | + "type": [ | |
| 1961 | + "string", | |
| 1962 | + "null" | |
| 1963 | + ] | |
| 1964 | + }, | |
| 1965 | + "index_value": { | |
| 1966 | + "type": [ | |
| 1967 | + "number", | |
| 1968 | + "null" | |
| 1969 | + ] | |
| 1970 | + }, | |
| 1971 | + "tracked_assets": { | |
| 1972 | + "type": [ | |
| 1973 | + "integer", | |
| 1974 | + "null" | |
| 1975 | + ] | |
| 1976 | + }, | |
| 1977 | + "sales": { | |
| 1978 | + "type": [ | |
| 1979 | + "integer", | |
| 1980 | + "null" | |
| 1981 | + ] | |
| 1982 | + }, | |
| 1983 | + "volume_usd": { | |
| 1984 | + "type": [ | |
| 1985 | + "number", | |
| 1986 | + "null" | |
| 1987 | + ] | |
| 1988 | + }, | |
| 1989 | + "change_30d": { | |
| 1990 | + "type": [ | |
| 1991 | + "number", | |
| 1992 | + "null" | |
| 1993 | + ] | |
| 1994 | + } | |
| 1995 | + } | |
| 1996 | + }, | |
| 1997 | + "MarketDetail": { | |
| 1998 | + "type": "object", | |
| 1999 | + "properties": { | |
| 2000 | + "category": { | |
| 2001 | + "type": "object" | |
| 2002 | + }, | |
| 2003 | + "snapshot": { | |
| 2004 | + "type": [ | |
| 2005 | + "object", | |
| 2006 | + "null" | |
| 2007 | + ] | |
| 2008 | + }, | |
| 2009 | + "counts": { | |
| 2010 | + "type": "object" | |
| 2011 | + }, | |
| 2012 | + "gainers": { | |
| 2013 | + "type": "array", | |
| 2014 | + "items": { | |
| 2015 | + "$ref": "#/components/schemas/AssetSummary" | |
| 2016 | + } | |
| 2017 | + }, | |
| 2018 | + "losers": { | |
| 2019 | + "type": "array", | |
| 2020 | + "items": { | |
| 2021 | + "$ref": "#/components/schemas/AssetSummary" | |
| 2022 | + } | |
| 2023 | + }, | |
| 2024 | + "most_valuable": { | |
| 2025 | + "type": "array", | |
| 2026 | + "items": { | |
| 2027 | + "$ref": "#/components/schemas/AssetSummary" | |
| 2028 | + } | |
| 2029 | + }, | |
| 2030 | + "most_liquid": { | |
| 2031 | + "type": "array", | |
| 2032 | + "items": { | |
| 2033 | + "$ref": "#/components/schemas/AssetSummary" | |
| 2034 | + } | |
| 2035 | + }, | |
| 2036 | + "recent_sales": { | |
| 2037 | + "type": "array", | |
| 2038 | + "items": { | |
| 2039 | + "type": "object" | |
| 2040 | + } | |
| 2041 | + }, | |
| 2042 | + "history": { | |
| 2043 | + "type": "array", | |
| 2044 | + "items": { | |
| 2045 | + "type": "object" | |
| 2046 | + } | |
| 2047 | + } | |
| 2048 | + } | |
| 2049 | + }, | |
| 2050 | + "Stats": { | |
| 2051 | + "type": "object", | |
| 2052 | + "properties": { | |
| 2053 | + "assets": { | |
| 2054 | + "type": "integer" | |
| 2055 | + }, | |
| 2056 | + "sales": { | |
| 2057 | + "type": "integer" | |
| 2058 | + }, | |
| 2059 | + "listings": { | |
| 2060 | + "type": "integer" | |
| 2061 | + }, | |
| 2062 | + "sources": { | |
| 2063 | + "type": "integer" | |
| 2064 | + }, | |
| 2065 | + "connectors": { | |
| 2066 | + "type": "integer" | |
| 2067 | + }, | |
| 2068 | + "categories": { | |
| 2069 | + "type": "integer" | |
| 2070 | + } | |
| 2071 | + } | |
| 2072 | + } | |
| 2073 | + } | |
| 2074 | + } | |
| 2075 | +} | |
added
packages/ai/package.json
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@rareindex/ai", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "exports": { | |
| 7 | + ".": { | |
| 8 | + "types": "./src/index.ts", | |
| 9 | + "default": "./src/index.ts" | |
| 10 | + } | |
| 11 | + }, | |
| 12 | + "scripts": { | |
| 13 | + "build": "tsc -p tsconfig.json --noEmit", | |
| 14 | + "typecheck": "tsc -p tsconfig.json --noEmit", | |
| 15 | + "test": "vitest run --passWithNoTests" | |
| 16 | + }, | |
| 17 | + "dependencies": { | |
| 18 | + "@anthropic-ai/sdk": "^0.124.0", | |
| 19 | + "@rareindex/database": "workspace:*", | |
| 20 | + "@rareindex/shared": "workspace:*", | |
| 21 | + "@rareindex/taxonomy": "workspace:*", | |
| 22 | + "openai": "^7.10.0", | |
| 23 | + "zod": "^4.0.0" | |
| 24 | + }, | |
| 25 | + "devDependencies": { | |
| 26 | + "@types/node": "^24.0.0", | |
| 27 | + "typescript": "^5.9.3", | |
| 28 | + "vitest": "^3.2.0" | |
| 29 | + } | |
| 30 | +} | |
added
packages/ai/src/ai.test.ts
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +import { describe, expect, it, vi, beforeEach } from 'vitest'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { estimateUsd, priceFor, addUsage, ZERO_USAGE } from './pricing.js'; | |
| 4 | +import { ModelRouter } from './router.js'; | |
| 5 | +import { AiNotConfiguredError, type ModelProvider, type Role } from './types.js'; | |
| 6 | +import { recentCosts, setCostSink, recordCost } from './costs.js'; | |
| 7 | +import { identifyCollectible, IdentificationSchema } from './helpers/identify.js'; | |
| 8 | + | |
| 9 | +describe('pricing', () => { | |
| 10 | + it('estimates usd from the price table', () => { | |
| 11 | + expect(priceFor('claude-opus-5')?.input).toBe(5); | |
| 12 | + expect(estimateUsd('claude-haiku-4-5', { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })).toBe(1); | |
| 13 | + expect(estimateUsd('claude-opus-5', { inputTokens: 0, outputTokens: 1000, cacheReadTokens: 1_000_000, cacheWriteTokens: 0 })).toBeCloseTo(0.025 + 0.5, 5); | |
| 14 | + expect(estimateUsd('unknown-local-model', { ...ZERO_USAGE, inputTokens: 5000 })).toBe(0); | |
| 15 | + expect(addUsage(ZERO_USAGE, { inputTokens: 1, outputTokens: 2, cacheReadTokens: 3, cacheWriteTokens: 4 }).cacheWriteTokens).toBe(4); | |
| 16 | + }); | |
| 17 | +}); | |
| 18 | + | |
| 19 | +describe('router', () => { | |
| 20 | + it('reports not configured without keys', () => { | |
| 21 | + const r = new ModelRouter({ persistCosts: false }); | |
| 22 | + expect(r.configured).toBe(false); | |
| 23 | + expect(() => r.providerFor('research')).toThrow(AiNotConfiguredError); | |
| 24 | + expect(r.table().every((t) => t.provider === null)).toBe(true); | |
| 25 | + }); | |
| 26 | + it('routes roles and honours overrides', () => { | |
| 27 | + const r = new ModelRouter({ anthropicApiKey: 'sk-test', openaiApiKey: 'sk-openai', overrides: { research: 'anthropic:claude-opus-5', classify: 'claude-haiku-4-5', embed: 'openai:text-embedding-3-small' }, persistCosts: false }); | |
| 28 | + expect(r.providerFor('research').id).toBe('anthropic'); | |
| 29 | + expect(r.modelFor('research')).toBe('claude-opus-5'); | |
| 30 | + expect(r.modelFor('normalize')).toBe('claude-haiku-4-5'); | |
| 31 | + expect(r.providerFor('embed').id).toBe('openai'); | |
| 32 | + expect(r.modelFor('vision')).toBe('claude-opus-5'); | |
| 33 | + const table = r.table(); | |
| 34 | + expect(table.find((t) => t.role === 'embed')?.provider).toBe('openai'); | |
| 35 | + }); | |
| 36 | + it('embeddings unavailable with Anthropic only', () => { | |
| 37 | + const r = new ModelRouter({ anthropicApiKey: 'sk-test', persistCosts: false }); | |
| 38 | + expect(r.supports('embed')).toBe(false); | |
| 39 | + expect(() => r.providerFor('embed')).toThrow(/embeddings/); | |
| 40 | + }); | |
| 41 | +}); | |
| 42 | + | |
| 43 | +describe('cost sink', () => { | |
| 44 | + beforeEach(() => setCostSink(null)); | |
| 45 | + it('records and buffers events', async () => { | |
| 46 | + const sink = vi.fn(); | |
| 47 | + setCostSink(sink); | |
| 48 | + await recordCost({ provider: 'anthropic', model: 'claude-haiku-4-5', role: 'classify', usage: ZERO_USAGE, usdEst: 0.001 }); | |
| 49 | + expect(sink).toHaveBeenCalledTimes(1); | |
| 50 | + expect(recentCosts().at(-1)?.role).toBe('classify'); | |
| 51 | + }); | |
| 52 | +}); | |
| 53 | + | |
| 54 | +function fakeProvider(data: unknown): ModelProvider { | |
| 55 | + return { | |
| 56 | + id: 'fake', | |
| 57 | + supports: () => true, | |
| 58 | + modelFor: () => 'fake-model', | |
| 59 | + complete: async () => ({ text: '', model: 'fake-model', provider: 'fake', usage: ZERO_USAGE, usdEst: 0, stopReason: 'end_turn', refusal: null }), | |
| 60 | + stream: async function* () {}, | |
| 61 | + runTools: async function* () {}, | |
| 62 | + extract: async <T,>(_role: Role, req: { schema: z.ZodType<T> }) => { | |
| 63 | + const parsed = req.schema.parse(data); | |
| 64 | + return { data: parsed, confidence: (parsed as { confidence?: number }).confidence ?? 1, model: 'fake-model', provider: 'fake', usage: ZERO_USAGE, usdEst: 0 }; | |
| 65 | + }, | |
| 66 | + vision: async <T,>(req: { schema: z.ZodType<T> }) => ({ data: req.schema.parse(data), confidence: 0.9, model: 'fake-model', provider: 'fake', usage: ZERO_USAGE, usdEst: 0 }), | |
| 67 | + embed: async () => ({ vectors: [], model: 'fake', provider: 'fake', dimensions: 0, usdEst: 0 }), | |
| 68 | + }; | |
| 69 | +} | |
| 70 | + | |
| 71 | +describe('identifyCollectible', () => { | |
| 72 | + it('validates schema and rejects unknown categories', async () => { | |
| 73 | + const guess = IdentificationSchema.parse({ | |
| 74 | + categorySlug: 'not_a_real_slug', | |
| 75 | + name: 'Charizard', | |
| 76 | + brand: null, | |
| 77 | + franchise: 'Pokémon', | |
| 78 | + set: 'Base Set', | |
| 79 | + number: '4/102', | |
| 80 | + year: 1999, | |
| 81 | + variant: '1st Edition Holo', | |
| 82 | + language: 'English', | |
| 83 | + grader: 'psa', | |
| 84 | + grade: '10', | |
| 85 | + certificationNumber: null, | |
| 86 | + conditionNotes: null, | |
| 87 | + likelyGradeRange: null, | |
| 88 | + confidence: 0.82, | |
| 89 | + rationale: 'Holo foil, 1st edition stamp visible', | |
| 90 | + searchQueries: ['Charizard Base Set 1st Edition 4/102'], | |
| 91 | + warnings: [], | |
| 92 | + }); | |
| 93 | + const res = await identifyCollectible({ text: '1999 Pokemon Base Set Charizard 1st Edition PSA 10', provider: fakeProvider(guess) }); | |
| 94 | + expect(res.data.categorySlug).toBeNull(); | |
| 95 | + expect(res.data.warnings[0]).toMatch(/unknown category/); | |
| 96 | + expect(res.confidence).toBeCloseTo(0.82); | |
| 97 | + }); | |
| 98 | + it('requires input', async () => { | |
| 99 | + await expect(identifyCollectible({ provider: fakeProvider({}) })).rejects.toThrow(/provide images or text/); | |
| 100 | + }); | |
| 101 | +}); | |
added
packages/ai/src/costs.ts
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +import { newId, logger } from '@rareindex/shared'; | |
| 2 | +import type { CostContext, Usage } from './types.js'; | |
| 3 | + | |
| 4 | +export interface CostEvent { | |
| 5 | + provider: string; | |
| 6 | + model: string; | |
| 7 | + role: string; | |
| 8 | + usage: Usage; | |
| 9 | + usdEst: number; | |
| 10 | + units?: number; | |
| 11 | + context?: CostContext; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export type CostSink = (event: CostEvent) => Promise<void> | void; | |
| 15 | + | |
| 16 | +let sink: CostSink | null = null; | |
| 17 | +const buffer: CostEvent[] = []; | |
| 18 | + | |
| 19 | +/** Install the persistence sink (the default DB sink is installed lazily by `installDbCostSink`). */ | |
| 20 | +export function setCostSink(fn: CostSink | null): void { | |
| 21 | + sink = fn; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export async function recordCost(event: CostEvent): Promise<void> { | |
| 25 | + buffer.push(event); | |
| 26 | + if (buffer.length > 500) buffer.shift(); | |
| 27 | + if (!sink) return; | |
| 28 | + try { | |
| 29 | + await sink(event); | |
| 30 | + } catch (err) { | |
| 31 | + logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'ai cost sink failed'); | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** In-memory tail of recent AI cost events (diagnostics/tests). */ | |
| 36 | +export function recentCosts(): readonly CostEvent[] { | |
| 37 | + return buffer; | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** Persist AI spend into the shared `costs` table (§169). Imports the DB lazily to keep this package light. */ | |
| 41 | +export async function installDbCostSink(): Promise<void> { | |
| 42 | + const { getDb, costs } = await import('@rareindex/database'); | |
| 43 | + setCostSink(async (e) => { | |
| 44 | + await getDb() | |
| 45 | + .insert(costs) | |
| 46 | + .values({ | |
| 47 | + id: newId('event'), | |
| 48 | + occurredAt: new Date(), | |
| 49 | + kind: 'ai', | |
| 50 | + provider: e.provider, | |
| 51 | + connectorId: e.context?.connectorId ?? null, | |
| 52 | + categorySlug: e.context?.categorySlug ?? null, | |
| 53 | + endpoint: e.context?.endpoint ?? e.role, | |
| 54 | + userId: e.context?.userId ?? null, | |
| 55 | + units: e.units ?? e.usage.inputTokens + e.usage.outputTokens, | |
| 56 | + credits: 0, | |
| 57 | + usdEst: e.usdEst, | |
| 58 | + metadata: { model: e.model, role: e.role, usage: e.usage, ...(e.context?.metadata ?? {}) }, | |
| 59 | + }); | |
| 60 | + }); | |
| 61 | +} | |
added
packages/ai/src/helpers/identify.ts
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { CATEGORIES, GRADERS } from '@rareindex/taxonomy'; | |
| 3 | +import { getRouter } from '../router.js'; | |
| 4 | +import type { ContentPart, CostContext, ImageInput, ModelProvider } from '../types.js'; | |
| 5 | + | |
| 6 | +/** Structured identification guess (§114). Every field is nullable: unknown stays null, never guessed. */ | |
| 7 | +export const IdentificationSchema = z.object({ | |
| 8 | + categorySlug: z.string().nullable().describe('One of the RareIndex taxonomy slugs provided, or null'), | |
| 9 | + name: z.string().nullable().describe('Item name (e.g. "Charizard", "Daytona", "Air Jordan 1 Chicago", "Millennium Falcon")'), | |
| 10 | + brand: z.string().nullable(), | |
| 11 | + franchise: z.string().nullable(), | |
| 12 | + set: z.string().nullable().describe('Set / series / collection name'), | |
| 13 | + number: z.string().nullable().describe('Card number, reference number, set number, style code…'), | |
| 14 | + year: z.number().int().nullable(), | |
| 15 | + variant: z.string().nullable().describe('Edition/variant: 1st Edition, Holo, Shadowless, Foil, colorway…'), | |
| 16 | + language: z.string().nullable(), | |
| 17 | + grader: z.string().nullable().describe('psa, bgs, cgc, sgc, wata, vga, pcgs, ngc… or null if raw/unknown'), | |
| 18 | + grade: z.string().nullable(), | |
| 19 | + certificationNumber: z.string().nullable(), | |
| 20 | + conditionNotes: z.string().nullable().describe('Visible condition observations, hedged'), | |
| 21 | + likelyGradeRange: z.string().nullable().describe('e.g. "PSA 7–8" — only when the image supports it'), | |
| 22 | + confidence: z.number().min(0).max(1).describe('Overall confidence 0–1 that the identification is correct'), | |
| 23 | + rationale: z.string().describe('Short explanation of the visual/textual cues used'), | |
| 24 | + searchQueries: z.array(z.string()).max(5).describe('Up to 5 short search strings to find this item in a catalog'), | |
| 25 | + warnings: z.array(z.string()).describe('Anything suspicious: possible reprint, proxy, mismatched slab, unreadable'), | |
| 26 | +}); | |
| 27 | +export type Identification = z.infer<typeof IdentificationSchema>; | |
| 28 | + | |
| 29 | +function taxonomyPrompt(): string { | |
| 30 | + const lines = CATEGORIES.filter((c) => c.phase <= 3).map((c) => `${c.slug} — ${c.name}${c.parent ? ` (child of ${c.parent})` : ''}`); | |
| 31 | + return `RareIndex taxonomy slugs (choose the most specific that applies):\n${lines.join('\n')}\n\nGrader slugs: ${GRADERS.map((g) => g.slug).join(', ')}.`; | |
| 32 | +} | |
| 33 | + | |
| 34 | +const SYSTEM = `You are RareIndex's identification model for collectibles (trading cards, sports cards, comics, video games, sneakers, watches, LEGO, toys, coins, books, art…). | |
| 35 | +Identify the object as precisely as the evidence allows. Never invent details you cannot see or read: unknown fields must be null. Report a calibrated confidence. You do not authenticate items and must flag signs of reprints, proxies or inconsistent grading labels in warnings. | |
| 36 | +${taxonomyPrompt()}`; | |
| 37 | + | |
| 38 | +export interface IdentifyOptions { | |
| 39 | + images?: ImageInput[]; | |
| 40 | + text?: string; | |
| 41 | + provider?: ModelProvider; | |
| 42 | + cost?: CostContext; | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Identify a collectible from photos and/or a free-text description. */ | |
| 46 | +export async function identifyCollectible(opts: IdentifyOptions) { | |
| 47 | + const provider = opts.provider ?? getRouter(); | |
| 48 | + const parts: ContentPart[] = []; | |
| 49 | + for (const img of opts.images ?? []) parts.push({ type: 'image', image: img }); | |
| 50 | + if (opts.text) parts.push({ type: 'text', text: `Description / listing text:\n${opts.text}` }); | |
| 51 | + if (parts.length === 0) throw new Error('identifyCollectible: provide images or text'); | |
| 52 | + const role = opts.images?.length ? 'vision' : 'classify'; | |
| 53 | + const res = await provider.extract(role, { | |
| 54 | + schema: IdentificationSchema, | |
| 55 | + system: SYSTEM, | |
| 56 | + prompt: 'Identify this collectible. Fill the schema; use null for anything not evidenced.', | |
| 57 | + input: parts, | |
| 58 | + maxTokens: 2048, | |
| 59 | + effort: 'medium', | |
| 60 | + cost: { endpoint: 'identify', ...(opts.cost ?? {}) }, | |
| 61 | + }); | |
| 62 | + const data = res.data; | |
| 63 | + if (data.categorySlug && !CATEGORIES.some((c) => c.slug === data.categorySlug)) { | |
| 64 | + data.warnings.push(`model proposed unknown category ${data.categorySlug}`); | |
| 65 | + data.categorySlug = null; | |
| 66 | + } | |
| 67 | + return { ...res, data }; | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** Cheap category classification from a title/description (used by normalizers and taxonomy discovery). */ | |
| 71 | +export const CategoryGuessSchema = z.object({ | |
| 72 | + categorySlug: z.string().nullable(), | |
| 73 | + confidence: z.number().min(0).max(1), | |
| 74 | + alternatives: z.array(z.string()).max(3), | |
| 75 | +}); | |
| 76 | +export async function classifyCategory(title: string, opts: { provider?: ModelProvider; cost?: CostContext } = {}) { | |
| 77 | + const provider = opts.provider ?? getRouter(); | |
| 78 | + const res = await provider.extract('classify', { | |
| 79 | + schema: CategoryGuessSchema, | |
| 80 | + system: `Classify collectible listings into RareIndex taxonomy slugs. Return null when none applies.\n${taxonomyPrompt()}`, | |
| 81 | + prompt: 'Classify this listing title.', | |
| 82 | + input: title, | |
| 83 | + maxTokens: 256, | |
| 84 | + effort: 'low', | |
| 85 | + cost: { endpoint: 'classify', ...(opts.cost ?? {}) }, | |
| 86 | + }); | |
| 87 | + if (res.data.categorySlug && !CATEGORIES.some((c) => c.slug === res.data.categorySlug)) res.data.categorySlug = null; | |
| 88 | + return res; | |
| 89 | +} | |
added
packages/ai/src/helpers/news.ts
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { getRouter } from '../router.js'; | |
| 3 | +import type { CostContext, ModelProvider } from '../types.js'; | |
| 4 | + | |
| 5 | +export interface NewsInput { | |
| 6 | + url: string; | |
| 7 | + title: string; | |
| 8 | + text: string; | |
| 9 | + publishedAt?: string | null; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export const NewsSummarySchema = z.object({ | |
| 13 | + items: z.array( | |
| 14 | + z.object({ | |
| 15 | + url: z.string(), | |
| 16 | + summary: z.string().describe('2–3 factual sentences, no speculation, figures only if present in the source'), | |
| 17 | + newsType: z.enum(['auction_results', 'record_sales', 'grading', 'releases', 'trends', 'discoveries', 'events', 'other']), | |
| 18 | + categorySlugs: z.array(z.string()).max(4), | |
| 19 | + mentionedPrices: z.array(z.object({ amount: z.number(), currency: z.string(), what: z.string() })).max(5), | |
| 20 | + }), | |
| 21 | + ), | |
| 22 | +}); | |
| 23 | + | |
| 24 | +/** Summarise market news items with source links (§156). Facts only; the UI links each summary to its source. */ | |
| 25 | +export async function summarizeNews(items: NewsInput[], opts: { provider?: ModelProvider; cost?: CostContext } = {}) { | |
| 26 | + const provider = opts.provider ?? getRouter(); | |
| 27 | + const input = items.map((i, n) => `### Item ${n + 1}\nURL: ${i.url}\nTitle: ${i.title}\nPublished: ${i.publishedAt ?? 'unknown'}\n${i.text.slice(0, 6000)}`).join('\n\n'); | |
| 28 | + return provider.extract('summarize', { | |
| 29 | + schema: NewsSummarySchema, | |
| 30 | + system: 'You summarise collectibles-market news for a data terminal. Neutral tone, no investment advice, keep every figure exactly as stated in the source, return one entry per item with the same URL.', | |
| 31 | + prompt: 'Summarise each item.', | |
| 32 | + input, | |
| 33 | + maxTokens: 4096, | |
| 34 | + effort: 'low', | |
| 35 | + cost: { endpoint: 'summarize_news', ...(opts.cost ?? {}) }, | |
| 36 | + }); | |
| 37 | +} | |
added
packages/ai/src/helpers/verify-match.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { getRouter } from '../router.js'; | |
| 3 | +import type { CostContext, ModelProvider } from '../types.js'; | |
| 4 | + | |
| 5 | +export interface MatchCandidate { | |
| 6 | + title: string; | |
| 7 | + attributes?: Record<string, unknown>; | |
| 8 | + identifiers?: Record<string, string>; | |
| 9 | + grade?: { grader?: string | null; grade?: string | null } | null; | |
| 10 | + source?: string; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export const MatchVerdictSchema = z.object({ | |
| 14 | + sameAsset: z.boolean().describe('True if both describe the same canonical collectible (ignoring grade/condition)'), | |
| 15 | + sameVariant: z.boolean().describe('True if grade/condition/edition also match'), | |
| 16 | + confidence: z.number().min(0).max(1), | |
| 17 | + blockingDifferences: z.array(z.string()).describe('Concrete attribute differences that prevent a match (set, number, edition, language, year…)'), | |
| 18 | + rationale: z.string(), | |
| 19 | +}); | |
| 20 | +export type MatchVerdict = z.infer<typeof MatchVerdictSchema>; | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * LLM verification for entity resolution (§112) — the last step after deterministic identifiers, | |
| 24 | + * canonical keys and fuzzy matching have produced a candidate pair. | |
| 25 | + */ | |
| 26 | +export async function verifyEntityMatch(a: MatchCandidate, b: MatchCandidate, opts: { provider?: ModelProvider; cost?: CostContext } = {}) { | |
| 27 | + const provider = opts.provider ?? getRouter(); | |
| 28 | + const res = await provider.extract('resolve', { | |
| 29 | + schema: MatchVerdictSchema, | |
| 30 | + system: 'You verify whether two collectible records refer to the same canonical asset. Be strict: different set, card number, edition (1st Edition vs Unlimited), language, year, reference number, colorway or size are different assets. Grade and condition differences make different variants of the same asset.', | |
| 31 | + prompt: 'Compare record A and record B.', | |
| 32 | + input: `Record A:\n${JSON.stringify(a, null, 2)}\n\nRecord B:\n${JSON.stringify(b, null, 2)}`, | |
| 33 | + maxTokens: 800, | |
| 34 | + effort: 'low', | |
| 35 | + cost: { endpoint: 'verify_match', ...(opts.cost ?? {}) }, | |
| 36 | + }); | |
| 37 | + return res; | |
| 38 | +} | |
added
packages/ai/src/index.ts
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +export * from './types.js'; | |
| 2 | +export * from './pricing.js'; | |
| 3 | +export * from './costs.js'; | |
| 4 | +export * from './router.js'; | |
| 5 | +export { AnthropicProvider, ANTHROPIC_DEFAULT_MODELS } from './providers/anthropic.js'; | |
| 6 | +export { OpenAICompatibleProvider } from './providers/openai-compatible.js'; | |
| 7 | +export * from './helpers/identify.js'; | |
| 8 | +export * from './helpers/verify-match.js'; | |
| 9 | +export * from './helpers/news.js'; | |
added
packages/ai/src/pricing.ts
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +import type { Usage } from './types.js'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * List prices in USD per 1M tokens (first-party API rates, cached 2026-06). Used only for the | |
| 5 | + * cost ledger (§169); billing truth lives with the provider. Update when prices change. | |
| 6 | + */ | |
| 7 | +export interface ModelPrice { | |
| 8 | + input: number; | |
| 9 | + output: number; | |
| 10 | + /** cache read multiplier vs input (Anthropic: 0.1) */ | |
| 11 | + cacheRead: number; | |
| 12 | + /** cache write multiplier vs input (Anthropic: 1.25) */ | |
| 13 | + cacheWrite: number; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export const PRICE_TABLE: Record<string, ModelPrice> = { | |
| 17 | + 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.1, cacheWrite: 1.25 }, | |
| 18 | + 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.1, cacheWrite: 1.25 }, | |
| 19 | + 'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 1.25 }, | |
| 20 | + 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.1, cacheWrite: 1.25 }, | |
| 21 | + 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, | |
| 22 | + 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.1, cacheWrite: 1.25 }, | |
| 23 | + // OpenAI (approximate list prices; verify before relying on them) | |
| 24 | + 'gpt-5': { input: 1.25, output: 10, cacheRead: 0.1, cacheWrite: 1 }, | |
| 25 | + 'gpt-5-mini': { input: 0.25, output: 2, cacheRead: 0.1, cacheWrite: 1 }, | |
| 26 | + 'gpt-4o-mini': { input: 0.15, output: 0.6, cacheRead: 0.5, cacheWrite: 1 }, | |
| 27 | + 'text-embedding-3-small': { input: 0.02, output: 0, cacheRead: 1, cacheWrite: 1 }, | |
| 28 | + 'text-embedding-3-large': { input: 0.13, output: 0, cacheRead: 1, cacheWrite: 1 }, | |
| 29 | +}; | |
| 30 | + | |
| 31 | +/** Local/self-hosted models cost nothing at the API layer. */ | |
| 32 | +export function priceFor(model: string): ModelPrice | null { | |
| 33 | + if (PRICE_TABLE[model]) return PRICE_TABLE[model]!; | |
| 34 | + const base = Object.keys(PRICE_TABLE).find((k) => model.startsWith(k)); | |
| 35 | + return base ? PRICE_TABLE[base]! : null; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function estimateUsd(model: string, usage: Usage): number { | |
| 39 | + const p = priceFor(model); | |
| 40 | + if (!p) return 0; | |
| 41 | + const usd = | |
| 42 | + (usage.inputTokens * p.input + usage.outputTokens * p.output + usage.cacheReadTokens * p.input * p.cacheRead + usage.cacheWriteTokens * p.input * p.cacheWrite) / 1_000_000; | |
| 43 | + return Math.round(usd * 1e6) / 1e6; | |
| 44 | +} | |
| 45 | + | |
| 46 | +export const ZERO_USAGE: Usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }; | |
| 47 | + | |
| 48 | +export function addUsage(a: Usage, b: Usage): Usage { | |
| 49 | + return { | |
| 50 | + inputTokens: a.inputTokens + b.inputTokens, | |
| 51 | + outputTokens: a.outputTokens + b.outputTokens, | |
| 52 | + cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens, | |
| 53 | + cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens, | |
| 54 | + }; | |
| 55 | +} | |
added
packages/ai/src/providers/anthropic.ts
+275 −0
@@ -0,0 +1,275 @@ | ||
| 1 | +import Anthropic from '@anthropic-ai/sdk'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { estimateUsd, addUsage, ZERO_USAGE } from '../pricing.js'; | |
| 4 | +import { recordCost } from '../costs.js'; | |
| 5 | +import { AiNotConfiguredError, AiRefusalError, type ChatMessage, type Completion, type CompletionRequest, type ContentPart, type EmbedRequest, type EmbedResult, type ExtractRequest, type ExtractResult, type ModelProvider, type Role, type StreamDelta, type ToolAgentRequest, type Usage } from '../types.js'; | |
| 6 | + | |
| 7 | +export interface AnthropicProviderOptions { | |
| 8 | + apiKey: string; | |
| 9 | + /** model per role; defaults below */ | |
| 10 | + models?: Partial<Record<Role, string>>; | |
| 11 | + /** stable system-prompt prefix cached across calls */ | |
| 12 | + defaultTimeoutMs?: number; | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * Default role → model mapping. Fast/cheap for bulk pipeline work, strong for research and vision. | |
| 17 | + * All ids are the exact strings from the current model table (no date suffixes). | |
| 18 | + */ | |
| 19 | +export const ANTHROPIC_DEFAULT_MODELS: Record<Role, string> = { | |
| 20 | + normalize: 'claude-haiku-4-5', | |
| 21 | + classify: 'claude-haiku-4-5', | |
| 22 | + summarize: 'claude-haiku-4-5', | |
| 23 | + resolve: 'claude-sonnet-5', | |
| 24 | + research: 'claude-opus-5', | |
| 25 | + vision: 'claude-opus-5', | |
| 26 | + embed: '', | |
| 27 | +}; | |
| 28 | + | |
| 29 | +function usageOf(u: Anthropic.Usage | Anthropic.MessageDeltaUsage | null | undefined): Usage { | |
| 30 | + return { | |
| 31 | + inputTokens: u?.input_tokens ?? 0, | |
| 32 | + outputTokens: u?.output_tokens ?? 0, | |
| 33 | + cacheReadTokens: u?.cache_read_input_tokens ?? 0, | |
| 34 | + cacheWriteTokens: u?.cache_creation_input_tokens ?? 0, | |
| 35 | + }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +function toBlocks(content: string | ContentPart[]): string | Anthropic.ContentBlockParam[] { | |
| 39 | + if (typeof content === 'string') return content; | |
| 40 | + return content.map((p): Anthropic.ContentBlockParam => { | |
| 41 | + if (p.type === 'text') return { type: 'text', text: p.text }; | |
| 42 | + const img = p.image; | |
| 43 | + if (img.url) return { type: 'image', source: { type: 'url', url: img.url } }; | |
| 44 | + return { type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.data ?? '' } }; | |
| 45 | + }); | |
| 46 | +} | |
| 47 | + | |
| 48 | +function toMessages(messages: ChatMessage[]): Anthropic.MessageParam[] { | |
| 49 | + return messages.map((m) => ({ role: m.role, content: toBlocks(m.content) })); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Haiku 4.5 still uses budgeted thinking; current-generation models use adaptive thinking. */ | |
| 53 | +function thinkingFor(model: string, effort: string | undefined): Anthropic.ThinkingConfigParam | undefined { | |
| 54 | + if (model.startsWith('claude-haiku')) return undefined; | |
| 55 | + if (effort === 'low') return { type: 'adaptive' }; | |
| 56 | + return { type: 'adaptive' }; | |
| 57 | +} | |
| 58 | + | |
| 59 | +function outputConfig(model: string, effort: string | undefined, format?: Anthropic.JSONOutputFormat): Anthropic.OutputConfig | undefined { | |
| 60 | + const cfg: Anthropic.OutputConfig = {}; | |
| 61 | + if (effort && !model.startsWith('claude-haiku')) cfg.effort = effort as Anthropic.OutputConfig['effort']; | |
| 62 | + if (format) cfg.format = format; | |
| 63 | + return Object.keys(cfg).length ? cfg : undefined; | |
| 64 | +} | |
| 65 | + | |
| 66 | +export class AnthropicProvider implements ModelProvider { | |
| 67 | + readonly id = 'anthropic'; | |
| 68 | + private readonly client: Anthropic; | |
| 69 | + private readonly models: Record<Role, string>; | |
| 70 | + | |
| 71 | + constructor(opts: AnthropicProviderOptions) { | |
| 72 | + if (!opts.apiKey) throw new AiNotConfiguredError('ANTHROPIC_API_KEY missing'); | |
| 73 | + this.client = new Anthropic({ apiKey: opts.apiKey, timeout: opts.defaultTimeoutMs ?? 10 * 60_000, maxRetries: 2 }); | |
| 74 | + this.models = { ...ANTHROPIC_DEFAULT_MODELS, ...(opts.models ?? {}) }; | |
| 75 | + } | |
| 76 | + | |
| 77 | + supports(role: Role): boolean { | |
| 78 | + return role !== 'embed'; | |
| 79 | + } | |
| 80 | + | |
| 81 | + modelFor(role: Role): string { | |
| 82 | + return this.models[role] || ANTHROPIC_DEFAULT_MODELS[role]; | |
| 83 | + } | |
| 84 | + | |
| 85 | + private async account(role: Role, model: string, usage: Usage, cost?: CompletionRequest['cost']): Promise<number> { | |
| 86 | + const usdEst = estimateUsd(model, usage); | |
| 87 | + await recordCost({ provider: this.id, model, role, usage, usdEst, context: cost }); | |
| 88 | + return usdEst; | |
| 89 | + } | |
| 90 | + | |
| 91 | + async complete(role: Role, req: CompletionRequest): Promise<Completion> { | |
| 92 | + const model = this.modelFor(role); | |
| 93 | + const system = req.json ? `${req.system ?? ''}\nRespond with a single JSON object and nothing else.`.trim() : req.system; | |
| 94 | + const res = await this.client.messages.create({ | |
| 95 | + model, | |
| 96 | + max_tokens: req.maxTokens ?? 4096, | |
| 97 | + ...(system ? { system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }] } : {}), | |
| 98 | + messages: toMessages(req.messages), | |
| 99 | + ...(thinkingFor(model, req.effort) ? { thinking: thinkingFor(model, req.effort) } : {}), | |
| 100 | + ...(outputConfig(model, req.effort) ? { output_config: outputConfig(model, req.effort) } : {}), | |
| 101 | + ...(req.stopSequences ? { stop_sequences: req.stopSequences } : {}), | |
| 102 | + }); | |
| 103 | + const usage = usageOf(res.usage); | |
| 104 | + const usdEst = await this.account(role, model, usage, req.cost); | |
| 105 | + if (res.stop_reason === 'refusal') { | |
| 106 | + const d = res.stop_details; | |
| 107 | + return { text: '', model, provider: this.id, usage, usdEst, stopReason: 'refusal', refusal: { category: d?.category ?? null, explanation: d?.explanation ?? null } }; | |
| 108 | + } | |
| 109 | + const text = res.content | |
| 110 | + .filter((b): b is Anthropic.TextBlock => b.type === 'text') | |
| 111 | + .map((b) => b.text) | |
| 112 | + .join(''); | |
| 113 | + return { text, model, provider: this.id, usage, usdEst, stopReason: res.stop_reason, refusal: null }; | |
| 114 | + } | |
| 115 | + | |
| 116 | + async *stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta> { | |
| 117 | + yield* this.runTools(role, { ...req, tools: [], execute: async () => null }); | |
| 118 | + } | |
| 119 | + | |
| 120 | + async *runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta> { | |
| 121 | + const model = this.modelFor(role); | |
| 122 | + const messages = toMessages(req.messages); | |
| 123 | + const tools: Anthropic.Tool[] = req.tools.map((t) => ({ | |
| 124 | + name: t.name, | |
| 125 | + description: t.description, | |
| 126 | + input_schema: t.inputSchema as Anthropic.Tool.InputSchema, | |
| 127 | + })); | |
| 128 | + const maxIter = req.maxIterations ?? 8; | |
| 129 | + let total: Usage = ZERO_USAGE; | |
| 130 | + let iterations = 0; | |
| 131 | + let stopReason: string | null = null; | |
| 132 | + try { | |
| 133 | + while (iterations < maxIter) { | |
| 134 | + iterations++; | |
| 135 | + const stream = this.client.messages.stream( | |
| 136 | + { | |
| 137 | + model, | |
| 138 | + max_tokens: req.maxTokens ?? 8192, | |
| 139 | + ...(req.system ? { system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }] } : {}), | |
| 140 | + messages, | |
| 141 | + ...(tools.length ? { tools } : {}), | |
| 142 | + ...(thinkingFor(model, req.effort) ? { thinking: { ...thinkingFor(model, req.effort)!, display: 'summarized' } as Anthropic.ThinkingConfigParam } : {}), | |
| 143 | + ...(outputConfig(model, req.effort) ? { output_config: outputConfig(model, req.effort) } : {}), | |
| 144 | + }, | |
| 145 | + { signal: req.signal }, | |
| 146 | + ); | |
| 147 | + const queue: StreamDelta[] = []; | |
| 148 | + let resolveWake: (() => void) | null = null; | |
| 149 | + const wake = () => { | |
| 150 | + resolveWake?.(); | |
| 151 | + resolveWake = null; | |
| 152 | + }; | |
| 153 | + stream.on('text', (delta) => { | |
| 154 | + queue.push({ type: 'text', text: delta }); | |
| 155 | + wake(); | |
| 156 | + }); | |
| 157 | + stream.on('thinking', (delta) => { | |
| 158 | + queue.push({ type: 'thinking', text: delta }); | |
| 159 | + wake(); | |
| 160 | + }); | |
| 161 | + let finished = false; | |
| 162 | + let failure: unknown = null; | |
| 163 | + const finalP = stream | |
| 164 | + .finalMessage() | |
| 165 | + .catch((err) => { | |
| 166 | + failure = err; | |
| 167 | + return null; | |
| 168 | + }) | |
| 169 | + .finally(() => { | |
| 170 | + finished = true; | |
| 171 | + wake(); | |
| 172 | + }); | |
| 173 | + while (!finished || queue.length) { | |
| 174 | + if (queue.length) { | |
| 175 | + yield queue.shift()!; | |
| 176 | + continue; | |
| 177 | + } | |
| 178 | + await new Promise<void>((r) => { | |
| 179 | + resolveWake = r; | |
| 180 | + }); | |
| 181 | + } | |
| 182 | + const message = await finalP; | |
| 183 | + if (failure || !message) throw failure ?? new Error('stream ended without a message'); | |
| 184 | + const u = usageOf(message.usage); | |
| 185 | + total = addUsage(total, u); | |
| 186 | + yield { type: 'usage', usage: u, usdEst: estimateUsd(model, u), model }; | |
| 187 | + stopReason = message.stop_reason; | |
| 188 | + if (message.stop_reason === 'refusal') { | |
| 189 | + const d = message.stop_details; | |
| 190 | + throw new AiRefusalError(d?.category ?? null, d?.explanation ?? null); | |
| 191 | + } | |
| 192 | + if (message.stop_reason === 'pause_turn') { | |
| 193 | + messages.push({ role: 'assistant', content: message.content }); | |
| 194 | + continue; | |
| 195 | + } | |
| 196 | + const toolUses = message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use'); | |
| 197 | + if (message.stop_reason !== 'tool_use' || toolUses.length === 0) break; | |
| 198 | + messages.push({ role: 'assistant', content: message.content }); | |
| 199 | + const results: Anthropic.ToolResultBlockParam[] = []; | |
| 200 | + for (const tu of toolUses) { | |
| 201 | + yield { type: 'tool_call', id: tu.id, name: tu.name, input: tu.input }; | |
| 202 | + const started = Date.now(); | |
| 203 | + let output: unknown; | |
| 204 | + let isError = false; | |
| 205 | + try { | |
| 206 | + output = await req.execute(tu.name, tu.input); | |
| 207 | + } catch (err) { | |
| 208 | + isError = true; | |
| 209 | + output = { error: err instanceof Error ? err.message : String(err) }; | |
| 210 | + } | |
| 211 | + yield { type: 'tool_result', id: tu.id, name: tu.name, output, isError, durationMs: Date.now() - started }; | |
| 212 | + results.push({ type: 'tool_result', tool_use_id: tu.id, content: typeof output === 'string' ? output : JSON.stringify(output ?? null), is_error: isError }); | |
| 213 | + } | |
| 214 | + messages.push({ role: 'user', content: results }); | |
| 215 | + } | |
| 216 | + } finally { | |
| 217 | + await this.account(role, model, total, req.cost); | |
| 218 | + } | |
| 219 | + yield { type: 'done', stopReason }; | |
| 220 | + } | |
| 221 | + | |
| 222 | + async extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>> { | |
| 223 | + const model = this.modelFor(role); | |
| 224 | + const schema = z.toJSONSchema(req.schema as z.ZodType, { target: 'draft-2020-12', io: 'output' }) as Record<string, unknown>; | |
| 225 | + const format: Anthropic.JSONOutputFormat = { type: 'json_schema', schema: stripUnsupported(schema) }; | |
| 226 | + const content = typeof req.input === 'string' ? `${req.prompt}\n\n${req.input}` : [{ type: 'text', text: req.prompt } as ContentPart, ...req.input]; | |
| 227 | + const res = await this.client.messages.create({ | |
| 228 | + model, | |
| 229 | + max_tokens: req.maxTokens ?? 4096, | |
| 230 | + ...(req.system ? { system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }] } : {}), | |
| 231 | + messages: toMessages([{ role: 'user', content }]), | |
| 232 | + ...(thinkingFor(model, req.effort) ? { thinking: thinkingFor(model, req.effort) } : {}), | |
| 233 | + output_config: outputConfig(model, req.effort, format)!, | |
| 234 | + }); | |
| 235 | + const usage = usageOf(res.usage); | |
| 236 | + const usdEst = await this.account(role, model, usage, req.cost); | |
| 237 | + if (res.stop_reason === 'refusal') throw new AiRefusalError(res.stop_details?.category ?? null, res.stop_details?.explanation ?? null); | |
| 238 | + const text = res.content | |
| 239 | + .filter((b): b is Anthropic.TextBlock => b.type === 'text') | |
| 240 | + .map((b) => b.text) | |
| 241 | + .join(''); | |
| 242 | + const parsed = req.schema.safeParse(JSON.parse(text)); | |
| 243 | + if (!parsed.success) throw new Error(`extract: model output did not match schema: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`); | |
| 244 | + const data = parsed.data as T & { confidence?: number }; | |
| 245 | + const confidence = typeof data.confidence === 'number' ? Math.max(0, Math.min(1, data.confidence)) : 1; | |
| 246 | + return { data: parsed.data, confidence, model, provider: this.id, usage, usdEst }; | |
| 247 | + } | |
| 248 | + | |
| 249 | + vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>> { | |
| 250 | + return this.extract('vision', req); | |
| 251 | + } | |
| 252 | + | |
| 253 | + async embed(_req: EmbedRequest): Promise<EmbedResult> { | |
| 254 | + throw new AiNotConfiguredError('Anthropic has no embeddings endpoint; configure OPENAI_API_KEY or a local embedding model'); | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +/** Structured-output schemas reject a few JSON-schema keywords; strip them recursively. */ | |
| 259 | +function stripUnsupported(schema: Record<string, unknown>): Record<string, unknown> { | |
| 260 | + const drop = new Set(['$schema', 'default', 'minLength', 'maxLength', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'pattern', 'format', 'minItems', 'maxItems', 'multipleOf']); | |
| 261 | + const walk = (node: unknown): unknown => { | |
| 262 | + if (Array.isArray(node)) return node.map(walk); | |
| 263 | + if (node && typeof node === 'object') { | |
| 264 | + const out: Record<string, unknown> = {}; | |
| 265 | + for (const [k, v] of Object.entries(node as Record<string, unknown>)) { | |
| 266 | + if (drop.has(k)) continue; | |
| 267 | + out[k] = walk(v); | |
| 268 | + } | |
| 269 | + if (out.type === 'object' && out.properties && out.additionalProperties === undefined) out.additionalProperties = false; | |
| 270 | + return out; | |
| 271 | + } | |
| 272 | + return node; | |
| 273 | + }; | |
| 274 | + return walk(schema) as Record<string, unknown>; | |
| 275 | +} | |
added
packages/ai/src/providers/openai-compatible.ts
+206 −0
@@ -0,0 +1,206 @@ | ||
| 1 | +import OpenAI from 'openai'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { estimateUsd, addUsage, ZERO_USAGE } from '../pricing.js'; | |
| 4 | +import { recordCost } from '../costs.js'; | |
| 5 | +import { AiNotConfiguredError, type ChatMessage, type Completion, type CompletionRequest, type ContentPart, type EmbedRequest, type EmbedResult, type ExtractRequest, type ExtractResult, type ModelProvider, type Role, type StreamDelta, type ToolAgentRequest, type Usage } from '../types.js'; | |
| 6 | + | |
| 7 | +export interface OpenAICompatibleOptions { | |
| 8 | + id?: string; | |
| 9 | + apiKey: string; | |
| 10 | + baseURL?: string; | |
| 11 | + models?: Partial<Record<Role, string>>; | |
| 12 | + embeddingModel?: string; | |
| 13 | + embeddingDimensions?: number; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const DEFAULT_MODELS: Record<Role, string> = { | |
| 17 | + normalize: 'gpt-5-mini', | |
| 18 | + classify: 'gpt-5-mini', | |
| 19 | + summarize: 'gpt-5-mini', | |
| 20 | + resolve: 'gpt-5-mini', | |
| 21 | + research: 'gpt-5', | |
| 22 | + vision: 'gpt-5', | |
| 23 | + embed: 'text-embedding-3-small', | |
| 24 | +}; | |
| 25 | + | |
| 26 | +function toContent(content: string | ContentPart[]): string | OpenAI.Chat.Completions.ChatCompletionContentPart[] { | |
| 27 | + if (typeof content === 'string') return content; | |
| 28 | + return content.map((p): OpenAI.Chat.Completions.ChatCompletionContentPart => { | |
| 29 | + if (p.type === 'text') return { type: 'text', text: p.text }; | |
| 30 | + const url = p.image.url ?? `data:${p.image.mediaType};base64,${p.image.data ?? ''}`; | |
| 31 | + return { type: 'image_url', image_url: { url } }; | |
| 32 | + }); | |
| 33 | +} | |
| 34 | + | |
| 35 | +function toMessages(system: string | undefined, messages: ChatMessage[]): OpenAI.Chat.Completions.ChatCompletionMessageParam[] { | |
| 36 | + const out: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = []; | |
| 37 | + if (system) out.push({ role: 'system', content: system }); | |
| 38 | + for (const m of messages) { | |
| 39 | + if (m.role === 'user') out.push({ role: 'user', content: toContent(m.content) }); | |
| 40 | + else out.push({ role: 'assistant', content: typeof m.content === 'string' ? m.content : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('') }); | |
| 41 | + } | |
| 42 | + return out; | |
| 43 | +} | |
| 44 | + | |
| 45 | +function usageOf(u: OpenAI.Completions.CompletionUsage | null | undefined): Usage { | |
| 46 | + return { | |
| 47 | + inputTokens: (u?.prompt_tokens ?? 0) - (u?.prompt_tokens_details?.cached_tokens ?? 0), | |
| 48 | + outputTokens: u?.completion_tokens ?? 0, | |
| 49 | + cacheReadTokens: u?.prompt_tokens_details?.cached_tokens ?? 0, | |
| 50 | + cacheWriteTokens: 0, | |
| 51 | + }; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * Provider for any OpenAI-compatible endpoint: OpenAI itself, or the local MacLustr gateway | |
| 56 | + * (LOCAL_LLM_BASE_URL). Also the only embeddings provider for now. | |
| 57 | + */ | |
| 58 | +export class OpenAICompatibleProvider implements ModelProvider { | |
| 59 | + readonly id: string; | |
| 60 | + private readonly client: OpenAI; | |
| 61 | + private readonly models: Record<Role, string>; | |
| 62 | + private readonly embeddingModel: string; | |
| 63 | + private readonly embeddingDimensions: number; | |
| 64 | + private readonly isLocal: boolean; | |
| 65 | + | |
| 66 | + constructor(opts: OpenAICompatibleOptions) { | |
| 67 | + if (!opts.apiKey && !opts.baseURL) throw new AiNotConfiguredError('OPENAI_API_KEY or LOCAL_LLM_BASE_URL missing'); | |
| 68 | + this.id = opts.id ?? (opts.baseURL ? 'openai-compatible' : 'openai'); | |
| 69 | + this.isLocal = Boolean(opts.baseURL); | |
| 70 | + this.client = new OpenAI({ apiKey: opts.apiKey || 'local', baseURL: opts.baseURL, timeout: 5 * 60_000, maxRetries: 2 }); | |
| 71 | + this.models = { ...DEFAULT_MODELS, ...(opts.models ?? {}) }; | |
| 72 | + this.embeddingModel = opts.embeddingModel ?? this.models.embed; | |
| 73 | + this.embeddingDimensions = opts.embeddingDimensions ?? 1536; | |
| 74 | + } | |
| 75 | + | |
| 76 | + supports(role: Role): boolean { | |
| 77 | + if (role === 'embed') return !this.isLocal || Boolean(this.embeddingModel); | |
| 78 | + return true; | |
| 79 | + } | |
| 80 | + | |
| 81 | + modelFor(role: Role): string { | |
| 82 | + return this.models[role]; | |
| 83 | + } | |
| 84 | + | |
| 85 | + private async account(role: Role, model: string, usage: Usage, cost?: CompletionRequest['cost'], units?: number): Promise<number> { | |
| 86 | + const usdEst = this.isLocal ? 0 : estimateUsd(model, usage); | |
| 87 | + await recordCost({ provider: this.id, model, role, usage, usdEst, context: cost, units }); | |
| 88 | + return usdEst; | |
| 89 | + } | |
| 90 | + | |
| 91 | + async complete(role: Role, req: CompletionRequest): Promise<Completion> { | |
| 92 | + const model = this.modelFor(role); | |
| 93 | + const res = await this.client.chat.completions.create({ | |
| 94 | + model, | |
| 95 | + messages: toMessages(req.system, req.messages), | |
| 96 | + max_completion_tokens: req.maxTokens ?? 4096, | |
| 97 | + ...(req.temperature !== undefined ? { temperature: req.temperature } : {}), | |
| 98 | + ...(req.json ? { response_format: { type: 'json_object' } } : {}), | |
| 99 | + ...(req.stopSequences ? { stop: req.stopSequences } : {}), | |
| 100 | + }); | |
| 101 | + const usage = usageOf(res.usage); | |
| 102 | + const usdEst = await this.account(role, model, usage, req.cost); | |
| 103 | + const choice = res.choices[0]; | |
| 104 | + return { text: choice?.message?.content ?? '', model, provider: this.id, usage, usdEst, stopReason: choice?.finish_reason ?? null, refusal: choice?.message?.refusal ? { category: null, explanation: choice.message.refusal } : null }; | |
| 105 | + } | |
| 106 | + | |
| 107 | + async *stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta> { | |
| 108 | + yield* this.runTools(role, { ...req, tools: [], execute: async () => null }); | |
| 109 | + } | |
| 110 | + | |
| 111 | + async *runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta> { | |
| 112 | + const model = this.modelFor(role); | |
| 113 | + const messages = toMessages(req.system, req.messages); | |
| 114 | + const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = req.tools.map((t) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.inputSchema } })); | |
| 115 | + let total: Usage = ZERO_USAGE; | |
| 116 | + let stopReason: string | null = null; | |
| 117 | + const maxIter = req.maxIterations ?? 8; | |
| 118 | + try { | |
| 119 | + for (let i = 0; i < maxIter; i++) { | |
| 120 | + const stream = await this.client.chat.completions.create( | |
| 121 | + { model, messages, max_completion_tokens: req.maxTokens ?? 8192, ...(tools.length ? { tools } : {}), stream: true, stream_options: { include_usage: true } }, | |
| 122 | + { signal: req.signal }, | |
| 123 | + ); | |
| 124 | + const calls = new Map<number, { id: string; name: string; args: string }>(); | |
| 125 | + let finish: string | null = null; | |
| 126 | + for await (const chunk of stream) { | |
| 127 | + if (chunk.usage) { | |
| 128 | + const u = usageOf(chunk.usage); | |
| 129 | + total = addUsage(total, u); | |
| 130 | + yield { type: 'usage', usage: u, usdEst: this.isLocal ? 0 : estimateUsd(model, u), model }; | |
| 131 | + } | |
| 132 | + const c = chunk.choices[0]; | |
| 133 | + if (!c) continue; | |
| 134 | + if (c.delta.content) yield { type: 'text', text: c.delta.content }; | |
| 135 | + for (const tc of c.delta.tool_calls ?? []) { | |
| 136 | + const cur = calls.get(tc.index) ?? { id: tc.id ?? '', name: '', args: '' }; | |
| 137 | + if (tc.id) cur.id = tc.id; | |
| 138 | + if (tc.function?.name) cur.name += tc.function.name; | |
| 139 | + if (tc.function?.arguments) cur.args += tc.function.arguments; | |
| 140 | + calls.set(tc.index, cur); | |
| 141 | + } | |
| 142 | + if (c.finish_reason) finish = c.finish_reason; | |
| 143 | + } | |
| 144 | + stopReason = finish; | |
| 145 | + if (finish !== 'tool_calls' || calls.size === 0) break; | |
| 146 | + const toolCalls = [...calls.values()]; | |
| 147 | + messages.push({ role: 'assistant', content: null, tool_calls: toolCalls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.args } })) }); | |
| 148 | + for (const tc of toolCalls) { | |
| 149 | + let input: unknown = {}; | |
| 150 | + try { | |
| 151 | + input = tc.args ? JSON.parse(tc.args) : {}; | |
| 152 | + } catch { | |
| 153 | + input = {}; | |
| 154 | + } | |
| 155 | + yield { type: 'tool_call', id: tc.id, name: tc.name, input }; | |
| 156 | + const started = Date.now(); | |
| 157 | + let output: unknown; | |
| 158 | + let isError = false; | |
| 159 | + try { | |
| 160 | + output = await req.execute(tc.name, input); | |
| 161 | + } catch (err) { | |
| 162 | + isError = true; | |
| 163 | + output = { error: err instanceof Error ? err.message : String(err) }; | |
| 164 | + } | |
| 165 | + yield { type: 'tool_result', id: tc.id, name: tc.name, output, isError, durationMs: Date.now() - started }; | |
| 166 | + messages.push({ role: 'tool', tool_call_id: tc.id, content: typeof output === 'string' ? output : JSON.stringify(output ?? null) }); | |
| 167 | + } | |
| 168 | + } | |
| 169 | + } finally { | |
| 170 | + await this.account(role, model, total, req.cost); | |
| 171 | + } | |
| 172 | + yield { type: 'done', stopReason }; | |
| 173 | + } | |
| 174 | + | |
| 175 | + async extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>> { | |
| 176 | + const model = this.modelFor(role); | |
| 177 | + const schema = z.toJSONSchema(req.schema as z.ZodType, { target: 'draft-2020-12', io: 'output' }) as Record<string, unknown>; | |
| 178 | + const content = typeof req.input === 'string' ? `${req.prompt}\n\n${req.input}` : [{ type: 'text', text: req.prompt } as ContentPart, ...req.input]; | |
| 179 | + const res = await this.client.chat.completions.create({ | |
| 180 | + model, | |
| 181 | + messages: toMessages(req.system, [{ role: 'user', content }]), | |
| 182 | + max_completion_tokens: req.maxTokens ?? 4096, | |
| 183 | + response_format: { type: 'json_schema', json_schema: { name: 'extraction', schema, strict: false } }, | |
| 184 | + }); | |
| 185 | + const usage = usageOf(res.usage); | |
| 186 | + const usdEst = await this.account(role, model, usage, req.cost); | |
| 187 | + const text = res.choices[0]?.message?.content ?? ''; | |
| 188 | + const parsed = req.schema.safeParse(JSON.parse(text)); | |
| 189 | + if (!parsed.success) throw new Error(`extract: model output did not match schema: ${parsed.error.message}`); | |
| 190 | + const data = parsed.data as T & { confidence?: number }; | |
| 191 | + return { data: parsed.data, confidence: typeof data.confidence === 'number' ? Math.max(0, Math.min(1, data.confidence)) : 1, model, provider: this.id, usage, usdEst }; | |
| 192 | + } | |
| 193 | + | |
| 194 | + vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>> { | |
| 195 | + return this.extract('vision', req); | |
| 196 | + } | |
| 197 | + | |
| 198 | + async embed(req: EmbedRequest): Promise<EmbedResult> { | |
| 199 | + if (!this.embeddingModel) throw new AiNotConfiguredError('no embedding model configured'); | |
| 200 | + const res = await this.client.embeddings.create({ model: this.embeddingModel, input: req.texts, ...(this.embeddingModel.startsWith('text-embedding-3') ? { dimensions: this.embeddingDimensions } : {}) }); | |
| 201 | + const usage: Usage = { ...ZERO_USAGE, inputTokens: res.usage?.prompt_tokens ?? 0 }; | |
| 202 | + const usdEst = await this.account('embed', this.embeddingModel, usage, req.cost, req.texts.length); | |
| 203 | + const vectors = res.data.sort((a, b) => a.index - b.index).map((d) => d.embedding); | |
| 204 | + return { vectors, model: this.embeddingModel, provider: this.id, dimensions: vectors[0]?.length ?? this.embeddingDimensions, usdEst }; | |
| 205 | + } | |
| 206 | +} | |
added
packages/ai/src/router.ts
+125 −0
@@ -0,0 +1,125 @@ | ||
| 1 | +import { AnthropicProvider } from './providers/anthropic.js'; | |
| 2 | +import { OpenAICompatibleProvider } from './providers/openai-compatible.js'; | |
| 3 | +import { installDbCostSink } from './costs.js'; | |
| 4 | +import { AiNotConfiguredError, type CompletionRequest, type Completion, type EmbedRequest, type EmbedResult, type ExtractRequest, type ExtractResult, type ModelProvider, type Role, type StreamDelta, type ToolAgentRequest } from './types.js'; | |
| 5 | + | |
| 6 | +export interface RouterConfig { | |
| 7 | + anthropicApiKey?: string; | |
| 8 | + openaiApiKey?: string; | |
| 9 | + localBaseUrl?: string; | |
| 10 | + localApiKey?: string; | |
| 11 | + /** role → "provider:model" or "model" overrides, e.g. { research: 'anthropic:claude-opus-5' } */ | |
| 12 | + overrides?: Partial<Record<Role, string>>; | |
| 13 | + persistCosts?: boolean; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export const ROLES: Role[] = ['normalize', 'classify', 'summarize', 'resolve', 'research', 'vision', 'embed']; | |
| 17 | + | |
| 18 | +/** Read router config from process.env (server-side only). AI_MODEL_<ROLE> overrides the default model. */ | |
| 19 | +export function configFromEnv(env: NodeJS.ProcessEnv = process.env): RouterConfig { | |
| 20 | + const overrides: Partial<Record<Role, string>> = {}; | |
| 21 | + for (const r of ROLES) { | |
| 22 | + const v = env[`AI_MODEL_${r.toUpperCase()}`]; | |
| 23 | + if (v) overrides[r] = v; | |
| 24 | + } | |
| 25 | + return { | |
| 26 | + anthropicApiKey: env.ANTHROPIC_API_KEY, | |
| 27 | + openaiApiKey: env.OPENAI_API_KEY, | |
| 28 | + localBaseUrl: env.LOCAL_LLM_BASE_URL, | |
| 29 | + localApiKey: env.LOCAL_LLM_API_KEY, | |
| 30 | + overrides, | |
| 31 | + persistCosts: env.NODE_ENV !== 'test', | |
| 32 | + }; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** | |
| 36 | + * Picks a provider per role (§168): Anthropic for everything but embeddings when configured, | |
| 37 | + * OpenAI(-compatible) otherwise, embeddings only via OpenAI-compatible endpoints. Callers never | |
| 38 | + * see vendor SDKs; a missing provider surfaces as AiNotConfiguredError which the UI renders as | |
| 39 | + * "AI provider not configured". | |
| 40 | + */ | |
| 41 | +export class ModelRouter implements ModelProvider { | |
| 42 | + readonly id = 'router'; | |
| 43 | + readonly providers: ModelProvider[] = []; | |
| 44 | + private readonly overrides: Partial<Record<Role, string>>; | |
| 45 | + | |
| 46 | + constructor(cfg: RouterConfig) { | |
| 47 | + this.overrides = cfg.overrides ?? {}; | |
| 48 | + const perProviderModels = (pid: string): Partial<Record<Role, string>> => { | |
| 49 | + const out: Partial<Record<Role, string>> = {}; | |
| 50 | + for (const [role, spec] of Object.entries(this.overrides) as Array<[Role, string]>) { | |
| 51 | + const [p, m] = spec.includes(':') ? (spec.split(':', 2) as [string, string]) : [null, spec]; | |
| 52 | + if (!p || p === pid) out[role] = m; | |
| 53 | + } | |
| 54 | + return out; | |
| 55 | + }; | |
| 56 | + if (cfg.anthropicApiKey) this.providers.push(new AnthropicProvider({ apiKey: cfg.anthropicApiKey, models: perProviderModels('anthropic') })); | |
| 57 | + if (cfg.openaiApiKey) this.providers.push(new OpenAICompatibleProvider({ id: 'openai', apiKey: cfg.openaiApiKey, models: perProviderModels('openai') })); | |
| 58 | + if (cfg.localBaseUrl) this.providers.push(new OpenAICompatibleProvider({ id: 'local', apiKey: cfg.localApiKey ?? 'local', baseURL: cfg.localBaseUrl, models: perProviderModels('local') })); | |
| 59 | + if (cfg.persistCosts) void installDbCostSink(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + get configured(): boolean { | |
| 63 | + return this.providers.length > 0; | |
| 64 | + } | |
| 65 | + | |
| 66 | + /** Provider for a role: explicit "provider:" override first, else first provider supporting the role. */ | |
| 67 | + providerFor(role: Role): ModelProvider { | |
| 68 | + const spec = this.overrides[role]; | |
| 69 | + if (spec?.includes(':')) { | |
| 70 | + const pid = spec.split(':', 1)[0]; | |
| 71 | + const p = this.providers.find((x) => x.id === pid); | |
| 72 | + if (p) return p; | |
| 73 | + } | |
| 74 | + const p = this.providers.find((x) => x.supports(role)); | |
| 75 | + if (!p) throw new AiNotConfiguredError(role === 'embed' ? 'No embeddings provider configured (set OPENAI_API_KEY)' : 'AI provider not configured (set ANTHROPIC_API_KEY or OPENAI_API_KEY)'); | |
| 76 | + return p; | |
| 77 | + } | |
| 78 | + | |
| 79 | + supports(role: Role): boolean { | |
| 80 | + return this.providers.some((p) => p.supports(role)); | |
| 81 | + } | |
| 82 | + modelFor(role: Role): string { | |
| 83 | + return this.providerFor(role).modelFor(role); | |
| 84 | + } | |
| 85 | + complete(role: Role, req: CompletionRequest): Promise<Completion> { | |
| 86 | + return this.providerFor(role).complete(role, req); | |
| 87 | + } | |
| 88 | + stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta> { | |
| 89 | + return this.providerFor(role).stream(role, req); | |
| 90 | + } | |
| 91 | + runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta> { | |
| 92 | + return this.providerFor(role).runTools(role, req); | |
| 93 | + } | |
| 94 | + extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>> { | |
| 95 | + return this.providerFor(role).extract(role, req); | |
| 96 | + } | |
| 97 | + vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>> { | |
| 98 | + return this.providerFor('vision').vision(req); | |
| 99 | + } | |
| 100 | + embed(req: EmbedRequest): Promise<EmbedResult> { | |
| 101 | + return this.providerFor('embed').embed(req); | |
| 102 | + } | |
| 103 | + | |
| 104 | + /** Table of role → provider/model for diagnostics and /admin. */ | |
| 105 | + table(): Array<{ role: Role; provider: string | null; model: string | null }> { | |
| 106 | + return ROLES.map((role) => { | |
| 107 | + try { | |
| 108 | + const p = this.providerFor(role); | |
| 109 | + return { role, provider: p.id, model: p.modelFor(role) }; | |
| 110 | + } catch { | |
| 111 | + return { role, provider: null, model: null }; | |
| 112 | + } | |
| 113 | + }); | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +let _router: ModelRouter | null = null; | |
| 118 | +/** Process-wide router built from env. */ | |
| 119 | +export function getRouter(): ModelRouter { | |
| 120 | + if (!_router) _router = new ModelRouter(configFromEnv()); | |
| 121 | + return _router; | |
| 122 | +} | |
| 123 | +export function resetRouter(): void { | |
| 124 | + _router = null; | |
| 125 | +} | |
added
packages/ai/src/types.ts
+150 −0
@@ -0,0 +1,150 @@ | ||
| 1 | +import type { z } from 'zod'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Provider-neutral model abstraction (CLAUDE.md §168). Nothing outside packages/ai imports a | |
| 5 | + * vendor SDK; callers speak in roles and these request shapes. | |
| 6 | + */ | |
| 7 | +export type Role = 'normalize' | 'classify' | 'resolve' | 'research' | 'vision' | 'embed' | 'summarize'; | |
| 8 | + | |
| 9 | +export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; | |
| 10 | + | |
| 11 | +export interface CostContext { | |
| 12 | + connectorId?: string | null; | |
| 13 | + categorySlug?: string | null; | |
| 14 | + userId?: string | null; | |
| 15 | + endpoint?: string | null; | |
| 16 | + metadata?: Record<string, unknown>; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface ImageInput { | |
| 20 | + /** base64-encoded bytes (no data: prefix) or an https URL */ | |
| 21 | + data?: string; | |
| 22 | + url?: string; | |
| 23 | + mediaType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export type ContentPart = { type: 'text'; text: string } | { type: 'image'; image: ImageInput }; | |
| 27 | + | |
| 28 | +export interface ChatMessage { | |
| 29 | + role: 'user' | 'assistant'; | |
| 30 | + content: string | ContentPart[]; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface ToolDefinition { | |
| 34 | + name: string; | |
| 35 | + description: string; | |
| 36 | + /** JSON Schema (draft 2020-12 subset) for the input object */ | |
| 37 | + inputSchema: Record<string, unknown>; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface CompletionRequest { | |
| 41 | + system?: string; | |
| 42 | + messages: ChatMessage[]; | |
| 43 | + maxTokens?: number; | |
| 44 | + temperature?: number; | |
| 45 | + effort?: Effort; | |
| 46 | + /** ask for JSON output (best effort; use extract() for schema-validated output) */ | |
| 47 | + json?: boolean; | |
| 48 | + stopSequences?: string[]; | |
| 49 | + cost?: CostContext; | |
| 50 | +} | |
| 51 | + | |
| 52 | +export interface Usage { | |
| 53 | + inputTokens: number; | |
| 54 | + outputTokens: number; | |
| 55 | + cacheReadTokens: number; | |
| 56 | + cacheWriteTokens: number; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export interface Completion { | |
| 60 | + text: string; | |
| 61 | + model: string; | |
| 62 | + provider: string; | |
| 63 | + usage: Usage; | |
| 64 | + usdEst: number; | |
| 65 | + stopReason: string | null; | |
| 66 | + refusal: { category: string | null; explanation: string | null } | null; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export type StreamDelta = | |
| 70 | + | { type: 'text'; text: string } | |
| 71 | + | { type: 'thinking'; text: string } | |
| 72 | + | { type: 'tool_call'; id: string; name: string; input: unknown } | |
| 73 | + | { type: 'tool_result'; id: string; name: string; output: unknown; isError: boolean; durationMs: number } | |
| 74 | + | { type: 'usage'; usage: Usage; usdEst: number; model: string } | |
| 75 | + | { type: 'error'; message: string } | |
| 76 | + | { type: 'done'; stopReason: string | null }; | |
| 77 | + | |
| 78 | +export interface ToolAgentRequest extends CompletionRequest { | |
| 79 | + tools: ToolDefinition[]; | |
| 80 | + /** execute a tool call; return JSON-serialisable output (throw to signal an error result) */ | |
| 81 | + execute: (name: string, input: unknown) => Promise<unknown>; | |
| 82 | + /** max model↔tool round trips (default 8) */ | |
| 83 | + maxIterations?: number; | |
| 84 | + signal?: AbortSignal; | |
| 85 | +} | |
| 86 | + | |
| 87 | +export interface ExtractRequest<T> { | |
| 88 | + schema: z.ZodType<T>; | |
| 89 | + /** instruction for the extraction */ | |
| 90 | + prompt: string; | |
| 91 | + /** raw input: text or content parts (images allowed) */ | |
| 92 | + input: string | ContentPart[]; | |
| 93 | + system?: string; | |
| 94 | + maxTokens?: number; | |
| 95 | + effort?: Effort; | |
| 96 | + cost?: CostContext; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export interface ExtractResult<T> { | |
| 100 | + data: T; | |
| 101 | + /** 0–1: the model's self-reported confidence when the schema has one, else 1 when parsed */ | |
| 102 | + confidence: number; | |
| 103 | + model: string; | |
| 104 | + provider: string; | |
| 105 | + usage: Usage; | |
| 106 | + usdEst: number; | |
| 107 | +} | |
| 108 | + | |
| 109 | +export interface EmbedRequest { | |
| 110 | + texts: string[]; | |
| 111 | + cost?: CostContext; | |
| 112 | +} | |
| 113 | + | |
| 114 | +export interface EmbedResult { | |
| 115 | + vectors: number[][]; | |
| 116 | + model: string; | |
| 117 | + provider: string; | |
| 118 | + dimensions: number; | |
| 119 | + usdEst: number; | |
| 120 | +} | |
| 121 | + | |
| 122 | +export interface ModelProvider { | |
| 123 | + readonly id: string; | |
| 124 | + /** which roles this provider can serve given its configuration */ | |
| 125 | + supports(role: Role): boolean; | |
| 126 | + complete(role: Role, req: CompletionRequest): Promise<Completion>; | |
| 127 | + stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta>; | |
| 128 | + /** tool-use agent loop with streaming events */ | |
| 129 | + runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta>; | |
| 130 | + extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>>; | |
| 131 | + vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>>; | |
| 132 | + embed(req: EmbedRequest): Promise<EmbedResult>; | |
| 133 | + modelFor(role: Role): string; | |
| 134 | +} | |
| 135 | + | |
| 136 | +export class AiNotConfiguredError extends Error { | |
| 137 | + readonly code = 'ai_not_configured'; | |
| 138 | + constructor(detail = 'AI provider not configured') { | |
| 139 | + super(detail); | |
| 140 | + this.name = 'AiNotConfiguredError'; | |
| 141 | + } | |
| 142 | +} | |
| 143 | + | |
| 144 | +export class AiRefusalError extends Error { | |
| 145 | + readonly code = 'ai_refusal'; | |
| 146 | + constructor(public readonly category: string | null, explanation: string | null) { | |
| 147 | + super(explanation ?? 'The model declined this request'); | |
| 148 | + this.name = 'AiRefusalError'; | |
| 149 | + } | |
| 150 | +} | |
added
packages/ai/tsconfig.json
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../../tsconfig.base.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "rootDir": "src", | |
| 5 | + "outDir": "dist", | |
| 6 | + "noEmit": true | |
| 7 | + }, | |
| 8 | + "include": ["src"] | |
| 9 | +} | |
added
packages/ai/vitest.config.ts
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { defineConfig } from 'vitest/config'; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + test: { include: ['src/**/*.test.ts'] }, | |
| 5 | +}); | |
added
packages/database/src/schema/ai.ts
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +import { pgTable, text, integer, real, index, jsonb, boolean } from 'drizzle-orm/pg-core'; | |
| 2 | +import { createdAt, ts, jsonObject } from './_common.js'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Scanner sessions (§114): what the user submitted, what the model guessed, which candidates we | |
| 6 | + * showed and what the user picked. The pick is the feedback loop for identification quality. | |
| 7 | + */ | |
| 8 | +export const scannerSessions = pgTable( | |
| 9 | + 'scanner_sessions', | |
| 10 | + { | |
| 11 | + id: text('id').primaryKey(), | |
| 12 | + userId: text('user_id'), | |
| 13 | + anonId: text('anon_id'), | |
| 14 | + ipHash: text('ip_hash'), | |
| 15 | + mode: text('mode').notNull(), // photo | url | text | |
| 16 | + inputUrl: text('input_url'), | |
| 17 | + inputText: text('input_text'), | |
| 18 | + imageCount: integer('image_count').notNull().default(0), | |
| 19 | + /** stored thumbnails (data URLs, ≤ 3 × ~40 KB) for review; never the originals */ | |
| 20 | + thumbnails: jsonb('thumbnails').$type<string[]>().notNull().default([]), | |
| 21 | + guess: jsonObject<Record<string, unknown>>('guess'), | |
| 22 | + guessConfidence: real('guess_confidence'), | |
| 23 | + candidates: jsonb('candidates').$type<Array<{ assetId: string; slug: string; title: string; score: number }>>().notNull().default([]), | |
| 24 | + chosenAssetId: text('chosen_asset_id'), | |
| 25 | + /** matched listing (url mode) */ | |
| 26 | + listing: jsonObject<Record<string, unknown>>('listing'), | |
| 27 | + model: text('model'), | |
| 28 | + usdEst: real('usd_est').notNull().default(0), | |
| 29 | + durationMs: integer('duration_ms'), | |
| 30 | + error: text('error'), | |
| 31 | + createdAt: createdAt(), | |
| 32 | + chosenAt: ts('chosen_at'), | |
| 33 | + }, | |
| 34 | + (t) => [index('scanner_sessions_created_idx').on(t.createdAt), index('scanner_sessions_ip_idx').on(t.ipHash, t.createdAt), index('scanner_sessions_user_idx').on(t.userId)], | |
| 35 | +); | |
| 36 | + | |
| 37 | +/** AI Research conversations (§128). One session per anonymous cookie/user thread. */ | |
| 38 | +export const researchSessions = pgTable( | |
| 39 | + 'research_sessions', | |
| 40 | + { | |
| 41 | + id: text('id').primaryKey(), | |
| 42 | + userId: text('user_id'), | |
| 43 | + anonId: text('anon_id'), | |
| 44 | + title: text('title'), | |
| 45 | + model: text('model'), | |
| 46 | + messageCount: integer('message_count').notNull().default(0), | |
| 47 | + usdEst: real('usd_est').notNull().default(0), | |
| 48 | + archived: boolean('archived').notNull().default(false), | |
| 49 | + createdAt: createdAt(), | |
| 50 | + updatedAt: ts('updated_at').notNull().defaultNow(), | |
| 51 | + }, | |
| 52 | + (t) => [index('research_sessions_anon_idx').on(t.anonId, t.updatedAt), index('research_sessions_user_idx').on(t.userId, t.updatedAt)], | |
| 53 | +); | |
| 54 | + | |
| 55 | +export const researchMessages = pgTable( | |
| 56 | + 'research_messages', | |
| 57 | + { | |
| 58 | + id: text('id').primaryKey(), | |
| 59 | + sessionId: text('session_id').notNull(), | |
| 60 | + role: text('role').notNull(), // user | assistant | |
| 61 | + content: text('content').notNull(), | |
| 62 | + /** transparent tool trace: [{name, input, ok, ms, rows}] */ | |
| 63 | + toolCalls: jsonb('tool_calls').$type<Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }>>().notNull().default([]), | |
| 64 | + usage: jsonObject<Record<string, number>>('usage'), | |
| 65 | + usdEst: real('usd_est').notNull().default(0), | |
| 66 | + model: text('model'), | |
| 67 | + createdAt: createdAt(), | |
| 68 | + }, | |
| 69 | + (t) => [index('research_messages_session_idx').on(t.sessionId, t.createdAt)], | |
| 70 | +); | |
| 71 | + | |
| 72 | +/** Per-key daily quota for anonymous AI features (scanner/research) keyed by ip hash or user. */ | |
| 73 | +export const aiQuotas = pgTable( | |
| 74 | + 'ai_quotas', | |
| 75 | + { | |
| 76 | + key: text('key').notNull(), | |
| 77 | + feature: text('feature').notNull(), | |
| 78 | + date: text('date').notNull(), | |
| 79 | + count: integer('count').notNull().default(0), | |
| 80 | + }, | |
| 81 | + (t) => [index('ai_quotas_uq').on(t.key, t.feature, t.date)], | |
| 82 | +); | |
modified
packages/database/src/schema/index.ts
+1 −0
@@ -5,3 +5,4 @@ export * from './assets.js'; | ||
| 5 | 5 | export * from './market.js'; |
| 6 | 6 | export * from './analytics.js'; |
| 7 | 7 | export * from './users.js'; |
| 8 | +export * from './ai.js'; | |
modified
pnpm-lock.yaml
+677 −2
@@ -23,13 +23,46 @@ importers: | ||
| 23 | 23 | |
| 24 | 24 | apps/api: |
| 25 | 25 | dependencies: |
| 26 | + '@fastify/compress': | |
| 27 | + specifier: ^9.0.0 | |
| 28 | + version: 9.2.0 | |
| 29 | + '@fastify/cors': | |
| 30 | + specifier: ^11.0.0 | |
| 31 | + version: 11.3.0 | |
| 32 | + '@fastify/etag': | |
| 33 | + specifier: ^6.0.0 | |
| 34 | + version: 6.2.0 | |
| 35 | + '@fastify/rate-limit': | |
| 36 | + specifier: ^11.0.0 | |
| 37 | + version: 11.2.0 | |
| 38 | + '@rareindex/connectors': | |
| 39 | + specifier: workspace:* | |
| 40 | + version: link:../../packages/connectors | |
| 41 | + '@rareindex/database': | |
| 42 | + specifier: workspace:* | |
| 43 | + version: link:../../packages/database | |
| 26 | 44 | '@rareindex/shared': |
| 27 | 45 | specifier: workspace:* |
| 28 | 46 | version: link:../../packages/shared |
| 47 | + '@rareindex/taxonomy': | |
| 48 | + specifier: workspace:* | |
| 49 | + version: link:../../packages/taxonomy | |
| 50 | + fastify: | |
| 51 | + specifier: ^5.6.0 | |
| 52 | + version: 5.12.3 | |
| 53 | + fastify-plugin: | |
| 54 | + specifier: ^6.0.0 | |
| 55 | + version: 6.0.0 | |
| 56 | + zod: | |
| 57 | + specifier: ^4.0.0 | |
| 58 | + version: 4.5.4 | |
| 29 | 59 | devDependencies: |
| 30 | 60 | '@types/node': |
| 31 | 61 | specifier: ^24.0.0 |
| 32 | 62 | version: 24.13.3 |
| 63 | + tsx: | |
| 64 | + specifier: ^4.20.0 | |
| 65 | + version: 4.23.13 | |
| 33 | 66 | typescript: |
| 34 | 67 | specifier: ^5.9.3 |
| 35 | 68 | version: 5.9.3 |
@@ -39,6 +72,9 @@ importers: | ||
| 39 | 72 | |
| 40 | 73 | apps/web: |
| 41 | 74 | dependencies: |
| 75 | + '@rareindex/ai': | |
| 76 | + specifier: workspace:* | |
| 77 | + version: link:../../packages/ai | |
| 42 | 78 | '@rareindex/connectors': |
| 43 | 79 | specifier: workspace:* |
| 44 | 80 | version: link:../../packages/connectors |
@@ -60,6 +96,9 @@ importers: | ||
| 60 | 96 | next: |
| 61 | 97 | specifier: 16.3.4 |
| 62 | 98 | version: 16.3.4(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) |
| 99 | + pg-boss: | |
| 100 | + specifier: ^12.30.0 | |
| 101 | + version: 12.30.0 | |
| 63 | 102 | react: |
| 64 | 103 | specifier: 19.2.8 |
| 65 | 104 | version: 19.2.8 |
@@ -126,6 +165,37 @@ importers: | ||
| 126 | 165 | specifier: ^3.2.0 |
| 127 | 166 | version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13) |
| 128 | 167 | |
| 168 | + packages/ai: | |
| 169 | + dependencies: | |
| 170 | + '@anthropic-ai/sdk': | |
| 171 | + specifier: ^0.124.0 | |
| 172 | + version: 0.124.0(zod@4.5.4) | |
| 173 | + '@rareindex/database': | |
| 174 | + specifier: workspace:* | |
| 175 | + version: link:../database | |
| 176 | + '@rareindex/shared': | |
| 177 | + specifier: workspace:* | |
| 178 | + version: link:../shared | |
| 179 | + '@rareindex/taxonomy': | |
| 180 | + specifier: workspace:* | |
| 181 | + version: link:../taxonomy | |
| 182 | + openai: | |
| 183 | + specifier: ^7.10.0 | |
| 184 | + version: 7.10.0(undici@7.29.1)(zod@4.5.4) | |
| 185 | + zod: | |
| 186 | + specifier: ^4.0.0 | |
| 187 | + version: 4.5.4 | |
| 188 | + devDependencies: | |
| 189 | + '@types/node': | |
| 190 | + specifier: ^24.0.0 | |
| 191 | + version: 24.13.3 | |
| 192 | + typescript: | |
| 193 | + specifier: ^5.9.3 | |
| 194 | + version: 5.9.3 | |
| 195 | + vitest: | |
| 196 | + specifier: ^3.2.0 | |
| 197 | + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13) | |
| 198 | + | |
| 129 | 199 | packages/connectors: |
| 130 | 200 | dependencies: |
| 131 | 201 | '@rareindex/shared': |
@@ -161,7 +231,7 @@ importers: | ||
| 161 | 231 | version: link:../taxonomy |
| 162 | 232 | drizzle-orm: |
| 163 | 233 | specifier: ^0.45.0 |
| 164 | − version: 0.45.2(postgres@3.4.9) | |
| 234 | + version: 0.45.2(pg@8.23.0)(postgres@3.4.9) | |
| 165 | 235 | postgres: |
| 166 | 236 | specifier: ^3.4.7 |
| 167 | 237 | version: 3.4.9 |
@@ -242,6 +312,15 @@ packages: | ||
| 242 | 312 | resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} |
| 243 | 313 | engines: {node: '>=10'} |
| 244 | 314 | |
| 315 | + '@anthropic-ai/sdk@0.124.0': | |
| 316 | + resolution: {integrity: sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==} | |
| 317 | + hasBin: true | |
| 318 | + peerDependencies: | |
| 319 | + zod: ^3.25.0 || ^4.0.0 | |
| 320 | + peerDependenciesMeta: | |
| 321 | + zod: | |
| 322 | + optional: true | |
| 323 | + | |
| 245 | 324 | '@babel/code-frame@7.29.7': |
| 246 | 325 | resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} |
| 247 | 326 | engines: {node: '>=6.9.0'} |
@@ -297,6 +376,10 @@ packages: | ||
| 297 | 376 | engines: {node: '>=6.0.0'} |
| 298 | 377 | hasBin: true |
| 299 | 378 | |
| 379 | + '@babel/runtime@7.29.7': | |
| 380 | + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} | |
| 381 | + engines: {node: '>=6.9.0'} | |
| 382 | + | |
| 300 | 383 | '@babel/template@7.29.7': |
| 301 | 384 | resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} |
| 302 | 385 | engines: {node: '>=6.9.0'} |
@@ -820,6 +903,39 @@ packages: | ||
| 820 | 903 | resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} |
| 821 | 904 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} |
| 822 | 905 | |
| 906 | + '@fastify/accept-negotiator@2.1.0': | |
| 907 | + resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==} | |
| 908 | + | |
| 909 | + '@fastify/ajv-compiler@4.0.6': | |
| 910 | + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} | |
| 911 | + | |
| 912 | + '@fastify/compress@9.2.0': | |
| 913 | + resolution: {integrity: sha512-35VL33PIEt/UbD/ZMlRNaICd2BQz0IaG7en74Dv7tI+MHIQkedibS1+9JfekWH4I1g+Y8RbbZD4Zsk9h4XTItg==} | |
| 914 | + | |
| 915 | + '@fastify/cors@11.3.0': | |
| 916 | + resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==} | |
| 917 | + | |
| 918 | + '@fastify/error@4.2.0': | |
| 919 | + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} | |
| 920 | + | |
| 921 | + '@fastify/etag@6.2.0': | |
| 922 | + resolution: {integrity: sha512-ptNM6UYBY+DFbZ4UPCSlGN0YRRIBy1ZAoo/cPLu8m7xJ58XpfTYLvS5bpS5KFws7MEcCqJJuAMu+rvrRCISfgw==} | |
| 923 | + | |
| 924 | + '@fastify/fast-json-stringify-compiler@5.1.0': | |
| 925 | + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} | |
| 926 | + | |
| 927 | + '@fastify/forwarded@3.0.2': | |
| 928 | + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} | |
| 929 | + | |
| 930 | + '@fastify/merge-json-schemas@0.2.1': | |
| 931 | + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} | |
| 932 | + | |
| 933 | + '@fastify/proxy-addr@5.1.0': | |
| 934 | + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} | |
| 935 | + | |
| 936 | + '@fastify/rate-limit@11.2.0': | |
| 937 | + resolution: {integrity: sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==} | |
| 938 | + | |
| 823 | 939 | '@humanfs/core@0.19.2': |
| 824 | 940 | resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} |
| 825 | 941 | engines: {node: '>=18.18.0'} |
@@ -1018,6 +1134,10 @@ packages: | ||
| 1018 | 1134 | '@jridgewell/trace-mapping@0.3.31': |
| 1019 | 1135 | resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} |
| 1020 | 1136 | |
| 1137 | + '@lukeed/ms@2.0.2': | |
| 1138 | + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} | |
| 1139 | + engines: {node: '>=8'} | |
| 1140 | + | |
| 1021 | 1141 | '@napi-rs/lzma-linux-x64-gnu@1.5.1': |
| 1022 | 1142 | resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} |
| 1023 | 1143 | engines: {node: ^22.20 || ^24.12 || >=25} |
@@ -1250,6 +1370,9 @@ packages: | ||
| 1250 | 1370 | '@rtsao/scc@1.1.0': |
| 1251 | 1371 | resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} |
| 1252 | 1372 | |
| 1373 | + '@stablelib/base64@1.0.1': | |
| 1374 | + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} | |
| 1375 | + | |
| 1253 | 1376 | '@swc/helpers@0.5.23': |
| 1254 | 1377 | resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} |
| 1255 | 1378 | |
@@ -1590,6 +1713,13 @@ packages: | ||
| 1590 | 1713 | '@vitest/utils@3.2.7': |
| 1591 | 1714 | resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} |
| 1592 | 1715 | |
| 1716 | + abort-controller@3.0.0: | |
| 1717 | + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} | |
| 1718 | + engines: {node: '>=6.5'} | |
| 1719 | + | |
| 1720 | + abstract-logging@2.0.1: | |
| 1721 | + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} | |
| 1722 | + | |
| 1593 | 1723 | acorn-jsx@5.3.2: |
| 1594 | 1724 | resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} |
| 1595 | 1725 | peerDependencies: |
@@ -1600,9 +1730,20 @@ packages: | ||
| 1600 | 1730 | engines: {node: '>=0.4.0'} |
| 1601 | 1731 | hasBin: true |
| 1602 | 1732 | |
| 1733 | + ajv-formats@3.0.1: | |
| 1734 | + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} | |
| 1735 | + peerDependencies: | |
| 1736 | + ajv: ^8.0.0 | |
| 1737 | + peerDependenciesMeta: | |
| 1738 | + ajv: | |
| 1739 | + optional: true | |
| 1740 | + | |
| 1603 | 1741 | ajv@6.15.0: |
| 1604 | 1742 | resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} |
| 1605 | 1743 | |
| 1744 | + ajv@8.20.0: | |
| 1745 | + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} | |
| 1746 | + | |
| 1606 | 1747 | ansi-styles@4.3.0: |
| 1607 | 1748 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} |
| 1608 | 1749 | engines: {node: '>=8'} |
@@ -1665,6 +1806,9 @@ packages: | ||
| 1665 | 1806 | resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} |
| 1666 | 1807 | engines: {node: '>= 0.4'} |
| 1667 | 1808 | |
| 1809 | + avvio@9.3.0: | |
| 1810 | + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} | |
| 1811 | + | |
| 1668 | 1812 | axe-core@4.13.0: |
| 1669 | 1813 | resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} |
| 1670 | 1814 | engines: {node: '>=4'} |
@@ -1680,6 +1824,9 @@ packages: | ||
| 1680 | 1824 | resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} |
| 1681 | 1825 | engines: {node: 18 || 20 || >=22} |
| 1682 | 1826 | |
| 1827 | + base64-js@1.5.1: | |
| 1828 | + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} | |
| 1829 | + | |
| 1683 | 1830 | baseline-browser-mapping@2.11.21: |
| 1684 | 1831 | resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} |
| 1685 | 1832 | engines: {node: '>=6.0.0'} |
@@ -1707,6 +1854,9 @@ packages: | ||
| 1707 | 1854 | buffer-from@1.1.2: |
| 1708 | 1855 | resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} |
| 1709 | 1856 | |
| 1857 | + buffer@6.0.3: | |
| 1858 | + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} | |
| 1859 | + | |
| 1710 | 1860 | cac@6.7.14: |
| 1711 | 1861 | resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} |
| 1712 | 1862 | engines: {node: '>=8'} |
@@ -1765,6 +1915,14 @@ packages: | ||
| 1765 | 1915 | convert-source-map@2.0.0: |
| 1766 | 1916 | resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} |
| 1767 | 1917 | |
| 1918 | + cookie@1.1.1: | |
| 1919 | + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} | |
| 1920 | + engines: {node: '>=18'} | |
| 1921 | + | |
| 1922 | + cron-parser@5.10.0: | |
| 1923 | + resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==} | |
| 1924 | + engines: {node: '>=18'} | |
| 1925 | + | |
| 1768 | 1926 | cross-spawn@7.0.6: |
| 1769 | 1927 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} |
| 1770 | 1928 | engines: {node: '>= 8'} |
@@ -1826,6 +1984,10 @@ packages: | ||
| 1826 | 1984 | resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} |
| 1827 | 1985 | engines: {node: '>= 0.4'} |
| 1828 | 1986 | |
| 1987 | + dequal@2.0.3: | |
| 1988 | + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} | |
| 1989 | + engines: {node: '>=6'} | |
| 1990 | + | |
| 1829 | 1991 | detect-libc@2.1.2: |
| 1830 | 1992 | resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} |
| 1831 | 1993 | engines: {node: '>=8'} |
@@ -2158,10 +2320,21 @@ packages: | ||
| 2158 | 2320 | resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} |
| 2159 | 2321 | engines: {node: '>=0.10.0'} |
| 2160 | 2322 | |
| 2323 | + event-target-shim@5.0.1: | |
| 2324 | + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} | |
| 2325 | + engines: {node: '>=6'} | |
| 2326 | + | |
| 2327 | + events@3.3.0: | |
| 2328 | + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} | |
| 2329 | + engines: {node: '>=0.8.x'} | |
| 2330 | + | |
| 2161 | 2331 | expect-type@1.4.0: |
| 2162 | 2332 | resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} |
| 2163 | 2333 | engines: {node: '>=12.0.0'} |
| 2164 | 2334 | |
| 2335 | + fast-decode-uri-component@1.0.1: | |
| 2336 | + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} | |
| 2337 | + | |
| 2165 | 2338 | fast-deep-equal@3.1.3: |
| 2166 | 2339 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} |
| 2167 | 2340 | |
@@ -2172,9 +2345,30 @@ packages: | ||
| 2172 | 2345 | fast-json-stable-stringify@2.1.0: |
| 2173 | 2346 | resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} |
| 2174 | 2347 | |
| 2348 | + fast-json-stringify@7.0.1: | |
| 2349 | + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} | |
| 2350 | + | |
| 2175 | 2351 | fast-levenshtein@2.0.6: |
| 2176 | 2352 | resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} |
| 2177 | 2353 | |
| 2354 | + fast-querystring@1.1.2: | |
| 2355 | + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} | |
| 2356 | + | |
| 2357 | + fast-sha256@1.3.0: | |
| 2358 | + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} | |
| 2359 | + | |
| 2360 | + fast-uri@3.1.7: | |
| 2361 | + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} | |
| 2362 | + | |
| 2363 | + fast-uri@4.1.4: | |
| 2364 | + resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==} | |
| 2365 | + | |
| 2366 | + fastify-plugin@6.0.0: | |
| 2367 | + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} | |
| 2368 | + | |
| 2369 | + fastify@5.12.3: | |
| 2370 | + resolution: {integrity: sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==} | |
| 2371 | + | |
| 2178 | 2372 | fastq@1.20.3: |
| 2179 | 2373 | resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} |
| 2180 | 2374 | |
@@ -2195,6 +2389,10 @@ packages: | ||
| 2195 | 2389 | resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} |
| 2196 | 2390 | engines: {node: '>=8'} |
| 2197 | 2391 | |
| 2392 | + find-my-way@9.9.0: | |
| 2393 | + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} | |
| 2394 | + engines: {node: '>=20'} | |
| 2395 | + | |
| 2198 | 2396 | find-up@5.0.0: |
| 2199 | 2397 | resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} |
| 2200 | 2398 | engines: {node: '>=10'} |
@@ -2315,6 +2513,9 @@ packages: | ||
| 2315 | 2513 | resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} |
| 2316 | 2514 | engines: {node: '>=0.10.0'} |
| 2317 | 2515 | |
| 2516 | + ieee754@1.2.1: | |
| 2517 | + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} | |
| 2518 | + | |
| 2318 | 2519 | ignore@5.3.2: |
| 2319 | 2520 | resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} |
| 2320 | 2521 | engines: {node: '>= 4'} |
@@ -2335,6 +2536,14 @@ packages: | ||
| 2335 | 2536 | resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} |
| 2336 | 2537 | engines: {node: '>= 0.4'} |
| 2337 | 2538 | |
| 2539 | + ip-address@10.7.0: | |
| 2540 | + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} | |
| 2541 | + engines: {node: '>= 12'} | |
| 2542 | + | |
| 2543 | + ipaddr.js@2.5.0: | |
| 2544 | + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} | |
| 2545 | + engines: {node: '>= 10'} | |
| 2546 | + | |
| 2338 | 2547 | is-array-buffer@3.0.5: |
| 2339 | 2548 | resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} |
| 2340 | 2549 | engines: {node: '>= 0.4'} |
@@ -2474,9 +2683,19 @@ packages: | ||
| 2474 | 2683 | json-buffer@3.0.1: |
| 2475 | 2684 | resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} |
| 2476 | 2685 | |
| 2686 | + json-schema-ref-resolver@3.0.0: | |
| 2687 | + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} | |
| 2688 | + | |
| 2689 | + json-schema-to-ts@3.1.1: | |
| 2690 | + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} | |
| 2691 | + engines: {node: '>=16'} | |
| 2692 | + | |
| 2477 | 2693 | json-schema-traverse@0.4.1: |
| 2478 | 2694 | resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} |
| 2479 | 2695 | |
| 2696 | + json-schema-traverse@1.0.0: | |
| 2697 | + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} | |
| 2698 | + | |
| 2480 | 2699 | json-stable-stringify-without-jsonify@1.0.1: |
| 2481 | 2700 | resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} |
| 2482 | 2701 | |
@@ -2507,6 +2726,9 @@ packages: | ||
| 2507 | 2726 | resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} |
| 2508 | 2727 | engines: {node: '>= 0.8.0'} |
| 2509 | 2728 | |
| 2729 | + light-my-request@6.6.0: | |
| 2730 | + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} | |
| 2731 | + | |
| 2510 | 2732 | lightningcss-android-arm64@1.32.0: |
| 2511 | 2733 | resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} |
| 2512 | 2734 | engines: {node: '>= 12.0.0'} |
@@ -2603,6 +2825,10 @@ packages: | ||
| 2603 | 2825 | peerDependencies: |
| 2604 | 2826 | react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 |
| 2605 | 2827 | |
| 2828 | + luxon@3.7.2: | |
| 2829 | + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} | |
| 2830 | + engines: {node: '>=12'} | |
| 2831 | + | |
| 2606 | 2832 | magic-string@0.30.21: |
| 2607 | 2833 | resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} |
| 2608 | 2834 | |
@@ -2618,6 +2844,10 @@ packages: | ||
| 2618 | 2844 | resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} |
| 2619 | 2845 | engines: {node: '>=8.6'} |
| 2620 | 2846 | |
| 2847 | + mime-db@1.54.0: | |
| 2848 | + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} | |
| 2849 | + engines: {node: '>= 0.6'} | |
| 2850 | + | |
| 2621 | 2851 | minimatch@10.2.6: |
| 2622 | 2852 | resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} |
| 2623 | 2853 | engines: {node: 18 || 20 || >=22} |
@@ -2628,6 +2858,10 @@ packages: | ||
| 2628 | 2858 | minimist@1.2.8: |
| 2629 | 2859 | resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} |
| 2630 | 2860 | |
| 2861 | + minipass@7.1.3: | |
| 2862 | + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} | |
| 2863 | + engines: {node: '>=16 || 14 >=14.17'} | |
| 2864 | + | |
| 2631 | 2865 | ms@2.1.3: |
| 2632 | 2866 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} |
| 2633 | 2867 | |
@@ -2673,6 +2907,10 @@ packages: | ||
| 2673 | 2907 | resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} |
| 2674 | 2908 | engines: {node: '>=18'} |
| 2675 | 2909 | |
| 2910 | + non-error@0.1.0: | |
| 2911 | + resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} | |
| 2912 | + engines: {node: '>=20'} | |
| 2913 | + | |
| 2676 | 2914 | nth-check@2.1.1: |
| 2677 | 2915 | resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} |
| 2678 | 2916 | |
@@ -2712,6 +2950,30 @@ packages: | ||
| 2712 | 2950 | resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} |
| 2713 | 2951 | engines: {node: '>=14.0.0'} |
| 2714 | 2952 | |
| 2953 | + openai@7.10.0: | |
| 2954 | + resolution: {integrity: sha512-sn9t2Kls7O52PwuF9BUTYNu4Gk/r0lXJyrgaNht4TNRlZFb3dJIGO0RciSgjARGCBRtWjySubAQFJttlzUvGQQ==} | |
| 2955 | + engines: {node: '>=22.0.0'} | |
| 2956 | + peerDependencies: | |
| 2957 | + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' | |
| 2958 | + '@smithy/hash-node': '>=4.3.0 <5' | |
| 2959 | + '@smithy/signature-v4': '>=5.4.0 <6' | |
| 2960 | + undici: '>=5 <9' | |
| 2961 | + ws: ^8.21.0 | |
| 2962 | + zod: ^3.25 || ^4.0 | |
| 2963 | + peerDependenciesMeta: | |
| 2964 | + '@aws-sdk/credential-provider-node': | |
| 2965 | + optional: true | |
| 2966 | + '@smithy/hash-node': | |
| 2967 | + optional: true | |
| 2968 | + '@smithy/signature-v4': | |
| 2969 | + optional: true | |
| 2970 | + undici: | |
| 2971 | + optional: true | |
| 2972 | + ws: | |
| 2973 | + optional: true | |
| 2974 | + zod: | |
| 2975 | + optional: true | |
| 2976 | + | |
| 2715 | 2977 | optionator@0.9.4: |
| 2716 | 2978 | resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} |
| 2717 | 2979 | engines: {node: '>= 0.8.0'} |
@@ -2759,6 +3021,45 @@ packages: | ||
| 2759 | 3021 | resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} |
| 2760 | 3022 | engines: {node: '>= 14.16'} |
| 2761 | 3023 | |
| 3024 | + pg-boss@12.30.0: | |
| 3025 | + resolution: {integrity: sha512-ke4Rr/uMJGwqt7DwKoWS9aa6XFlK43C0fMw1Ma0CoJrVxWSjE7ZwFLwz60dGQODESDA2+yq/B474pOH4n3d3CQ==} | |
| 3026 | + engines: {node: '>=22.12.0'} | |
| 3027 | + hasBin: true | |
| 3028 | + | |
| 3029 | + pg-cloudflare@1.4.0: | |
| 3030 | + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} | |
| 3031 | + | |
| 3032 | + pg-connection-string@2.14.0: | |
| 3033 | + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} | |
| 3034 | + | |
| 3035 | + pg-int8@1.0.1: | |
| 3036 | + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} | |
| 3037 | + engines: {node: '>=4.0.0'} | |
| 3038 | + | |
| 3039 | + pg-pool@3.14.0: | |
| 3040 | + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} | |
| 3041 | + peerDependencies: | |
| 3042 | + pg: '>=8.0' | |
| 3043 | + | |
| 3044 | + pg-protocol@1.16.0: | |
| 3045 | + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} | |
| 3046 | + | |
| 3047 | + pg-types@2.2.0: | |
| 3048 | + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} | |
| 3049 | + engines: {node: '>=4'} | |
| 3050 | + | |
| 3051 | + pg@8.23.0: | |
| 3052 | + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} | |
| 3053 | + engines: {node: '>= 16.0.0'} | |
| 3054 | + peerDependencies: | |
| 3055 | + pg-native: '>=3.0.1' | |
| 3056 | + peerDependenciesMeta: | |
| 3057 | + pg-native: | |
| 3058 | + optional: true | |
| 3059 | + | |
| 3060 | + pgpass@1.0.5: | |
| 3061 | + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} | |
| 3062 | + | |
| 2762 | 3063 | picocolors@1.1.1: |
| 2763 | 3064 | resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} |
| 2764 | 3065 | |
@@ -2792,6 +3093,22 @@ packages: | ||
| 2792 | 3093 | resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} |
| 2793 | 3094 | engines: {node: ^10 || ^12 || >=14} |
| 2794 | 3095 | |
| 3096 | + postgres-array@2.0.0: | |
| 3097 | + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} | |
| 3098 | + engines: {node: '>=4'} | |
| 3099 | + | |
| 3100 | + postgres-bytea@1.0.1: | |
| 3101 | + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} | |
| 3102 | + engines: {node: '>=0.10.0'} | |
| 3103 | + | |
| 3104 | + postgres-date@1.0.7: | |
| 3105 | + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} | |
| 3106 | + engines: {node: '>=0.10.0'} | |
| 3107 | + | |
| 3108 | + postgres-interval@1.2.0: | |
| 3109 | + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} | |
| 3110 | + engines: {node: '>=0.10.0'} | |
| 3111 | + | |
| 2795 | 3112 | postgres@3.4.9: |
| 2796 | 3113 | resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} |
| 2797 | 3114 | engines: {node: '>=12'} |
@@ -2800,9 +3117,16 @@ packages: | ||
| 2800 | 3117 | resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} |
| 2801 | 3118 | engines: {node: '>= 0.8.0'} |
| 2802 | 3119 | |
| 3120 | + process-warning@4.0.1: | |
| 3121 | + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} | |
| 3122 | + | |
| 2803 | 3123 | process-warning@5.1.0: |
| 2804 | 3124 | resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} |
| 2805 | 3125 | |
| 3126 | + process@0.11.10: | |
| 3127 | + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} | |
| 3128 | + engines: {node: '>= 0.6.0'} | |
| 3129 | + | |
| 2806 | 3130 | prop-types@15.8.1: |
| 2807 | 3131 | resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} |
| 2808 | 3132 | |
@@ -2828,6 +3152,10 @@ packages: | ||
| 2828 | 3152 | resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} |
| 2829 | 3153 | engines: {node: '>=0.10.0'} |
| 2830 | 3154 | |
| 3155 | + readable-stream@4.7.0: | |
| 3156 | + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} | |
| 3157 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 3158 | + | |
| 2831 | 3159 | real-require@0.2.0: |
| 2832 | 3160 | resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} |
| 2833 | 3161 | engines: {node: '>= 12.13.0'} |
@@ -2840,6 +3168,10 @@ packages: | ||
| 2840 | 3168 | resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} |
| 2841 | 3169 | engines: {node: '>= 0.4'} |
| 2842 | 3170 | |
| 3171 | + require-from-string@2.0.2: | |
| 3172 | + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} | |
| 3173 | + engines: {node: '>=0.10.0'} | |
| 3174 | + | |
| 2843 | 3175 | resolve-from@4.0.0: |
| 2844 | 3176 | resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} |
| 2845 | 3177 | engines: {node: '>=4'} |
@@ -2852,10 +3184,17 @@ packages: | ||
| 2852 | 3184 | engines: {node: '>= 0.4'} |
| 2853 | 3185 | hasBin: true |
| 2854 | 3186 | |
| 3187 | + ret@0.5.0: | |
| 3188 | + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} | |
| 3189 | + engines: {node: '>=10'} | |
| 3190 | + | |
| 2855 | 3191 | reusify@1.1.0: |
| 2856 | 3192 | resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} |
| 2857 | 3193 | engines: {iojs: '>=1.0.0', node: '>=0.10.0'} |
| 2858 | 3194 | |
| 3195 | + rfdc@1.4.1: | |
| 3196 | + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} | |
| 3197 | + | |
| 2859 | 3198 | rollup@4.63.1: |
| 2860 | 3199 | resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} |
| 2861 | 3200 | engines: {node: '>=18.0.0', npm: '>=8.0.0'} |
@@ -2868,6 +3207,9 @@ packages: | ||
| 2868 | 3207 | resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} |
| 2869 | 3208 | engines: {node: '>=0.4'} |
| 2870 | 3209 | |
| 3210 | + safe-buffer@5.2.1: | |
| 3211 | + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} | |
| 3212 | + | |
| 2871 | 3213 | safe-push-apply@1.0.0: |
| 2872 | 3214 | resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} |
| 2873 | 3215 | engines: {node: '>= 0.4'} |
@@ -2876,6 +3218,10 @@ packages: | ||
| 2876 | 3218 | resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} |
| 2877 | 3219 | engines: {node: '>= 0.4'} |
| 2878 | 3220 | |
| 3221 | + safe-regex2@5.1.1: | |
| 3222 | + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} | |
| 3223 | + hasBin: true | |
| 3224 | + | |
| 2879 | 3225 | safe-stable-stringify@2.5.0: |
| 2880 | 3226 | resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} |
| 2881 | 3227 | engines: {node: '>=10'} |
@@ -2886,6 +3232,9 @@ packages: | ||
| 2886 | 3232 | scheduler@0.27.0: |
| 2887 | 3233 | resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} |
| 2888 | 3234 | |
| 3235 | + secure-json-parse@4.1.0: | |
| 3236 | + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} | |
| 3237 | + | |
| 2889 | 3238 | semver@6.3.1: |
| 2890 | 3239 | resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} |
| 2891 | 3240 | hasBin: true |
@@ -2895,9 +3244,16 @@ packages: | ||
| 2895 | 3244 | engines: {node: '>=10'} |
| 2896 | 3245 | hasBin: true |
| 2897 | 3246 | |
| 3247 | + serialize-error@13.0.1: | |
| 3248 | + resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} | |
| 3249 | + engines: {node: '>=20'} | |
| 3250 | + | |
| 2898 | 3251 | server-only@0.0.1: |
| 2899 | 3252 | resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} |
| 2900 | 3253 | |
| 3254 | + set-cookie-parser@2.7.2: | |
| 3255 | + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} | |
| 3256 | + | |
| 2901 | 3257 | set-function-length@1.2.2: |
| 2902 | 3258 | resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} |
| 2903 | 3259 | engines: {node: '>= 0.4'} |
@@ -2970,6 +3326,9 @@ packages: | ||
| 2970 | 3326 | stackback@0.0.2: |
| 2971 | 3327 | resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} |
| 2972 | 3328 | |
| 3329 | + standardwebhooks@1.1.1: | |
| 3330 | + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} | |
| 3331 | + | |
| 2973 | 3332 | std-env@3.10.0: |
| 2974 | 3333 | resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} |
| 2975 | 3334 | |
@@ -3000,6 +3359,9 @@ packages: | ||
| 3000 | 3359 | resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} |
| 3001 | 3360 | engines: {node: '>= 0.4'} |
| 3002 | 3361 | |
| 3362 | + string_decoder@1.3.0: | |
| 3363 | + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} | |
| 3364 | + | |
| 3003 | 3365 | strip-bom@3.0.0: |
| 3004 | 3366 | resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} |
| 3005 | 3367 | engines: {node: '>=4'} |
@@ -3032,6 +3394,10 @@ packages: | ||
| 3032 | 3394 | resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} |
| 3033 | 3395 | engines: {node: '>= 0.4'} |
| 3034 | 3396 | |
| 3397 | + tagged-tag@1.0.0: | |
| 3398 | + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} | |
| 3399 | + engines: {node: '>=20'} | |
| 3400 | + | |
| 3035 | 3401 | tailwindcss@4.3.3: |
| 3036 | 3402 | resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} |
| 3037 | 3403 | |
@@ -3068,6 +3434,13 @@ packages: | ||
| 3068 | 3434 | resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} |
| 3069 | 3435 | engines: {node: '>=8.0'} |
| 3070 | 3436 | |
| 3437 | + toad-cache@3.7.4: | |
| 3438 | + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} | |
| 3439 | + engines: {node: '>=20'} | |
| 3440 | + | |
| 3441 | + ts-algebra@2.0.0: | |
| 3442 | + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} | |
| 3443 | + | |
| 3071 | 3444 | ts-api-utils@2.5.0: |
| 3072 | 3445 | resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} |
| 3073 | 3446 | engines: {node: '>=18.12'} |
@@ -3089,6 +3462,10 @@ packages: | ||
| 3089 | 3462 | resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} |
| 3090 | 3463 | engines: {node: '>= 0.8.0'} |
| 3091 | 3464 | |
| 3465 | + type-fest@5.9.0: | |
| 3466 | + resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==} | |
| 3467 | + engines: {node: '>=20'} | |
| 3468 | + | |
| 3092 | 3469 | typed-array-buffer@1.0.3: |
| 3093 | 3470 | resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} |
| 3094 | 3471 | engines: {node: '>= 0.4'} |
@@ -3252,6 +3629,10 @@ packages: | ||
| 3252 | 3629 | resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} |
| 3253 | 3630 | engines: {node: '>=0.10.0'} |
| 3254 | 3631 | |
| 3632 | + xtend@4.0.2: | |
| 3633 | + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} | |
| 3634 | + engines: {node: '>=0.4'} | |
| 3635 | + | |
| 3255 | 3636 | yallist@3.1.1: |
| 3256 | 3637 | resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} |
| 3257 | 3638 | |
@@ -3272,6 +3653,13 @@ snapshots: | ||
| 3272 | 3653 | |
| 3273 | 3654 | '@alloc/quick-lru@5.3.0': {} |
| 3274 | 3655 | |
| 3656 | + '@anthropic-ai/sdk@0.124.0(zod@4.5.4)': | |
| 3657 | + dependencies: | |
| 3658 | + json-schema-to-ts: 3.1.1 | |
| 3659 | + standardwebhooks: 1.1.1 | |
| 3660 | + optionalDependencies: | |
| 3661 | + zod: 4.5.4 | |
| 3662 | + | |
| 3275 | 3663 | '@babel/code-frame@7.29.7': |
| 3276 | 3664 | dependencies: |
| 3277 | 3665 | '@babel/helper-validator-identifier': 7.29.7 |
@@ -3349,6 +3737,8 @@ snapshots: | ||
| 3349 | 3737 | dependencies: |
| 3350 | 3738 | '@babel/types': 7.29.8 |
| 3351 | 3739 | |
| 3740 | + '@babel/runtime@7.29.7': {} | |
| 3741 | + | |
| 3352 | 3742 | '@babel/template@7.29.7': |
| 3353 | 3743 | dependencies: |
| 3354 | 3744 | '@babel/code-frame': 7.29.7 |
@@ -3678,6 +4068,55 @@ snapshots: | ||
| 3678 | 4068 | '@eslint/core': 0.17.0 |
| 3679 | 4069 | levn: 0.4.1 |
| 3680 | 4070 | |
| 4071 | + '@fastify/accept-negotiator@2.1.0': {} | |
| 4072 | + | |
| 4073 | + '@fastify/ajv-compiler@4.0.6': | |
| 4074 | + dependencies: | |
| 4075 | + ajv: 8.20.0 | |
| 4076 | + ajv-formats: 3.0.1(ajv@8.20.0) | |
| 4077 | + fast-uri: 4.1.4 | |
| 4078 | + | |
| 4079 | + '@fastify/compress@9.2.0': | |
| 4080 | + dependencies: | |
| 4081 | + '@fastify/accept-negotiator': 2.1.0 | |
| 4082 | + fastify-plugin: 6.0.0 | |
| 4083 | + mime-db: 1.54.0 | |
| 4084 | + minipass: 7.1.3 | |
| 4085 | + readable-stream: 4.7.0 | |
| 4086 | + | |
| 4087 | + '@fastify/cors@11.3.0': | |
| 4088 | + dependencies: | |
| 4089 | + fastify-plugin: 6.0.0 | |
| 4090 | + toad-cache: 3.7.4 | |
| 4091 | + | |
| 4092 | + '@fastify/error@4.2.0': {} | |
| 4093 | + | |
| 4094 | + '@fastify/etag@6.2.0': | |
| 4095 | + dependencies: | |
| 4096 | + fastify-plugin: 6.0.0 | |
| 4097 | + | |
| 4098 | + '@fastify/fast-json-stringify-compiler@5.1.0': | |
| 4099 | + dependencies: | |
| 4100 | + fast-json-stringify: 7.0.1 | |
| 4101 | + | |
| 4102 | + '@fastify/forwarded@3.0.2': {} | |
| 4103 | + | |
| 4104 | + '@fastify/merge-json-schemas@0.2.1': | |
| 4105 | + dependencies: | |
| 4106 | + dequal: 2.0.3 | |
| 4107 | + | |
| 4108 | + '@fastify/proxy-addr@5.1.0': | |
| 4109 | + dependencies: | |
| 4110 | + '@fastify/forwarded': 3.0.2 | |
| 4111 | + ipaddr.js: 2.5.0 | |
| 4112 | + | |
| 4113 | + '@fastify/rate-limit@11.2.0': | |
| 4114 | + dependencies: | |
| 4115 | + '@lukeed/ms': 2.0.2 | |
| 4116 | + fastify-plugin: 6.0.0 | |
| 4117 | + ip-address: 10.7.0 | |
| 4118 | + toad-cache: 3.7.4 | |
| 4119 | + | |
| 3681 | 4120 | '@humanfs/core@0.19.2': |
| 3682 | 4121 | dependencies: |
| 3683 | 4122 | '@humanfs/types': 0.15.0 |
@@ -3820,6 +4259,8 @@ snapshots: | ||
| 3820 | 4259 | '@jridgewell/resolve-uri': 3.1.2 |
| 3821 | 4260 | '@jridgewell/sourcemap-codec': 1.6.0 |
| 3822 | 4261 | |
| 4262 | + '@lukeed/ms@2.0.2': {} | |
| 4263 | + | |
| 3823 | 4264 | '@napi-rs/lzma-linux-x64-gnu@1.5.1': |
| 3824 | 4265 | optional: true |
| 3825 | 4266 | |
@@ -3956,6 +4397,8 @@ snapshots: | ||
| 3956 | 4397 | |
| 3957 | 4398 | '@rtsao/scc@1.1.0': {} |
| 3958 | 4399 | |
| 4400 | + '@stablelib/base64@1.0.1': {} | |
| 4401 | + | |
| 3959 | 4402 | '@swc/helpers@0.5.23': |
| 3960 | 4403 | dependencies: |
| 3961 | 4404 | tslib: 2.8.1 |
@@ -4269,12 +4712,22 @@ snapshots: | ||
| 4269 | 4712 | loupe: 3.2.1 |
| 4270 | 4713 | tinyrainbow: 2.0.0 |
| 4271 | 4714 | |
| 4715 | + abort-controller@3.0.0: | |
| 4716 | + dependencies: | |
| 4717 | + event-target-shim: 5.0.1 | |
| 4718 | + | |
| 4719 | + abstract-logging@2.0.1: {} | |
| 4720 | + | |
| 4272 | 4721 | acorn-jsx@5.3.2(acorn@8.18.0): |
| 4273 | 4722 | dependencies: |
| 4274 | 4723 | acorn: 8.18.0 |
| 4275 | 4724 | |
| 4276 | 4725 | acorn@8.18.0: {} |
| 4277 | 4726 | |
| 4727 | + ajv-formats@3.0.1(ajv@8.20.0): | |
| 4728 | + optionalDependencies: | |
| 4729 | + ajv: 8.20.0 | |
| 4730 | + | |
| 4278 | 4731 | ajv@6.15.0: |
| 4279 | 4732 | dependencies: |
| 4280 | 4733 | fast-deep-equal: 3.1.3 |
@@ -4282,6 +4735,13 @@ snapshots: | ||
| 4282 | 4735 | json-schema-traverse: 0.4.1 |
| 4283 | 4736 | uri-js: 4.4.1 |
| 4284 | 4737 | |
| 4738 | + ajv@8.20.0: | |
| 4739 | + dependencies: | |
| 4740 | + fast-deep-equal: 3.1.3 | |
| 4741 | + fast-uri: 3.1.7 | |
| 4742 | + json-schema-traverse: 1.0.0 | |
| 4743 | + require-from-string: 2.0.2 | |
| 4744 | + | |
| 4285 | 4745 | ansi-styles@4.3.0: |
| 4286 | 4746 | dependencies: |
| 4287 | 4747 | color-convert: 2.0.1 |
@@ -4369,6 +4829,11 @@ snapshots: | ||
| 4369 | 4829 | dependencies: |
| 4370 | 4830 | possible-typed-array-names: 1.1.0 |
| 4371 | 4831 | |
| 4832 | + avvio@9.3.0: | |
| 4833 | + dependencies: | |
| 4834 | + '@fastify/error': 4.2.0 | |
| 4835 | + fastq: 1.20.3 | |
| 4836 | + | |
| 4372 | 4837 | axe-core@4.13.0: {} |
| 4373 | 4838 | |
| 4374 | 4839 | axobject-query@4.1.0: {} |
@@ -4377,6 +4842,8 @@ snapshots: | ||
| 4377 | 4842 | |
| 4378 | 4843 | balanced-match@4.0.4: {} |
| 4379 | 4844 | |
| 4845 | + base64-js@1.5.1: {} | |
| 4846 | + | |
| 4380 | 4847 | baseline-browser-mapping@2.11.21: {} |
| 4381 | 4848 | |
| 4382 | 4849 | boolbase@1.0.0: {} |
@@ -4404,6 +4871,11 @@ snapshots: | ||
| 4404 | 4871 | |
| 4405 | 4872 | buffer-from@1.1.2: {} |
| 4406 | 4873 | |
| 4874 | + buffer@6.0.3: | |
| 4875 | + dependencies: | |
| 4876 | + base64-js: 1.5.1 | |
| 4877 | + ieee754: 1.2.1 | |
| 4878 | + | |
| 4407 | 4879 | cac@6.7.14: {} |
| 4408 | 4880 | |
| 4409 | 4881 | call-bind-apply-helpers@1.0.2: |
@@ -4477,6 +4949,12 @@ snapshots: | ||
| 4477 | 4949 | |
| 4478 | 4950 | convert-source-map@2.0.0: {} |
| 4479 | 4951 | |
| 4952 | + cookie@1.1.1: {} | |
| 4953 | + | |
| 4954 | + cron-parser@5.10.0: | |
| 4955 | + dependencies: | |
| 4956 | + luxon: 3.7.2 | |
| 4957 | + | |
| 4480 | 4958 | cross-spawn@7.0.6: |
| 4481 | 4959 | dependencies: |
| 4482 | 4960 | path-key: 3.1.1 |
@@ -4539,6 +5017,8 @@ snapshots: | ||
| 4539 | 5017 | has-property-descriptors: 1.0.2 |
| 4540 | 5018 | object-keys: 1.1.1 |
| 4541 | 5019 | |
| 5020 | + dequal@2.0.3: {} | |
| 5021 | + | |
| 4542 | 5022 | detect-libc@2.1.2: {} |
| 4543 | 5023 | |
| 4544 | 5024 | doctrine@2.1.0: |
@@ -4570,8 +5050,9 @@ snapshots: | ||
| 4570 | 5050 | esbuild: 0.25.12 |
| 4571 | 5051 | tsx: 4.23.13 |
| 4572 | 5052 | |
| 4573 | − drizzle-orm@0.45.2(postgres@3.4.9): | |
| 5053 | + drizzle-orm@0.45.2(pg@8.23.0)(postgres@3.4.9): | |
| 4574 | 5054 | optionalDependencies: |
| 5055 | + pg: 8.23.0 | |
| 4575 | 5056 | postgres: 3.4.9 |
| 4576 | 5057 | |
| 4577 | 5058 | dunder-proto@1.0.1: |
@@ -5009,8 +5490,14 @@ snapshots: | ||
| 5009 | 5490 | |
| 5010 | 5491 | esutils@2.0.3: {} |
| 5011 | 5492 | |
| 5493 | + event-target-shim@5.0.1: {} | |
| 5494 | + | |
| 5495 | + events@3.3.0: {} | |
| 5496 | + | |
| 5012 | 5497 | expect-type@1.4.0: {} |
| 5013 | 5498 | |
| 5499 | + fast-decode-uri-component@1.0.1: {} | |
| 5500 | + | |
| 5014 | 5501 | fast-deep-equal@3.1.3: {} |
| 5015 | 5502 | |
| 5016 | 5503 | fast-glob@3.3.1: |
@@ -5023,8 +5510,47 @@ snapshots: | ||
| 5023 | 5510 | |
| 5024 | 5511 | fast-json-stable-stringify@2.1.0: {} |
| 5025 | 5512 | |
| 5513 | + fast-json-stringify@7.0.1: | |
| 5514 | + dependencies: | |
| 5515 | + '@fastify/merge-json-schemas': 0.2.1 | |
| 5516 | + ajv: 8.20.0 | |
| 5517 | + ajv-formats: 3.0.1(ajv@8.20.0) | |
| 5518 | + fast-uri: 4.1.4 | |
| 5519 | + json-schema-ref-resolver: 3.0.0 | |
| 5520 | + rfdc: 1.4.1 | |
| 5521 | + | |
| 5026 | 5522 | fast-levenshtein@2.0.6: {} |
| 5027 | 5523 | |
| 5524 | + fast-querystring@1.1.2: | |
| 5525 | + dependencies: | |
| 5526 | + fast-decode-uri-component: 1.0.1 | |
| 5527 | + | |
| 5528 | + fast-sha256@1.3.0: {} | |
| 5529 | + | |
| 5530 | + fast-uri@3.1.7: {} | |
| 5531 | + | |
| 5532 | + fast-uri@4.1.4: {} | |
| 5533 | + | |
| 5534 | + fastify-plugin@6.0.0: {} | |
| 5535 | + | |
| 5536 | + fastify@5.12.3: | |
| 5537 | + dependencies: | |
| 5538 | + '@fastify/ajv-compiler': 4.0.6 | |
| 5539 | + '@fastify/error': 4.2.0 | |
| 5540 | + '@fastify/fast-json-stringify-compiler': 5.1.0 | |
| 5541 | + '@fastify/proxy-addr': 5.1.0 | |
| 5542 | + abstract-logging: 2.0.1 | |
| 5543 | + avvio: 9.3.0 | |
| 5544 | + fast-json-stringify: 7.0.1 | |
| 5545 | + find-my-way: 9.9.0 | |
| 5546 | + light-my-request: 6.6.0 | |
| 5547 | + pino: 9.14.0 | |
| 5548 | + process-warning: 5.1.0 | |
| 5549 | + rfdc: 1.4.1 | |
| 5550 | + secure-json-parse: 4.1.0 | |
| 5551 | + semver: 7.8.5 | |
| 5552 | + toad-cache: 3.7.4 | |
| 5553 | + | |
| 5028 | 5554 | fastq@1.20.3: |
| 5029 | 5555 | dependencies: |
| 5030 | 5556 | reusify: 1.1.0 |
@@ -5041,6 +5567,12 @@ snapshots: | ||
| 5041 | 5567 | dependencies: |
| 5042 | 5568 | to-regex-range: 5.0.1 |
| 5043 | 5569 | |
| 5570 | + find-my-way@9.9.0: | |
| 5571 | + dependencies: | |
| 5572 | + fast-deep-equal: 3.1.3 | |
| 5573 | + fast-querystring: 1.1.2 | |
| 5574 | + safe-regex2: 5.1.1 | |
| 5575 | + | |
| 5044 | 5576 | find-up@5.0.0: |
| 5045 | 5577 | dependencies: |
| 5046 | 5578 | locate-path: 6.0.0 |
@@ -5168,6 +5700,8 @@ snapshots: | ||
| 5168 | 5700 | dependencies: |
| 5169 | 5701 | safer-buffer: 2.1.2 |
| 5170 | 5702 | |
| 5703 | + ieee754@1.2.1: {} | |
| 5704 | + | |
| 5171 | 5705 | ignore@5.3.2: {} |
| 5172 | 5706 | |
| 5173 | 5707 | ignore@7.0.8: {} |
@@ -5185,6 +5719,10 @@ snapshots: | ||
| 5185 | 5719 | hasown: 2.0.4 |
| 5186 | 5720 | side-channel: 1.1.1 |
| 5187 | 5721 | |
| 5722 | + ip-address@10.7.0: {} | |
| 5723 | + | |
| 5724 | + ipaddr.js@2.5.0: {} | |
| 5725 | + | |
| 5188 | 5726 | is-array-buffer@3.0.5: |
| 5189 | 5727 | dependencies: |
| 5190 | 5728 | call-bind: 1.0.9 |
@@ -5328,8 +5866,19 @@ snapshots: | ||
| 5328 | 5866 | |
| 5329 | 5867 | json-buffer@3.0.1: {} |
| 5330 | 5868 | |
| 5869 | + json-schema-ref-resolver@3.0.0: | |
| 5870 | + dependencies: | |
| 5871 | + dequal: 2.0.3 | |
| 5872 | + | |
| 5873 | + json-schema-to-ts@3.1.1: | |
| 5874 | + dependencies: | |
| 5875 | + '@babel/runtime': 7.29.7 | |
| 5876 | + ts-algebra: 2.0.0 | |
| 5877 | + | |
| 5331 | 5878 | json-schema-traverse@0.4.1: {} |
| 5332 | 5879 | |
| 5880 | + json-schema-traverse@1.0.0: {} | |
| 5881 | + | |
| 5333 | 5882 | json-stable-stringify-without-jsonify@1.0.1: {} |
| 5334 | 5883 | |
| 5335 | 5884 | json5@1.0.2: |
@@ -5360,6 +5909,12 @@ snapshots: | ||
| 5360 | 5909 | prelude-ls: 1.2.1 |
| 5361 | 5910 | type-check: 0.4.0 |
| 5362 | 5911 | |
| 5912 | + light-my-request@6.6.0: | |
| 5913 | + dependencies: | |
| 5914 | + cookie: 1.1.1 | |
| 5915 | + process-warning: 4.0.1 | |
| 5916 | + set-cookie-parser: 2.7.2 | |
| 5917 | + | |
| 5363 | 5918 | lightningcss-android-arm64@1.32.0: |
| 5364 | 5919 | optional: true |
| 5365 | 5920 | |
@@ -5429,6 +5984,8 @@ snapshots: | ||
| 5429 | 5984 | dependencies: |
| 5430 | 5985 | react: 19.2.8 |
| 5431 | 5986 | |
| 5987 | + luxon@3.7.2: {} | |
| 5988 | + | |
| 5432 | 5989 | magic-string@0.30.21: |
| 5433 | 5990 | dependencies: |
| 5434 | 5991 | '@jridgewell/sourcemap-codec': 1.6.0 |
@@ -5442,6 +5999,8 @@ snapshots: | ||
| 5442 | 5999 | braces: 3.0.3 |
| 5443 | 6000 | picomatch: 2.3.2 |
| 5444 | 6001 | |
| 6002 | + mime-db@1.54.0: {} | |
| 6003 | + | |
| 5445 | 6004 | minimatch@10.2.6: |
| 5446 | 6005 | dependencies: |
| 5447 | 6006 | brace-expansion: 5.0.9 |
@@ -5452,6 +6011,8 @@ snapshots: | ||
| 5452 | 6011 | |
| 5453 | 6012 | minimist@1.2.8: {} |
| 5454 | 6013 | |
| 6014 | + minipass@7.1.3: {} | |
| 6015 | + | |
| 5455 | 6016 | ms@2.1.3: {} |
| 5456 | 6017 | |
| 5457 | 6018 | nanoid@3.3.18: {} |
@@ -5494,6 +6055,8 @@ snapshots: | ||
| 5494 | 6055 | |
| 5495 | 6056 | node-releases@2.0.54: {} |
| 5496 | 6057 | |
| 6058 | + non-error@0.1.0: {} | |
| 6059 | + | |
| 5497 | 6060 | nth-check@2.1.1: |
| 5498 | 6061 | dependencies: |
| 5499 | 6062 | boolbase: 1.0.0 |
@@ -5542,6 +6105,11 @@ snapshots: | ||
| 5542 | 6105 | |
| 5543 | 6106 | on-exit-leak-free@2.1.2: {} |
| 5544 | 6107 | |
| 6108 | + openai@7.10.0(undici@7.29.1)(zod@4.5.4): | |
| 6109 | + optionalDependencies: | |
| 6110 | + undici: 7.29.1 | |
| 6111 | + zod: 4.5.4 | |
| 6112 | + | |
| 5545 | 6113 | optionator@0.9.4: |
| 5546 | 6114 | dependencies: |
| 5547 | 6115 | deep-is: 0.1.4 |
@@ -5593,6 +6161,49 @@ snapshots: | ||
| 5593 | 6161 | |
| 5594 | 6162 | pathval@2.0.1: {} |
| 5595 | 6163 | |
| 6164 | + pg-boss@12.30.0: | |
| 6165 | + dependencies: | |
| 6166 | + cron-parser: 5.10.0 | |
| 6167 | + pg: 8.23.0 | |
| 6168 | + serialize-error: 13.0.1 | |
| 6169 | + transitivePeerDependencies: | |
| 6170 | + - pg-native | |
| 6171 | + | |
| 6172 | + pg-cloudflare@1.4.0: | |
| 6173 | + optional: true | |
| 6174 | + | |
| 6175 | + pg-connection-string@2.14.0: {} | |
| 6176 | + | |
| 6177 | + pg-int8@1.0.1: {} | |
| 6178 | + | |
| 6179 | + pg-pool@3.14.0(pg@8.23.0): | |
| 6180 | + dependencies: | |
| 6181 | + pg: 8.23.0 | |
| 6182 | + | |
| 6183 | + pg-protocol@1.16.0: {} | |
| 6184 | + | |
| 6185 | + pg-types@2.2.0: | |
| 6186 | + dependencies: | |
| 6187 | + pg-int8: 1.0.1 | |
| 6188 | + postgres-array: 2.0.0 | |
| 6189 | + postgres-bytea: 1.0.1 | |
| 6190 | + postgres-date: 1.0.7 | |
| 6191 | + postgres-interval: 1.2.0 | |
| 6192 | + | |
| 6193 | + pg@8.23.0: | |
| 6194 | + dependencies: | |
| 6195 | + pg-connection-string: 2.14.0 | |
| 6196 | + pg-pool: 3.14.0(pg@8.23.0) | |
| 6197 | + pg-protocol: 1.16.0 | |
| 6198 | + pg-types: 2.2.0 | |
| 6199 | + pgpass: 1.0.5 | |
| 6200 | + optionalDependencies: | |
| 6201 | + pg-cloudflare: 1.4.0 | |
| 6202 | + | |
| 6203 | + pgpass@1.0.5: | |
| 6204 | + dependencies: | |
| 6205 | + split2: 4.2.0 | |
| 6206 | + | |
| 5596 | 6207 | picocolors@1.1.1: {} |
| 5597 | 6208 | |
| 5598 | 6209 | picomatch@2.3.2: {} |
@@ -5633,12 +6244,26 @@ snapshots: | ||
| 5633 | 6244 | picocolors: 1.1.1 |
| 5634 | 6245 | source-map-js: 1.2.1 |
| 5635 | 6246 | |
| 6247 | + postgres-array@2.0.0: {} | |
| 6248 | + | |
| 6249 | + postgres-bytea@1.0.1: {} | |
| 6250 | + | |
| 6251 | + postgres-date@1.0.7: {} | |
| 6252 | + | |
| 6253 | + postgres-interval@1.2.0: | |
| 6254 | + dependencies: | |
| 6255 | + xtend: 4.0.2 | |
| 6256 | + | |
| 5636 | 6257 | postgres@3.4.9: {} |
| 5637 | 6258 | |
| 5638 | 6259 | prelude-ls@1.2.1: {} |
| 5639 | 6260 | |
| 6261 | + process-warning@4.0.1: {} | |
| 6262 | + | |
| 5640 | 6263 | process-warning@5.1.0: {} |
| 5641 | 6264 | |
| 6265 | + process@0.11.10: {} | |
| 6266 | + | |
| 5642 | 6267 | prop-types@15.8.1: |
| 5643 | 6268 | dependencies: |
| 5644 | 6269 | loose-envify: 1.4.0 |
@@ -5660,6 +6285,14 @@ snapshots: | ||
| 5660 | 6285 | |
| 5661 | 6286 | react@19.2.8: {} |
| 5662 | 6287 | |
| 6288 | + readable-stream@4.7.0: | |
| 6289 | + dependencies: | |
| 6290 | + abort-controller: 3.0.0 | |
| 6291 | + buffer: 6.0.3 | |
| 6292 | + events: 3.3.0 | |
| 6293 | + process: 0.11.10 | |
| 6294 | + string_decoder: 1.3.0 | |
| 6295 | + | |
| 5663 | 6296 | real-require@0.2.0: {} |
| 5664 | 6297 | |
| 5665 | 6298 | reflect.getprototypeof@1.0.10: |
@@ -5682,6 +6315,8 @@ snapshots: | ||
| 5682 | 6315 | gopd: 1.2.0 |
| 5683 | 6316 | set-function-name: 2.0.2 |
| 5684 | 6317 | |
| 6318 | + require-from-string@2.0.2: {} | |
| 6319 | + | |
| 5685 | 6320 | resolve-from@4.0.0: {} |
| 5686 | 6321 | |
| 5687 | 6322 | resolve-pkg-maps@1.0.0: {} |
@@ -5695,8 +6330,12 @@ snapshots: | ||
| 5695 | 6330 | path-parse: 1.0.7 |
| 5696 | 6331 | supports-preserve-symlinks-flag: 1.0.0 |
| 5697 | 6332 | |
| 6333 | + ret@0.5.0: {} | |
| 6334 | + | |
| 5698 | 6335 | reusify@1.1.0: {} |
| 5699 | 6336 | |
| 6337 | + rfdc@1.4.1: {} | |
| 6338 | + | |
| 5700 | 6339 | rollup@4.63.1: |
| 5701 | 6340 | dependencies: |
| 5702 | 6341 | '@types/estree': 1.0.9 |
@@ -5741,6 +6380,8 @@ snapshots: | ||
| 5741 | 6380 | has-symbols: 1.1.0 |
| 5742 | 6381 | isarray: 2.0.5 |
| 5743 | 6382 | |
| 6383 | + safe-buffer@5.2.1: {} | |
| 6384 | + | |
| 5744 | 6385 | safe-push-apply@1.0.0: |
| 5745 | 6386 | dependencies: |
| 5746 | 6387 | es-errors: 1.3.0 |
@@ -5752,18 +6393,31 @@ snapshots: | ||
| 5752 | 6393 | es-errors: 1.3.0 |
| 5753 | 6394 | is-regex: 1.2.1 |
| 5754 | 6395 | |
| 6396 | + safe-regex2@5.1.1: | |
| 6397 | + dependencies: | |
| 6398 | + ret: 0.5.0 | |
| 6399 | + | |
| 5755 | 6400 | safe-stable-stringify@2.5.0: {} |
| 5756 | 6401 | |
| 5757 | 6402 | safer-buffer@2.1.2: {} |
| 5758 | 6403 | |
| 5759 | 6404 | scheduler@0.27.0: {} |
| 5760 | 6405 | |
| 6406 | + secure-json-parse@4.1.0: {} | |
| 6407 | + | |
| 5761 | 6408 | semver@6.3.1: {} |
| 5762 | 6409 | |
| 5763 | 6410 | semver@7.8.5: {} |
| 5764 | 6411 | |
| 6412 | + serialize-error@13.0.1: | |
| 6413 | + dependencies: | |
| 6414 | + non-error: 0.1.0 | |
| 6415 | + type-fest: 5.9.0 | |
| 6416 | + | |
| 5765 | 6417 | server-only@0.0.1: {} |
| 5766 | 6418 | |
| 6419 | + set-cookie-parser@2.7.2: {} | |
| 6420 | + | |
| 5767 | 6421 | set-function-length@1.2.2: |
| 5768 | 6422 | dependencies: |
| 5769 | 6423 | define-data-property: 1.1.4 |
@@ -5875,6 +6529,11 @@ snapshots: | ||
| 5875 | 6529 | |
| 5876 | 6530 | stackback@0.0.2: {} |
| 5877 | 6531 | |
| 6532 | + standardwebhooks@1.1.1: | |
| 6533 | + dependencies: | |
| 6534 | + '@stablelib/base64': 1.0.1 | |
| 6535 | + fast-sha256: 1.3.0 | |
| 6536 | + | |
| 5878 | 6537 | std-env@3.10.0: {} |
| 5879 | 6538 | |
| 5880 | 6539 | stop-iteration-iterator@1.1.0: |
@@ -5933,6 +6592,10 @@ snapshots: | ||
| 5933 | 6592 | define-properties: 1.2.1 |
| 5934 | 6593 | es-object-atoms: 1.1.2 |
| 5935 | 6594 | |
| 6595 | + string_decoder@1.3.0: | |
| 6596 | + dependencies: | |
| 6597 | + safe-buffer: 5.2.1 | |
| 6598 | + | |
| 5936 | 6599 | strip-bom@3.0.0: {} |
| 5937 | 6600 | |
| 5938 | 6601 | strip-json-comments@3.1.1: {} |
@@ -5954,6 +6617,8 @@ snapshots: | ||
| 5954 | 6617 | |
| 5955 | 6618 | supports-preserve-symlinks-flag@1.0.0: {} |
| 5956 | 6619 | |
| 6620 | + tagged-tag@1.0.0: {} | |
| 6621 | + | |
| 5957 | 6622 | tailwindcss@4.3.3: {} |
| 5958 | 6623 | |
| 5959 | 6624 | tapable@2.3.3: {} |
@@ -5981,6 +6646,10 @@ snapshots: | ||
| 5981 | 6646 | dependencies: |
| 5982 | 6647 | is-number: 7.0.0 |
| 5983 | 6648 | |
| 6649 | + toad-cache@3.7.4: {} | |
| 6650 | + | |
| 6651 | + ts-algebra@2.0.0: {} | |
| 6652 | + | |
| 5984 | 6653 | ts-api-utils@2.5.0(typescript@5.9.3): |
| 5985 | 6654 | dependencies: |
| 5986 | 6655 | typescript: 5.9.3 |
@@ -6004,6 +6673,10 @@ snapshots: | ||
| 6004 | 6673 | dependencies: |
| 6005 | 6674 | prelude-ls: 1.2.1 |
| 6006 | 6675 | |
| 6676 | + type-fest@5.9.0: | |
| 6677 | + dependencies: | |
| 6678 | + tagged-tag: 1.0.0 | |
| 6679 | + | |
| 6007 | 6680 | typed-array-buffer@1.0.3: |
| 6008 | 6681 | dependencies: |
| 6009 | 6682 | call-bound: 1.0.4 |
@@ -6233,6 +6906,8 @@ snapshots: | ||
| 6233 | 6906 | |
| 6234 | 6907 | word-wrap@1.2.5: {} |
| 6235 | 6908 | |
| 6909 | + xtend@4.0.2: {} | |
| 6910 | + | |
| 6236 | 6911 | yallist@3.1.1: {} |
| 6237 | 6912 | |
| 6238 | 6913 | yocto-queue@0.1.0: {} |
added
scripts/admin-cookie.ts
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +/** Print the admin cookie value for scripted access: scripts/with-env.sh pnpm tsx scripts/admin-cookie.ts */ | |
| 2 | +import { createHmac } from 'node:crypto'; | |
| 3 | +const token = process.env.ADMIN_TOKEN; | |
| 4 | +if (!token) throw new Error('ADMIN_TOKEN not set'); | |
| 5 | +console.log(createHmac('sha256', process.env.SESSION_SECRET ?? 'dev-only').update(`admin:${token}`).digest('hex')); | |
added
scripts/ai-smoke.ts
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +/** Manual smoke test for packages/ai (real API calls, a few cents). Run: pnpm tsx scripts/ai-smoke.ts */ | |
| 2 | +import { getRouter, classifyCategory, verifyEntityMatch, recentCosts } from '../packages/ai/src/index.ts'; | |
| 3 | + | |
| 4 | +const r = getRouter(); | |
| 5 | +console.log(r.table()); | |
| 6 | +const c = await classifyCategory('1999 Pokemon Base Set Charizard 1st Edition Holo PSA 10 #4/102'); | |
| 7 | +console.log('classify', c.data, c.usdEst, c.model); | |
| 8 | +const v = await verifyEntityMatch({ title: '1999 Pokemon Base Charizard 1st PSA 10' }, { title: 'Pokémon 1999 First Edition Charizard Holo PSA GEM MT 10' }); | |
| 9 | +console.log('verify', v.data.sameAsset, v.data.sameVariant, v.data.confidence, v.usdEst); | |
| 10 | +console.log(recentCosts().map((e) => [e.model, e.usdEst])); | |
| 11 | +process.exit(0); | |
added
scripts/internal-token.ts
+3 −0
@@ -0,0 +1,3 @@ | ||
| 1 | +/** Print the internal token shared between web and api: scripts/with-env.sh pnpm tsx scripts/internal-token.ts */ | |
| 2 | +import { createHmac } from 'node:crypto'; | |
| 3 | +console.log(createHmac('sha256', process.env.SESSION_SECRET ?? 'dev-only').update('internal').digest('hex')); | |
added
scripts/scan-smoke.py
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Manual smoke test of the scanner endpoint. Usage: scan-smoke.py <text|photo|url> [value] [port]""" | |
| 3 | +import base64, json, sys, urllib.request | |
| 4 | + | |
| 5 | +mode = sys.argv[1] | |
| 6 | +value = sys.argv[2] if len(sys.argv) > 2 else '' | |
| 7 | +port = sys.argv[3] if len(sys.argv) > 3 else '3002' | |
| 8 | +if mode == 'photo': | |
| 9 | + body = {"mode": "photo", "images": [{"data": base64.b64encode(open(value, 'rb').read()).decode(), "mediaType": "image/jpeg"}]} | |
| 10 | +elif mode == 'url': | |
| 11 | + body = {"mode": "url", "url": value} | |
| 12 | +else: | |
| 13 | + body = {"mode": "text", "text": value} | |
| 14 | +req = urllib.request.Request(f'http://127.0.0.1:{port}/api/scanner', data=json.dumps(body).encode(), headers={'content-type': 'application/json'}) | |
| 15 | +try: | |
| 16 | + r = json.load(urllib.request.urlopen(req, timeout=180)) | |
| 17 | +except urllib.error.HTTPError as e: | |
| 18 | + print('HTTP', e.code, e.read().decode()[:500]); sys.exit(1) | |
| 19 | +print(json.dumps({k: r.get(k) for k in ('sessionId', 'mode', 'guessConfidence', 'model', 'usdEst', 'durationMs', 'notes')}, indent=1)) | |
| 20 | +g = r.get('guess') or {} | |
| 21 | +print('guess', {k: g.get(k) for k in ('categorySlug', 'name', 'brand', 'set', 'number', 'year', 'variant', 'grader', 'grade', 'likelyGradeRange')}) | |
| 22 | +print('warnings', g.get('warnings')) | |
| 23 | +print('candidates', [(c['title'], round(c['score'], 2)) for c in r.get('candidates', [])]) | |
| 24 | +print('best', (r.get('best') or {}).get('title')) | |
| 25 | +if r.get('listing'): | |
| 26 | + l = r['listing'] | |
| 27 | + print('listing', l['sourceId'], l['rawTitle'], l['price'], l['currency'], l['verdict'], l['discountToRiv']) | |
added
scripts/with-env.sh
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# Run a command with the repo .env loaded: scripts/with-env.sh pnpm tsx scripts/ai-smoke.ts | |
| 3 | +set -a | |
| 4 | +. "$(dirname "$0")/../.env" | |
| 5 | +set +a | |
| 6 | +exec "$@" | |
| 7 | ||