Add company overview skeleton and cache
This commit is contained in:
@@ -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