Files
Neon-Desk/lib/server/company-overview-synthesis.ts

274 lines
8.4 KiB
TypeScript

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<T> = {
expiresAt: number;
value: T;
};
const SYNTHESIS_CACHE_TTL_MS = 1000 * 60 * 30;
const synthesisCache = new Map<string, CacheEntry<SynthesisResult>>();
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<string>();
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<SynthesisResult> {
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
};