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

@@ -10,6 +10,7 @@ import type {
import { runAiAnalysis } from '@/lib/server/ai';
import { buildPortfolioSummary } from '@/lib/server/portfolio';
import { getQuote } from '@/lib/server/prices';
import { generateResearchBrief } from '@/lib/server/research-copilot';
import { indexSearchDocuments } from '@/lib/server/search';
import {
getFilingByAccession,
@@ -32,7 +33,9 @@ import {
listUserHoldings
} from '@/lib/server/repos/holdings';
import { createPortfolioInsight } from '@/lib/server/repos/insights';
import { createResearchArtifactRecord } from '@/lib/server/repos/research-library';
import { updateTaskStage } from '@/lib/server/repos/tasks';
import { updateWatchlistReviewByTicker } from '@/lib/server/repos/watchlist';
import {
fetchPrimaryFilingText,
fetchRecentFilings
@@ -1302,6 +1305,97 @@ async function processPortfolioInsights(task: Task) {
);
}
async function processResearchBrief(task: Task) {
const ticker = typeof task.payload.ticker === 'string' ? task.payload.ticker.trim().toUpperCase() : '';
const query = typeof task.payload.query === 'string' ? task.payload.query.trim() : '';
const sources = Array.isArray(task.payload.sources)
? task.payload.sources.filter((entry): entry is 'documents' | 'filings' | 'research' => entry === 'documents' || entry === 'filings' || entry === 'research')
: undefined;
if (!ticker) {
throw new Error('Research brief task requires a ticker');
}
if (!query) {
throw new Error('Research brief task requires a query');
}
await setProjectionStage(task, 'research.retrieve', `Collecting evidence for ${ticker} research brief`, {
subject: { ticker },
progress: { current: 1, total: 3, unit: 'steps' }
});
const brief = await generateResearchBrief({
userId: task.user_id,
ticker,
query,
selectedSources: sources
});
await setProjectionStage(task, 'research.answer', `Generating research brief for ${ticker}`, {
subject: { ticker },
progress: { current: 2, total: 3, unit: 'steps' },
counters: {
evidence: brief.evidence.length
}
});
const summary = brief.bodyMarkdown
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0 && !line.startsWith('#'))
?? `Generated research brief for ${ticker}.`;
await setProjectionStage(task, 'research.persist', `Saving research brief artifact for ${ticker}`, {
subject: { ticker },
progress: { current: 3, total: 3, unit: 'steps' },
counters: {
evidence: brief.evidence.length
}
});
const artifact = await createResearchArtifactRecord({
userId: task.user_id,
ticker,
kind: 'ai_report',
source: 'system',
subtype: 'research_brief',
title: `Research brief · ${query}`,
summary,
bodyMarkdown: brief.bodyMarkdown,
tags: ['copilot', 'research-brief'],
metadata: {
query,
sources: sources ?? ['documents', 'filings', 'research'],
provider: brief.provider,
model: brief.model,
citations: brief.evidence.map((result, index) => ({
index: index + 1,
label: result.citationLabel,
href: result.href
}))
}
});
await updateWatchlistReviewByTicker(task.user_id, ticker, artifact.updated_at);
return buildTaskOutcome(
{
ticker,
artifactId: artifact.id,
provider: brief.provider,
model: brief.model
},
`Generated research brief artifact for ${ticker}.`,
{
subject: { ticker },
counters: {
evidence: brief.evidence.length
}
}
);
}
export const __taskProcessorInternals = {
parseExtractionPayload,
deterministicExtractionFallback,
@@ -1320,6 +1414,8 @@ export async function runTaskProcessor(task: Task) {
return await processPortfolioInsights(task);
case 'index_search':
return await processIndexSearch(task);
case 'research_brief':
return await processResearchBrief(task);
default:
throw new Error(`Unsupported task type: ${task.task_type}`);
}