import type { ResearchCopilotCitation, ResearchCopilotTurnResponse, ResearchMemo, ResearchMemoSection, SearchResult, SearchSource } from '@/lib/types'; import { runAiAnalysis } from '@/lib/server/ai'; import { extractJsonObject, parseCitationIndexes, parseCopilotResponse } from '@/lib/server/research-copilot-format'; import { appendResearchCopilotMessage, getOrCreateResearchCopilotSession, upsertResearchCopilotSessionState } from '@/lib/server/repos/research-copilot'; import { createAiReportArtifactFromAccession, createFilingArtifactFromAccession, getResearchArtifactsByIdsForUser, getResearchMemoByTicker } from '@/lib/server/repos/research-library'; import { searchKnowledgeBase } from '@/lib/server/search'; type CopilotTurnInput = { userId: string; ticker: string; query: string; selectedSources?: SearchSource[]; pinnedArtifactIds?: number[]; memoSection?: ResearchMemoSection | null; }; const DEFAULT_SELECTED_SOURCES: SearchSource[] = ['documents', 'filings', 'research']; const MAX_HISTORY_MESSAGES = 6; const MAX_CONTEXT_RESULTS = 6; const MAX_CONTEXT_CHARS = 8_000; function normalizeTicker(ticker: string) { return ticker.trim().toUpperCase(); } function normalizeSources(value?: SearchSource[] | null) { const unique = new Set(); for (const source of value ?? DEFAULT_SELECTED_SOURCES) { if (source === 'documents' || source === 'filings' || source === 'research') { unique.add(source); } } return unique.size > 0 ? [...unique] : [...DEFAULT_SELECTED_SOURCES]; } function normalizePinnedArtifactIds(value?: number[] | null) { const unique = new Set(); for (const id of value ?? []) { const normalized = Math.trunc(Number(id)); if (Number.isInteger(normalized) && normalized > 0) { unique.add(normalized); } } return [...unique]; } function truncate(value: string, maxLength: number) { const normalized = value.trim(); if (normalized.length <= maxLength) { return normalized; } return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; } function buildSessionTitle(query: string) { return truncate(query, 72); } function summarizeMemoPosture(memo: ResearchMemo | null) { if (!memo) { return 'No investment memo exists yet.'; } return JSON.stringify({ rating: memo.rating, conviction: memo.conviction, timeHorizonMonths: memo.time_horizon_months, packetTitle: memo.packet_title, packetSubtitle: memo.packet_subtitle }); } function buildConversationContext(history: Array<{ role: 'user' | 'assistant'; content_markdown: string }>) { if (history.length === 0) { return 'No previous conversation.'; } return history.map((message) => `${message.role.toUpperCase()}: ${truncate(message.content_markdown, 600)}`).join('\n\n'); } function buildPinnedArtifactContext(artifacts: Array<{ id: number; title: string | null; summary: string | null; body_markdown: string | null; kind: string }>) { if (artifacts.length === 0) { return 'No pinned artifacts.'; } return artifacts.map((artifact) => JSON.stringify({ id: artifact.id, kind: artifact.kind, title: artifact.title, summary: artifact.summary, body: artifact.body_markdown ? truncate(artifact.body_markdown, 700) : null })).join('\n'); } function buildEvidence(results: SearchResult[]) { const evidence: SearchResult[] = []; let totalChars = 0; for (const result of results) { if (evidence.length >= MAX_CONTEXT_RESULTS) { break; } if (totalChars + result.chunkText.length > MAX_CONTEXT_CHARS && evidence.length > 0) { break; } evidence.push(result); totalChars += result.chunkText.length; } return evidence; } function buildCopilotPrompt(input: { ticker: string; query: string; selectedSources: SearchSource[]; memoSection: ResearchMemoSection | null; memo: ResearchMemo | null; history: Array<{ role: 'user' | 'assistant'; content_markdown: string }>; pinnedArtifacts: Array<{ id: number; title: string | null; summary: string | null; body_markdown: string | null; kind: string }>; evidence: SearchResult[]; }) { const evidenceText = input.evidence.map((result, index) => ([ `[${index + 1}] ${result.citationLabel}`, `Source kind: ${result.sourceKind}`, `Ticker: ${result.ticker ?? 'n/a'}`, `Title: ${result.title ?? result.sourceRef}`, `Excerpt: ${result.chunkText}` ].join('\n'))).join('\n\n'); return [ 'You are an embedded buy-side company research copilot.', 'Use only the supplied evidence. Never use outside knowledge.', 'Return strict JSON only with this shape:', '{"answerMarkdown":"string","followUps":["string"],"suggestedActions":[{"type":"draft_note|draft_memo_section|queue_research_brief","label":"string","description":"string|null","section":"thesis|variant_view|catalysts|risks|disconfirming_evidence|next_actions|null","title":"string|null","contentMarkdown":"string|null","citationIndexes":[1],"query":"string|null"}]}', 'The answerMarkdown should use inline citations like [1] and [2].', 'Suggested actions must be review-first. Never instruct the system to save or mutate automatically.', `Ticker: ${input.ticker}`, `Selected sources: ${input.selectedSources.join(', ')}`, `Target memo section: ${input.memoSection ?? 'none'}`, `Memo posture: ${summarizeMemoPosture(input.memo)}`, `Pinned artifacts:\n${buildPinnedArtifactContext(input.pinnedArtifacts)}`, `Recent conversation:\n${buildConversationContext(input.history)}`, `User question: ${input.query}`, '', 'Evidence:', evidenceText ].join('\n'); } async function materializeArtifactIdForResult(userId: string, result: SearchResult) { if (result.sourceKind === 'research_note') { const artifactId = Math.trunc(Number(result.sourceRef)); return Number.isInteger(artifactId) && artifactId > 0 ? artifactId : null; } if (!result.accessionNumber) { return null; } try { if (result.sourceKind === 'filing_brief') { return (await createAiReportArtifactFromAccession(userId, result.accessionNumber)).id; } return (await createFilingArtifactFromAccession(userId, result.accessionNumber)).id; } catch { if (result.sourceKind === 'filing_brief') { try { return (await createFilingArtifactFromAccession(userId, result.accessionNumber)).id; } catch { return null; } } return null; } } async function buildCopilotCitations(userId: string, evidence: SearchResult[], citationIndexes: number[]) { const citations: ResearchCopilotCitation[] = []; for (const index of citationIndexes) { const result = evidence[index - 1]; if (!result) { continue; } citations.push({ index, label: result.citationLabel, chunkId: result.chunkId, href: result.href, source: result.source, sourceKind: result.sourceKind, sourceRef: result.sourceRef, title: result.title, ticker: result.ticker, accessionNumber: result.accessionNumber, filingDate: result.filingDate, excerpt: result.snippet || truncate(result.chunkText, 280), artifactId: await materializeArtifactIdForResult(userId, result) }); } return citations; } export async function runResearchCopilotTurn(input: CopilotTurnInput): Promise { const ticker = normalizeTicker(input.ticker); const query = input.query.trim(); if (!ticker) { throw new Error('ticker is required'); } if (!query) { throw new Error('query is required'); } const selectedSources = normalizeSources(input.selectedSources); const pinnedArtifactIds = normalizePinnedArtifactIds(input.pinnedArtifactIds); const existingSession = await getOrCreateResearchCopilotSession({ userId: input.userId, ticker, title: buildSessionTitle(query), selectedSources, pinnedArtifactIds }); const memo = await getResearchMemoByTicker(input.userId, ticker); const history = existingSession.messages.slice(-MAX_HISTORY_MESSAGES).map((message) => ({ role: message.role, content_markdown: message.content_markdown })); const pinnedArtifacts = await getResearchArtifactsByIdsForUser(input.userId, pinnedArtifactIds); const userMessage = await appendResearchCopilotMessage({ userId: input.userId, sessionId: existingSession.id, role: 'user', contentMarkdown: query, selectedSources, pinnedArtifactIds, memoSection: input.memoSection ?? null }); const results = await searchKnowledgeBase({ userId: input.userId, query, ticker, sources: selectedSources, limit: 10 }); const evidence = buildEvidence(results); if (evidence.length === 0) { const answerMarkdown = 'Insufficient evidence to answer from the indexed sources.'; const assistantMessage = await appendResearchCopilotMessage({ userId: input.userId, sessionId: existingSession.id, role: 'assistant', contentMarkdown: answerMarkdown, citations: [], followUps: [], suggestedActions: parseCopilotResponse(answerMarkdown, [], query, input.memoSection ?? null).suggestedActions, selectedSources, pinnedArtifactIds, memoSection: input.memoSection ?? null }); const session = await upsertResearchCopilotSessionState({ userId: input.userId, ticker, title: existingSession.title ?? buildSessionTitle(query), selectedSources, pinnedArtifactIds }); return { session, user_message: userMessage, assistant_message: assistantMessage, results }; } const response = await runAiAnalysis( buildCopilotPrompt({ ticker, query, selectedSources, memoSection: input.memoSection ?? null, memo, history, pinnedArtifacts, evidence }), 'Return strict JSON only. Stay concise, factual, and operational.', { workload: 'report' } ); const parsed = parseCopilotResponse(response.text, evidence, query, input.memoSection ?? null); const citations = await buildCopilotCitations(input.userId, evidence, parsed.citationIndexes); const assistantMessage = await appendResearchCopilotMessage({ userId: input.userId, sessionId: existingSession.id, role: 'assistant', contentMarkdown: parsed.answerMarkdown, citations, followUps: parsed.followUps, suggestedActions: parsed.suggestedActions, selectedSources, pinnedArtifactIds, memoSection: input.memoSection ?? null }); const session = await upsertResearchCopilotSessionState({ userId: input.userId, ticker, title: existingSession.title ?? buildSessionTitle(query), selectedSources, pinnedArtifactIds }); return { session, user_message: userMessage, assistant_message: assistantMessage, results }; } function buildResearchBriefPrompt(input: { ticker: string; query: string; memo: ResearchMemo | null; evidence: SearchResult[]; }) { const evidenceText = input.evidence.map((result, index) => [ `[${index + 1}] ${result.citationLabel}`, `Title: ${result.title ?? result.sourceRef}`, `Excerpt: ${result.chunkText}` ].join('\n')).join('\n\n'); return [ 'Write a longer-form buy-side research brief grounded only in the evidence below.', 'Use markdown with these sections: Executive Summary, Key Evidence, Memo Implications, Open Questions.', `Ticker: ${input.ticker}`, `Brief request: ${input.query}`, `Memo posture: ${summarizeMemoPosture(input.memo)}`, '', 'Evidence:', evidenceText ].join('\n'); } export async function generateResearchBrief(input: { userId: string; ticker: string; query: string; selectedSources?: SearchSource[]; }) { const selectedSources = normalizeSources(input.selectedSources); const memo = await getResearchMemoByTicker(input.userId, input.ticker); const results = await searchKnowledgeBase({ userId: input.userId, query: input.query, ticker: input.ticker, sources: selectedSources, limit: 10 }); const evidence = buildEvidence(results); const response = await runAiAnalysis( buildResearchBriefPrompt({ ticker: normalizeTicker(input.ticker), query: input.query.trim(), memo, evidence }), 'Use neutral analyst prose and cite evidence inline like [1].', { workload: 'report' } ); return { provider: response.provider, model: response.model, bodyMarkdown: response.text.trim(), evidence }; } export const __researchCopilotInternals = { buildCopilotPrompt, buildResearchBriefPrompt, extractJsonObject, parseCopilotResponse, parseCitationIndexes };