Add company overview skeleton and cache

This commit is contained in:
2026-03-13 19:05:17 -04:00
parent b1c9c0ef08
commit 0394f4e795
18 changed files with 1571 additions and 158 deletions

View File

@@ -70,12 +70,6 @@ import {
updateWatchlistReviewByTicker,
upsertWatchlistItemRecord
} from '@/lib/server/repos/watchlist';
import { getPriceHistory, getQuote } from '@/lib/server/prices';
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
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 { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
import {
enqueueTask,
@@ -86,6 +80,7 @@ import {
listRecentTasks,
updateTaskNotification
} from '@/lib/server/tasks';
import { getCompanyAnalysisPayload } from '@/lib/server/company-analysis';
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
const FINANCIAL_FORMS: ReadonlySet<Filing['filing_type']> = new Set(['10-K', '10-Q']);
@@ -1390,145 +1385,20 @@ export const app = new Elysia({ prefix: '/api' })
if (!ticker) {
return jsonError('ticker is required');
}
const [filings, holding, watchlistItem, liveQuote, priceHistory, benchmarkHistory, journalPreview, memo, secProfile] = await Promise.all([
listFilingsRecords({ ticker, limit: 40 }),
getHoldingByTicker(session.user.id, ticker),
getWatchlistItemByTicker(session.user.id, ticker),
getQuote(ticker),
getPriceHistory(ticker),
getPriceHistory('^GSPC'),
listResearchJournalEntries(session.user.id, ticker, 6),
getResearchMemoByTicker(session.user.id, ticker),
getSecCompanyProfile(ticker)
]);
const redactedFilings = filings
.map(redactInternalFilingAnalysisFields)
.map(withFinancialMetricsPolicy);
const latestFiling = redactedFilings[0] ?? null;
const companyName = latestFiling?.company_name
?? secProfile?.companyName
?? holding?.company_name
?? 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 = 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,
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
revenue: keyMetrics.revenue,
cash: keyMetrics.cash,
debt: keyMetrics.debt,
netIncome: keyMetrics.netIncome
});
const synthesis = await synthesizeCompanyOverview({
const refresh = asBoolean(query.refresh, false);
const analysis = await getCompanyAnalysisPayload({
userId: session.user.id,
ticker,
companyName,
description,
memo,
latestFilingSummary,
recentAiReports: aiReports.slice(0, 5),
recentDevelopments: synthesizedDevelopments.items
refresh
});
const recentDevelopments = {
...synthesizedDevelopments,
weeklySnapshot: synthesis.weeklySnapshot,
status: synthesizedDevelopments.items.length > 0
? synthesis.weeklySnapshot ? 'ready' : 'partial'
: synthesis.weeklySnapshot ? 'partial' : 'unavailable'
} as const;
return Response.json({
analysis: {
company: {
ticker,
companyName,
sector: watchlistItem?.sector ?? null,
category: watchlistItem?.category ?? null,
tags: watchlistItem?.tags ?? [],
cik: latestFiling?.cik ?? null
},
quote: liveQuote,
position: holding,
priceHistory,
benchmarkHistory,
financials,
filings: redactedFilings.slice(0, 20),
aiReports,
coverage: watchlistItem
? {
...watchlistItem,
latest_filing_date: latestFiling?.filing_date ?? watchlistItem.latest_filing_date ?? null
}
: null,
journalPreview,
recentAiReports: aiReports.slice(0, 5),
latestFilingSummary,
keyMetrics,
companyProfile,
valuationSnapshot,
bullBear: synthesis.bullBear,
recentDevelopments
}
analysis
});
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
ticker: t.String({ minLength: 1 }),
refresh: t.Optional(t.String())
})
})
.get('/financials/company', async ({ query }) => {

View File

@@ -127,7 +127,8 @@ function applySqlMigrations(client: { exec: (query: string) => void }) {
'0008_research_workspace.sql',
'0009_task_notification_context.sql',
'0010_taxonomy_surface_sidecar.sql',
'0011_remove_legacy_xbrl_defaults.sql'
'0011_remove_legacy_xbrl_defaults.sql',
'0012_company_overview_cache.sql'
];
for (const file of migrationFiles) {
@@ -165,6 +166,7 @@ function clearProjectionTables(client: { exec: (query: string) => void }) {
client.exec('DELETE FROM holding;');
client.exec('DELETE FROM watchlist_item;');
client.exec('DELETE FROM portfolio_insight;');
client.exec('DELETE FROM company_overview_cache;');
client.exec('DELETE FROM filing;');
}
@@ -246,6 +248,73 @@ async function jsonRequest(
};
}
function buildCachedAnalysisPayload(input: {
ticker: string;
companyName: string;
bull?: string[];
}) {
return {
company: {
ticker: input.ticker,
companyName: input.companyName,
sector: null,
category: null,
tags: [],
cik: null
},
quote: 100,
position: null,
priceHistory: [],
benchmarkHistory: [],
financials: [],
filings: [],
aiReports: [],
coverage: null,
journalPreview: [],
recentAiReports: [],
latestFilingSummary: null,
keyMetrics: {
referenceDate: null,
revenue: null,
netIncome: null,
totalAssets: null,
cash: null,
debt: null,
netMargin: null
},
companyProfile: {
description: null,
exchange: null,
industry: null,
country: null,
website: null,
fiscalYearEnd: null,
employeeCount: null,
source: 'unavailable'
},
valuationSnapshot: {
sharesOutstanding: null,
marketCap: null,
enterpriseValue: null,
trailingPe: null,
evToRevenue: null,
evToEbitda: null,
source: 'unavailable'
},
bullBear: {
source: input.bull && input.bull.length > 0 ? 'memo_fallback' : 'unavailable',
bull: input.bull ?? [],
bear: [],
updatedAt: new Date().toISOString()
},
recentDevelopments: {
status: 'unavailable',
items: [],
weeklySnapshot: null
}
};
}
if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
describe('task workflow hybrid migration e2e', () => {
beforeAll(async () => {
@@ -472,7 +541,7 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
ticker: 'NFLX',
accessionNumber: '0000000000-26-000777',
filingType: '10-K',
filingDate: '2026-02-15',
filingDate: '2026-03-10',
companyName: 'Netflix, Inc.',
metrics: {
revenue: 41000000000,
@@ -575,6 +644,157 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
}).entries).toHaveLength(0);
});
it('serves cached analysis until refresh is requested', async () => {
if (!sqliteClient) {
throw new Error('sqlite client not initialized');
}
seedFilingRecord(sqliteClient, {
ticker: 'CACH',
accessionNumber: '0000000000-26-000901',
filingType: '10-K',
filingDate: '2026-02-20',
companyName: 'Live Corp'
});
const filingRow = sqliteClient.query(`
SELECT created_at, updated_at
FROM filing
WHERE ticker = 'CACH'
ORDER BY id DESC
LIMIT 1
`).get() as { created_at: string; updated_at: string } | null;
if (!filingRow) {
throw new Error('cached filing row not found');
}
const { __companyAnalysisInternals } = await import('../company-analysis');
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
ticker: 'CACH',
localInputs: {
filings: [{
id: 1,
ticker: 'CACH',
filing_type: '10-K',
filing_date: '2026-02-20',
accession_number: '0000000000-26-000901',
cik: '0000000000',
company_name: 'Live Corp',
filing_url: 'https://www.sec.gov/Archives/0000000000-26-000901.htm',
submission_url: 'https://www.sec.gov/submissions/0000000000-26-000901.json',
primary_document: '0000000000-26-000901.htm',
metrics: null,
analysis: null,
created_at: filingRow.created_at,
updated_at: filingRow.updated_at
}],
holding: null,
watchlistItem: null,
journalPreview: [],
memo: null
}
});
const now = new Date().toISOString();
sqliteClient.query(`
INSERT INTO company_overview_cache (
user_id,
ticker,
cache_version,
source_signature,
payload,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
TEST_USER_ID,
'CACH',
1,
sourceSignature,
JSON.stringify(buildCachedAnalysisPayload({
ticker: 'CACH',
companyName: 'Cached Corp'
})),
now,
now
);
const cached = await jsonRequest('GET', '/api/analysis/company?ticker=CACH');
expect(cached.response.status).toBe(200);
expect((cached.json as {
analysis: { company: { companyName: string } };
}).analysis.company.companyName).toBe('Cached Corp');
const refreshed = await jsonRequest('GET', '/api/analysis/company?ticker=CACH&refresh=true');
expect(refreshed.response.status).toBe(200);
expect((refreshed.json as {
analysis: { company: { companyName: string } };
}).analysis.company.companyName).toBe('Live Corp');
});
it('invalidates cached analysis when the memo changes', async () => {
if (!sqliteClient) {
throw new Error('sqlite client not initialized');
}
seedFilingRecord(sqliteClient, {
ticker: 'MEMO',
accessionNumber: '0000000000-26-000902',
filingType: '10-K',
filingDate: '2026-02-20',
companyName: 'Memo Corp'
});
sqliteClient.query(`
INSERT INTO research_memo (
user_id,
organization_id,
ticker,
rating,
conviction,
time_horizon_months,
packet_title,
packet_subtitle,
thesis_markdown,
variant_view_markdown,
catalysts_markdown,
risks_markdown,
disconfirming_evidence_markdown,
next_actions_markdown,
created_at,
updated_at
) VALUES (?, NULL, ?, 'buy', 'high', 24, NULL, NULL, ?, '', '', '', '', '', ?, ?)
`).run(
TEST_USER_ID,
'MEMO',
'Legacy thesis still holds.',
'2026-03-13T00:00:00.000Z',
'2026-03-13T00:00:00.000Z'
);
const first = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO');
expect(first.response.status).toBe(200);
expect((first.json as {
analysis: { bullBear: { bull: string[] } };
}).analysis.bullBear.bull.join(' ')).toContain('Legacy thesis');
sqliteClient.query(`
UPDATE research_memo
SET thesis_markdown = ?, updated_at = ?
WHERE user_id = ? AND ticker = ?
`).run(
'Updated thesis drives the next refresh.',
'2026-03-13T01:00:00.000Z',
TEST_USER_ID,
'MEMO'
);
const second = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO');
expect(second.response.status).toBe(200);
expect((second.json as {
analysis: { bullBear: { bull: string[] } };
}).analysis.bullBear.bull.join(' ')).toContain('Updated thesis');
});
it('persists nullable holding company names and allows later enrichment', async () => {
const created = await jsonRequest('POST', '/api/portfolio/holdings', {
ticker: 'ORCL',