Add hybrid research copilot workspace
This commit is contained in:
419
lib/server/research-copilot.ts
Normal file
419
lib/server/research-copilot.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user