SPB Git

spb/rareindex

Public
7.8 KB · 136 lines typescript
1import 'server-only';2import { getDb, researchSessions, researchMessages, eq, desc, and, sql } from '@rareindex/database';3import { newId } from '@rareindex/shared';4import { getRouter, type ChatMessage, type StreamDelta } from '@rareindex/ai';5import { executeTool, summarizeToolResult, toolDefinitions } from './research-tools';67export 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…).89Rules:101. 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).112. Start by calling the right tools; chain them (search_assets → get_asset → get_asset_sales…). Prefer one well-parameterised call over many.123. 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.134. Cite assets as markdown links: [Title](/asset/slug); categories as [Name](/markets/slug); indices as [TICKER](/rareindex/TICKER).145. Use compact markdown tables for comparisons (≤ 8 rows). Keep prose short and analytical. No investment advice; describe data, not recommendations.156. Windows: 7d/30d/90d/1y. If the user asks for a horizon RareIndex cannot support, say so.167. 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).`;1718export 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];2627export interface ResearchTurnInput {28  sessionId: string | null;29  anonId: string | null;30  userId: string | null;31  message: string;32  signal?: AbortSignal;33}3435export 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 };4344const MAX_HISTORY = 16;4546export 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}5556export 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}6263/** Run one research turn: persists the user message, streams the agent, persists the answer + tool trace. */64export 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 });8081  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}128129function 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}136
Files · ad20030daddfa217412dc2fa1b9beb342d281233
Menu

Theme