Add company overview skeleton and cache
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user