Rebuild company overview analysis page
This commit is contained in:
273
lib/server/company-overview-synthesis.ts
Normal file
273
lib/server/company-overview-synthesis.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user