feat: migrate task jobs to workflow notifications + timeline
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user