import { createHash } from 'node:crypto'; import type { CompanyAiReport, CompanyAnalysis, Filing, Holding, ResearchJournalEntry, ResearchMemo, WatchlistItem } from '@/lib/types'; import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis'; import { getPriceHistory, getQuote } from '@/lib/server/prices'; import { getRecentDevelopments } from '@/lib/server/recent-developments'; import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile'; import { getCompanyDescription } from '@/lib/server/sec-description'; import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile'; import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction'; import { listFilingsRecords } from '@/lib/server/repos/filings'; import { getHoldingByTicker } from '@/lib/server/repos/holdings'; import { getResearchMemoByTicker } from '@/lib/server/repos/research-library'; import { listResearchJournalEntries } from '@/lib/server/repos/research-journal'; import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist'; import { CURRENT_COMPANY_OVERVIEW_CACHE_VERSION, getCompanyOverviewCache, upsertCompanyOverviewCache } from '@/lib/server/repos/company-overview-cache'; const FINANCIAL_FORMS = new Set(['10-K', '10-Q']); const COMPANY_OVERVIEW_CACHE_TTL_MS = 1000 * 60 * 15; export type CompanyAnalysisLocalInputs = { filings: Filing[]; holding: Holding | null; watchlistItem: WatchlistItem | null; journalPreview: ResearchJournalEntry[]; memo: ResearchMemo | null; }; function withFinancialMetricsPolicy(filing: Filing): Filing { if (FINANCIAL_FORMS.has(filing.filing_type)) { return filing; } return { ...filing, metrics: null }; } export function buildCompanyAnalysisSourceSignature(input: { ticker: string; localInputs: CompanyAnalysisLocalInputs; cacheVersion?: number; }) { const payload = { ticker: input.ticker.trim().toUpperCase(), cacheVersion: input.cacheVersion ?? CURRENT_COMPANY_OVERVIEW_CACHE_VERSION, filings: input.localInputs.filings.map((filing) => ({ accessionNumber: filing.accession_number, updatedAt: filing.updated_at })), memoUpdatedAt: input.localInputs.memo?.updated_at ?? null, watchlistUpdatedAt: input.localInputs.watchlistItem?.updated_at ?? null, holdingUpdatedAt: input.localInputs.holding?.updated_at ?? null, journalPreview: input.localInputs.journalPreview.map((entry) => ({ id: entry.id, updatedAt: entry.updated_at })) }; return createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } export function isCompanyOverviewCacheFresh(input: { updatedAt: string; sourceSignature: string; expectedSourceSignature: string; cacheVersion: number; refresh?: boolean; ttlMs?: number; now?: Date; }) { if (input.refresh) { return false; } if (input.cacheVersion !== CURRENT_COMPANY_OVERVIEW_CACHE_VERSION) { return false; } if (input.sourceSignature !== input.expectedSourceSignature) { return false; } const now = input.now ?? new Date(); const updatedAt = Date.parse(input.updatedAt); if (!Number.isFinite(updatedAt)) { return false; } return now.getTime() - updatedAt <= (input.ttlMs ?? COMPANY_OVERVIEW_CACHE_TTL_MS); } async function getCompanyAnalysisLocalInputs(input: { userId: string; ticker: string }): Promise { const ticker = input.ticker.trim().toUpperCase(); const [filings, holding, watchlistItem, journalPreview, memo] = await Promise.all([ listFilingsRecords({ ticker, limit: 40 }), getHoldingByTicker(input.userId, ticker), getWatchlistItemByTicker(input.userId, ticker), listResearchJournalEntries(input.userId, ticker, 6), getResearchMemoByTicker(input.userId, ticker) ]); return { filings, holding, watchlistItem, journalPreview, memo }; } async function buildCompanyAnalysisPayload(input: { userId: string; ticker: string; localInputs: CompanyAnalysisLocalInputs; }): Promise { const ticker = input.ticker.trim().toUpperCase(); const redactedFilings = input.localInputs.filings .map(redactInternalFilingAnalysisFields) .map(withFinancialMetricsPolicy); const [liveQuote, priceHistory, benchmarkHistory, secProfile] = await Promise.all([ getQuote(ticker), getPriceHistory(ticker), getPriceHistory('^GSPC'), getSecCompanyProfile(ticker) ]); const latestFiling = redactedFilings[0] ?? null; const companyName = latestFiling?.company_name ?? secProfile?.companyName ?? input.localInputs.holding?.company_name ?? input.localInputs.watchlistItem?.company_name ?? ticker; const financials = redactedFilings .filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type)) .map((entry) => ({ filingDate: entry.filing_date, filingType: entry.filing_type, revenue: entry.metrics?.revenue ?? null, netIncome: entry.metrics?.netIncome ?? null, totalAssets: entry.metrics?.totalAssets ?? null, cash: entry.metrics?.cash ?? null, debt: entry.metrics?.debt ?? null })); const aiReports: CompanyAiReport[] = redactedFilings .filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights) .slice(0, 8) .map((entry) => ({ accessionNumber: entry.accession_number, filingDate: entry.filing_date, filingType: entry.filing_type, provider: entry.analysis?.provider ?? 'unknown', model: entry.analysis?.model ?? 'unknown', summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? '' })); const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null; const referenceMetrics = latestMetricsFiling?.metrics ?? null; const keyMetrics = { referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null, revenue: referenceMetrics?.revenue ?? null, netIncome: referenceMetrics?.netIncome ?? null, totalAssets: referenceMetrics?.totalAssets ?? null, cash: referenceMetrics?.cash ?? null, debt: referenceMetrics?.debt ?? null, netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null ? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100 : null }; const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null; const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([ getCompanyDescription(annualFiling), getYahooCompanyDescription(ticker), getRecentDevelopments(ticker, { filings: redactedFilings }) ]); const description = yahooDescription ?? secDescription; const latestFilingSummary = latestFiling ? { accessionNumber: latestFiling.accession_number, filingDate: latestFiling.filing_date, filingType: latestFiling.filing_type, filingUrl: latestFiling.filing_url, submissionUrl: latestFiling.submission_url ?? null, summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null, hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights) } : null; const companyProfile = toCompanyProfile(secProfile, description); const valuationSnapshot = deriveValuationSnapshot({ quote: liveQuote.value, sharesOutstanding: secProfile?.sharesOutstanding ?? null, revenue: keyMetrics.revenue, cash: keyMetrics.cash, debt: keyMetrics.debt, netIncome: keyMetrics.netIncome }); const synthesis = await synthesizeCompanyOverview({ ticker, companyName, description, memo: input.localInputs.memo, latestFilingSummary, recentAiReports: aiReports.slice(0, 5), recentDevelopments: synthesizedDevelopments.items }); const recentDevelopments = { ...synthesizedDevelopments, weeklySnapshot: synthesis.weeklySnapshot, status: synthesizedDevelopments.items.length > 0 ? synthesis.weeklySnapshot ? 'ready' : 'partial' : synthesis.weeklySnapshot ? 'partial' : 'unavailable' } as const; return { company: { ticker, companyName, sector: input.localInputs.watchlistItem?.sector ?? null, category: input.localInputs.watchlistItem?.category ?? null, tags: input.localInputs.watchlistItem?.tags ?? [], cik: latestFiling?.cik ?? null }, quote: { value: liveQuote.value, stale: liveQuote.stale }, position: input.localInputs.holding, priceHistory: { value: priceHistory.value, stale: priceHistory.stale }, benchmarkHistory: { value: benchmarkHistory.value, stale: benchmarkHistory.stale }, financials, filings: redactedFilings.slice(0, 20), aiReports, coverage: input.localInputs.watchlistItem ? { ...input.localInputs.watchlistItem, latest_filing_date: latestFiling?.filing_date ?? input.localInputs.watchlistItem.latest_filing_date ?? null } : null, journalPreview: input.localInputs.journalPreview, recentAiReports: aiReports.slice(0, 5), latestFilingSummary, keyMetrics, companyProfile, valuationSnapshot, bullBear: synthesis.bullBear, recentDevelopments }; } type GetCompanyAnalysisPayloadOptions = { userId: string; ticker: string; refresh?: boolean; now?: Date; }; type GetCompanyAnalysisPayloadDeps = { getLocalInputs?: (input: { userId: string; ticker: string }) => Promise; getCachedOverview?: typeof getCompanyOverviewCache; upsertCachedOverview?: typeof upsertCompanyOverviewCache; buildOverview?: (input: { userId: string; ticker: string; localInputs: CompanyAnalysisLocalInputs; }) => Promise; }; export async function getCompanyAnalysisPayload( input: GetCompanyAnalysisPayloadOptions, deps?: GetCompanyAnalysisPayloadDeps ): Promise { const ticker = input.ticker.trim().toUpperCase(); const now = input.now ?? new Date(); const localInputs = await (deps?.getLocalInputs ?? getCompanyAnalysisLocalInputs)({ userId: input.userId, ticker }); const sourceSignature = buildCompanyAnalysisSourceSignature({ ticker, localInputs }); const cached = await (deps?.getCachedOverview ?? getCompanyOverviewCache)({ userId: input.userId, ticker }); if (cached && isCompanyOverviewCacheFresh({ updatedAt: cached.updated_at, sourceSignature: cached.source_signature, expectedSourceSignature: sourceSignature, cacheVersion: cached.cache_version, refresh: input.refresh, now })) { return cached.payload; } const analysis = await (deps?.buildOverview ?? buildCompanyAnalysisPayload)({ userId: input.userId, ticker, localInputs }); await (deps?.upsertCachedOverview ?? upsertCompanyOverviewCache)({ userId: input.userId, ticker, sourceSignature, payload: analysis }); return analysis; } export const __companyAnalysisInternals = { COMPANY_OVERVIEW_CACHE_TTL_MS, buildCompanyAnalysisPayload, buildCompanyAnalysisSourceSignature, getCompanyAnalysisLocalInputs, isCompanyOverviewCacheFresh, withFinancialMetricsPolicy };