import 'server-only'; import { getDb, researchSessions, researchMessages, eq, desc, and, sql } from '@rareindex/database'; import { newId } from '@rareindex/shared'; import { getRouter, type ChatMessage, type StreamDelta } from '@rareindex/ai'; import { executeTool, summarizeToolResult, toolDefinitions } from './research-tools'; 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…). Rules: 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). 2. Start by calling the right tools; chain them (search_assets → get_asset → get_asset_sales…). Prefer one well-parameterised call over many. 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. 4. Cite assets as markdown links: [Title](/asset/slug); categories as [Name](/markets/slug); indices as [TICKER](/rareindex/TICKER). 5. Use compact markdown tables for comparisons (≤ 8 rows). Keep prose short and analytical. No investment advice; describe data, not recommendations. 6. Windows: 7d/30d/90d/1y. If the user asks for a horizon RareIndex cannot support, say so. 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).`; export const SUGGESTED_PROMPTS = [ 'Find Pokémon cards under $5,000 that have increased more than 30% in the last year.', 'Compare RARE-WATCH versus RARE-TCG over the last 90 days.', 'Find sealed Nintendo games with fewer than 20 public sales in the last year.', 'Which collectible category has the strongest momentum this month?', 'What are the highest verified sales tracked by RareIndex?', 'How much data does RareIndex currently cover?', ]; export interface ResearchTurnInput { sessionId: string | null; anonId: string | null; userId: string | null; message: string; signal?: AbortSignal; } export type ResearchEvent = | { type: 'session'; sessionId: string } | { type: 'text'; text: string } | { type: 'thinking'; text: string } | { type: 'tool_call'; id: string; name: string; input: unknown } | { type: 'tool_result'; id: string; name: string; ok: boolean; ms: number; summary: string; preview: unknown } | { type: 'done'; usdEst: number; model: string | null } | { type: 'error'; message: string }; const MAX_HISTORY = 16; export async function loadSession(sessionId: string, owner: { anonId: string | null; userId: string | null }) { const db = getDb(); const [s] = await db.select().from(researchSessions).where(eq(researchSessions.id, sessionId)).limit(1); if (!s) return null; if (s.userId && s.userId !== owner.userId) return null; // anonymous threads are readable only by the browser that created them; a thread without an // anon id (cookie could not be set) is not readable by anyone afterwards if (!s.userId && (!owner.anonId || s.anonId !== owner.anonId)) return null; const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt); return { session: s, messages }; } export async function listSessions(owner: { anonId: string | null; userId: string | null }, limit = 20) { const db = getDb(); const where = owner.userId ? eq(researchSessions.userId, owner.userId) : owner.anonId ? and(eq(researchSessions.anonId, owner.anonId), sql`${researchSessions.userId} is null`) : null; if (!where) return []; return db.select().from(researchSessions).where(and(where, eq(researchSessions.archived, false))).orderBy(desc(researchSessions.updatedAt)).limit(limit); } /** Run one research turn: persists the user message, streams the agent, persists the answer + tool trace. */ export async function* researchTurn(input: ResearchTurnInput): AsyncGenerator { const db = getDb(); const router = getRouter(); let sessionId = input.sessionId; let history: ChatMessage[] = []; if (sessionId) { const loaded = await loadSession(sessionId, { anonId: input.anonId, userId: input.userId }); if (!loaded) sessionId = null; else history = loaded.messages.slice(-MAX_HISTORY).map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })); } if (!sessionId) { sessionId = newId('event').replace('evt_', 'rs_'); 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 }); } yield { type: 'session', sessionId }; await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'user', content: input.message }); let answer = ''; let usdEst = 0; let model: string | null = null; const trace: Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }> = []; const pending = new Map(); const usage: Record = {}; try { const stream = router.runTools('research', { system: RESEARCH_SYSTEM, messages: [...history, { role: 'user', content: input.message }], tools: toolDefinitions(), execute: executeTool, maxIterations: 10, maxTokens: 6000, effort: 'medium', signal: input.signal, cost: { endpoint: 'research', userId: input.userId, metadata: { sessionId } }, }); for await (const d of stream as AsyncIterable) { if (d.type === 'text') { answer += d.text; yield d; } else if (d.type === 'thinking') { yield d; } else if (d.type === 'tool_call') { pending.set(d.id, { name: d.name, input: d.input }); yield d; } else if (d.type === 'tool_result') { const summary = d.isError ? `error: ${JSON.stringify(d.output).slice(0, 120)}` : summarizeToolResult(d.name, d.output); trace.push({ name: d.name, input: pending.get(d.id)?.input, ok: !d.isError, ms: d.durationMs, summary }); yield { type: 'tool_result', id: d.id, name: d.name, ok: !d.isError, ms: d.durationMs, summary, preview: preview(d.output) }; } else if (d.type === 'usage') { usdEst += d.usdEst; model = d.model; for (const [k, v] of Object.entries(d.usage)) usage[k] = (usage[k] ?? 0) + v; } } } catch (err) { const e = err as { code?: string; message?: string }; 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'); yield { type: 'error', message }; answer = answer || `_${message}_`; } await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'assistant', content: answer, toolCalls: trace, usage, usdEst, model }); await db.update(researchSessions).set({ messageCount: sql`${researchSessions.messageCount} + 2`, usdEst: sql`${researchSessions.usdEst} + ${usdEst}`, updatedAt: new Date(), model }).where(eq(researchSessions.id, sessionId)); yield { type: 'done', usdEst, model }; } function preview(output: unknown): unknown { if (!output || typeof output !== 'object') return output; const o = output as Record; const arrKey = ['results', 'sales', 'listings', 'items', 'points', 'categories', 'findings', 'variants', 'gainers'].find((k) => Array.isArray(o[k])); if (arrKey) return { [arrKey]: (o[arrKey] as unknown[]).slice(0, 5), truncated: (o[arrKey] as unknown[]).length > 5 }; return Object.fromEntries(Object.entries(o).slice(0, 12)); }