274 lines
8.4 KiB
TypeScript
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
|
|
};
|