feat: migrate task jobs to workflow notifications + timeline

This commit is contained in:
2026-03-02 14:29:31 -05:00
parent 36c4ed2ee2
commit d81a681905
33 changed files with 2437 additions and 292 deletions

View File

@@ -3,7 +3,8 @@ import type {
FilingExtraction,
FilingExtractionMeta,
Holding,
Task
Task,
TaskStage
} from '@/lib/types';
import { runAiAnalysis } from '@/lib/server/ai';
import { buildPortfolioSummary } from '@/lib/server/portfolio';
@@ -24,6 +25,7 @@ import {
listUserHoldings
} from '@/lib/server/repos/holdings';
import { createPortfolioInsight } from '@/lib/server/repos/insights';
import { updateTaskStage } from '@/lib/server/repos/tasks';
import {
fetchFilingMetricsForFilings,
fetchPrimaryFilingText,
@@ -130,6 +132,10 @@ function toTaskResult(value: unknown): Record<string, unknown> {
return value as Record<string, unknown>;
}
async function setProjectionStage(task: Task, stage: TaskStage, detail: string | null = null) {
await updateTaskStage(task.id, stage, detail);
}
function parseTicker(raw: unknown) {
if (typeof raw !== 'string' || raw.trim().length < 1) {
throw new Error('Ticker is required');
@@ -513,6 +519,8 @@ function filingLinks(filing: {
async function processSyncFilings(task: Task) {
const ticker = parseTicker(task.payload.ticker);
const limit = parseLimit(task.payload.limit, 20, 1, 50);
await setProjectionStage(task, 'sync.fetch_filings', `Fetching up to ${limit} filings for ${ticker}`);
const filings = await fetchRecentFilings(ticker, limit);
const metricsByAccession = new Map<string, Filing['metrics']>();
const filingsByCik = new Map<string, typeof filings>();
@@ -527,6 +535,7 @@ async function processSyncFilings(task: Task) {
filingsByCik.set(filing.cik, [filing]);
}
await setProjectionStage(task, 'sync.fetch_metrics', `Computing financial metrics for ${filings.length} filings`);
for (const [cik, filingsForCik] of filingsByCik) {
const filingsForFinancialMetrics = filingsForCik.filter((filing) => isFinancialMetricsForm(filing.filingType));
if (filingsForFinancialMetrics.length === 0) {
@@ -548,6 +557,7 @@ async function processSyncFilings(task: Task) {
}
}
await setProjectionStage(task, 'sync.persist_filings', 'Persisting filings and links');
const saveResult = await upsertFilingsRecords(
filings.map((filing) => ({
ticker: filing.ticker,
@@ -574,6 +584,7 @@ async function processSyncFilings(task: Task) {
return filing.filing_type === '10-K' || filing.filing_type === '10-Q';
});
await setProjectionStage(task, 'sync.hydrate_statements', `Hydrating statement snapshots for ${hydrateCandidates.length} candidate filings`);
for (const filing of hydrateCandidates) {
const existingSnapshot = await getFilingStatementSnapshotByFilingId(filing.id);
const shouldRefresh = !existingSnapshot
@@ -634,15 +645,18 @@ async function processRefreshPrices(task: Task) {
throw new Error('Task is missing user scope');
}
await setProjectionStage(task, 'refresh.load_holdings', 'Loading holdings for price refresh');
const userHoldings = await listHoldingsForPriceRefresh(userId);
const tickers = [...new Set(userHoldings.map((entry) => entry.ticker))];
const quotes = new Map<string, number>();
await setProjectionStage(task, 'refresh.fetch_quotes', `Fetching quotes for ${tickers.length} tickers`);
for (const ticker of tickers) {
const quote = await getQuote(ticker);
quotes.set(ticker, quote);
}
await setProjectionStage(task, 'refresh.persist_prices', 'Writing refreshed prices to holdings');
const updatedCount = await applyRefreshedPrices(userId, quotes, new Date().toISOString());
return {
@@ -660,6 +674,7 @@ async function processAnalyzeFiling(task: Task) {
throw new Error('accessionNumber is required');
}
await setProjectionStage(task, 'analyze.load_filing', `Loading filing ${accessionNumber}`);
const filing = await getFilingByAccession(accessionNumber);
if (!filing) {
@@ -676,6 +691,7 @@ async function processAnalyzeFiling(task: Task) {
};
try {
await setProjectionStage(task, 'analyze.fetch_document', 'Fetching primary filing document');
const filingDocument = await fetchPrimaryFilingText({
filingUrl: filing.filing_url,
cik: filing.cik,
@@ -684,6 +700,7 @@ async function processAnalyzeFiling(task: Task) {
});
if (filingDocument?.text) {
await setProjectionStage(task, 'analyze.extract', 'Generating extraction context from filing text');
const ruleBasedExtraction = buildRuleBasedExtraction(filing, filingDocument.text);
extraction = ruleBasedExtraction;
extractionMeta = {
@@ -720,12 +737,14 @@ async function processAnalyzeFiling(task: Task) {
};
}
await setProjectionStage(task, 'analyze.generate_report', 'Generating final filing analysis report');
const analysis = await runAiAnalysis(
reportPrompt(filing, extraction, extractionMeta),
'Use concise institutional analyst language.',
{ workload: 'report' }
);
await setProjectionStage(task, 'analyze.persist_report', 'Persisting filing analysis output');
await saveFilingAnalysis(accessionNumber, {
provider: analysis.provider,
model: analysis.model,
@@ -761,6 +780,7 @@ async function processPortfolioInsights(task: Task) {
throw new Error('Task is missing user scope');
}
await setProjectionStage(task, 'insights.load_holdings', 'Loading holdings for portfolio insight generation');
const userHoldings = await listUserHoldings(userId);
const summary = buildPortfolioSummary(userHoldings);
@@ -771,12 +791,14 @@ async function processPortfolioInsights(task: Task) {
'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.'
].join('\n');
await setProjectionStage(task, 'insights.generate', 'Generating portfolio AI insight');
const analysis = await runAiAnalysis(
prompt,
'Act as a risk-aware buy-side analyst.',
{ workload: 'report' }
);
await setProjectionStage(task, 'insights.persist', 'Persisting generated portfolio insight');
await createPortfolioInsight({
userId,
provider: analysis.provider,