SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
8.0 KB · 138 lines typescript
Raw Blame History
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  // anonymous threads are readable only by the browser that created them; a thread without an52  // anon id (cookie could not be set) is not readable by anyone afterwards53  if (!s.userId && (!owner.anonId || s.anonId !== owner.anonId)) return null;54  const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt);55  return { session: s, messages };56}5758export async function listSessions(owner: { anonId: string | null; userId: string | null }, limit = 20) {59  const db = getDb();60  const where = owner.userId ? eq(researchSessions.userId, owner.userId) : owner.anonId ? and(eq(researchSessions.anonId, owner.anonId), sql`${researchSessions.userId} is null`) : null;61  if (!where) return [];62  return db.select().from(researchSessions).where(and(where, eq(researchSessions.archived, false))).orderBy(desc(researchSessions.updatedAt)).limit(limit);63}6465/** Run one research turn: persists the user message, streams the agent, persists the answer + tool trace. */66export async function* researchTurn(input: ResearchTurnInput): AsyncGenerator<ResearchEvent> {67  const db = getDb();68  const router = getRouter();69  let sessionId = input.sessionId;70  let history: ChatMessage[] = [];71  if (sessionId) {72    const loaded = await loadSession(sessionId, { anonId: input.anonId, userId: input.userId });73    if (!loaded) sessionId = null;74    else history = loaded.messages.slice(-MAX_HISTORY).map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));75  }76  if (!sessionId) {77    sessionId = newId('event').replace('evt_', 'rs_');78    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 });79  }80  yield { type: 'session', sessionId };81  await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'user', content: input.message });8283  let answer = '';84  let usdEst = 0;85  let model: string | null = null;86  const trace: Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }> = [];87  const pending = new Map<string, { name: string; input: unknown }>();88  const usage: Record<string, number> = {};89  try {90    const stream = router.runTools('research', {91      system: RESEARCH_SYSTEM,92      messages: [...history, { role: 'user', content: input.message }],93      tools: toolDefinitions(),94      execute: executeTool,95      maxIterations: 10,96      maxTokens: 6000,97      effort: 'medium',98      signal: input.signal,99      cost: { endpoint: 'research', userId: input.userId, metadata: { sessionId } },100    });101    for await (const d of stream as AsyncIterable<StreamDelta>) {102      if (d.type === 'text') {103        answer += d.text;104        yield d;105      } else if (d.type === 'thinking') {106        yield d;107      } else if (d.type === 'tool_call') {108        pending.set(d.id, { name: d.name, input: d.input });109        yield d;110      } else if (d.type === 'tool_result') {111        const summary = d.isError ? `error: ${JSON.stringify(d.output).slice(0, 120)}` : summarizeToolResult(d.name, d.output);112        trace.push({ name: d.name, input: pending.get(d.id)?.input, ok: !d.isError, ms: d.durationMs, summary });113        yield { type: 'tool_result', id: d.id, name: d.name, ok: !d.isError, ms: d.durationMs, summary, preview: preview(d.output) };114      } else if (d.type === 'usage') {115        usdEst += d.usdEst;116        model = d.model;117        for (const [k, v] of Object.entries(d.usage)) usage[k] = (usage[k] ?? 0) + v;118      }119    }120  } catch (err) {121    const e = err as { code?: string; message?: string };122    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');123    yield { type: 'error', message };124    answer = answer || `_${message}_`;125  }126  await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'assistant', content: answer, toolCalls: trace, usage, usdEst, model });127  await db.update(researchSessions).set({ messageCount: sql`${researchSessions.messageCount} + 2`, usdEst: sql`${researchSessions.usdEst} + ${usdEst}`, updatedAt: new Date(), model }).where(eq(researchSessions.id, sessionId));128  yield { type: 'done', usdEst, model };129}130131function preview(output: unknown): unknown {132  if (!output || typeof output !== 'object') return output;133  const o = output as Record<string, unknown>;134  const arrKey = ['results', 'sales', 'listings', 'items', 'points', 'categories', 'findings', 'variants', 'gainers'].find((k) => Array.isArray(o[k]));135  if (arrKey) return { [arrKey]: (o[arrKey] as unknown[]).slice(0, 5), truncated: (o[arrKey] as unknown[]).length > 5 };136  return Object.fromEntries(Object.entries(o).slice(0, 12));137}138