Add hybrid research copilot workspace

This commit is contained in:
2026-03-14 19:32:00 -04:00
parent 7a42d73a48
commit 2ee9a549a3
27 changed files with 2864 additions and 323 deletions

View 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
};