import { isAiConfigured, runAiAnalysis } from '@/lib/server/ai'; import type { CompanyAiReport, CompanyBullBear, RecentDevelopmentItem, RecentDevelopmentsWeeklySnapshot, ResearchMemo } from '@/lib/types'; type SynthesisResult = { bullBear: CompanyBullBear; weeklySnapshot: RecentDevelopmentsWeeklySnapshot | null; }; type SynthesisOptions = { now?: Date; runAnalysis?: typeof runAiAnalysis; aiConfigured?: boolean; }; type CacheEntry = { expiresAt: number; value: T; }; const SYNTHESIS_CACHE_TTL_MS = 1000 * 60 * 30; const synthesisCache = new Map>(); function normalizeLine(line: string) { return line .replace(/^[-*+]\s+/, '') .replace(/^\d+\.\s+/, '') .replace(/[`#>*_~]/g, ' ') .replace(/\[(.*?)\]\((.*?)\)/g, '$1') .replace(/\s+/g, ' ') .trim(); } function bulletizeText(value: string | null | undefined, fallback: string[] = []) { if (!value) { return fallback; } const lines = value .split(/\n+/) .map(normalizeLine) .filter((line) => line.length >= 18); if (lines.length > 0) { return lines; } return value .split(/(?<=[.!?])\s+/) .map(normalizeLine) .filter((sentence) => sentence.length >= 18); } function dedupe(items: string[], limit = 5) { const seen = new Set(); const result: string[] = []; for (const item of items) { const normalized = item.trim(); if (!normalized) { continue; } const key = normalized.toLowerCase(); if (seen.has(key)) { continue; } seen.add(key); result.push(normalized); if (result.length >= limit) { break; } } return result; } function fallbackBullBear(input: { memo: ResearchMemo | null; latestFilingSummary: { summary: string | null } | null; recentDevelopments: RecentDevelopmentItem[]; }) { const bull = dedupe([ ...bulletizeText(input.memo?.thesis_markdown), ...bulletizeText(input.memo?.catalysts_markdown), ...bulletizeText(input.latestFilingSummary?.summary) ]); const bear = dedupe([ ...bulletizeText(input.memo?.risks_markdown), ...bulletizeText(input.memo?.disconfirming_evidence_markdown), ...bulletizeText(input.memo?.variant_view_markdown) ]); const highlights = dedupe( input.recentDevelopments .slice(0, 3) .map((item) => item.summary ?? item.title), 3 ); const summary = highlights.length > 0 ? `Recent developments centered on ${highlights.join(' ')}` : 'No recent SEC development summaries are available yet.'; return { bullBear: { source: bull.length > 0 || bear.length > 0 ? 'memo_fallback' : 'unavailable', bull, bear, updatedAt: new Date().toISOString() } satisfies CompanyBullBear, weeklySummary: { summary, highlights } }; } function parseAiJson(text: string) { const fenced = text.match(/```json\s*([\s\S]*?)```/i); const raw = fenced?.[1] ?? text; const parsed = JSON.parse(raw) as { bull?: unknown; bear?: unknown; weeklySummary?: unknown; weeklyHighlights?: unknown; }; const asItems = (value: unknown) => Array.isArray(value) ? dedupe(value.filter((item): item is string => typeof item === 'string'), 5) : []; return { bull: asItems(parsed.bull), bear: asItems(parsed.bear), weeklySummary: typeof parsed.weeklySummary === 'string' ? parsed.weeklySummary.trim() : '', weeklyHighlights: asItems(parsed.weeklyHighlights).slice(0, 3) }; } function buildPrompt(input: { ticker: string; companyName: string; description: string | null; memo: ResearchMemo | null; latestFilingSummary: { summary: string | null; filingType: string; filingDate: string } | null; recentAiReports: CompanyAiReport[]; recentDevelopments: RecentDevelopmentItem[]; }) { return [ 'You are synthesizing a public-equity company overview.', 'Return strict JSON with keys: bull, bear, weeklySummary, weeklyHighlights.', 'bull and bear must each be arrays of 3 to 5 concise strings.', 'weeklyHighlights must be an array of up to 3 concise strings.', 'Do not include markdown, prose before JSON, or code fences unless absolutely necessary.', '', `Ticker: ${input.ticker}`, `Company: ${input.companyName}`, `Business description: ${input.description ?? 'n/a'}`, `Memo thesis: ${input.memo?.thesis_markdown ?? 'n/a'}`, `Memo catalysts: ${input.memo?.catalysts_markdown ?? 'n/a'}`, `Memo risks: ${input.memo?.risks_markdown ?? 'n/a'}`, `Memo disconfirming evidence: ${input.memo?.disconfirming_evidence_markdown ?? 'n/a'}`, `Memo variant view: ${input.memo?.variant_view_markdown ?? 'n/a'}`, `Latest filing summary: ${input.latestFilingSummary?.summary ?? 'n/a'}`, `Recent AI report summaries: ${input.recentAiReports.map((report) => `${report.filingType} ${report.filingDate}: ${report.summary}`).join(' | ') || 'n/a'}`, `Recent developments: ${input.recentDevelopments.map((item) => `${item.kind} ${item.publishedAt}: ${item.summary ?? item.title}`).join(' | ') || 'n/a'}` ].join('\n'); } export async function synthesizeCompanyOverview( input: { ticker: string; companyName: string; description: string | null; memo: ResearchMemo | null; latestFilingSummary: { accessionNumber: string; filingDate: string; filingType: string; summary: string | null; } | null; recentAiReports: CompanyAiReport[]; recentDevelopments: RecentDevelopmentItem[]; }, options?: SynthesisOptions ): Promise { const now = options?.now ?? new Date(); const cacheKey = JSON.stringify({ ticker: input.ticker, description: input.description, memoUpdatedAt: input.memo?.updated_at ?? null, latestFilingSummary: input.latestFilingSummary?.accessionNumber ?? null, recentAiReports: input.recentAiReports.map((report) => report.accessionNumber), recentDevelopments: input.recentDevelopments.map((item) => item.id) }); const cached = synthesisCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.value; } const fallback = fallbackBullBear(input); const buildWeeklySnapshot = (source: 'ai_synthesized' | 'heuristic', summary: string, highlights: string[]) => ({ summary, highlights, itemCount: input.recentDevelopments.length, startDate: new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), endDate: now.toISOString().slice(0, 10), updatedAt: now.toISOString(), source } satisfies RecentDevelopmentsWeeklySnapshot); const aiEnabled = options?.aiConfigured ?? isAiConfigured(); if (!aiEnabled) { const result = { bullBear: fallback.bullBear, weeklySnapshot: buildWeeklySnapshot('heuristic', fallback.weeklySummary.summary, fallback.weeklySummary.highlights) }; synthesisCache.set(cacheKey, { value: result, expiresAt: Date.now() + SYNTHESIS_CACHE_TTL_MS }); return result; } try { const runAnalysis = options?.runAnalysis ?? runAiAnalysis; const aiResult = await runAnalysis( buildPrompt(input), 'Respond with strict JSON only.', { workload: 'report' } ); const parsed = parseAiJson(aiResult.text); const bull = parsed.bull.length > 0 ? parsed.bull : fallback.bullBear.bull; const bear = parsed.bear.length > 0 ? parsed.bear : fallback.bullBear.bear; const summary = parsed.weeklySummary || fallback.weeklySummary.summary; const highlights = parsed.weeklyHighlights.length > 0 ? parsed.weeklyHighlights : fallback.weeklySummary.highlights; const result = { bullBear: { source: bull.length > 0 || bear.length > 0 ? 'ai_synthesized' : fallback.bullBear.source, bull, bear, updatedAt: now.toISOString() } satisfies CompanyBullBear, weeklySnapshot: buildWeeklySnapshot( summary || highlights.length > 0 ? 'ai_synthesized' : 'heuristic', summary || fallback.weeklySummary.summary, highlights ) }; synthesisCache.set(cacheKey, { value: result, expiresAt: Date.now() + SYNTHESIS_CACHE_TTL_MS }); return result; } catch { const result = { bullBear: fallback.bullBear, weeklySnapshot: buildWeeklySnapshot('heuristic', fallback.weeklySummary.summary, fallback.weeklySummary.highlights) }; synthesisCache.set(cacheKey, { value: result, expiresAt: Date.now() + SYNTHESIS_CACHE_TTL_MS }); return result; } } export const __companyOverviewSynthesisInternals = { bulletizeText, dedupe, fallbackBullBear, parseAiJson };