420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
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<SearchSource>();
|
|
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<number>();
|
|
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<ResearchCopilotTurnResponse> {
|
|
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
|
|
};
|