finance-dashboard/lib/companyintel-ai.js

313 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// AI 整理公司研究為固定 JSON 結構(產業鏈、簡介、管理層動態、新聞摘要)
import { callAI, extractJSONObject } from './ai-client.js';
import { getCompanyIntelEnriched, saveCompanyIntelEnriched } from './db.js';
import { computeNextPublicRefresh, shouldRunIntelSync, intelRefreshPolicy } from './companyintel-refresh.js';
import { localizeOfficer, sanitizeOfficers } from './companyintel-i18n.js';
import {
mergeNewsIntoChain, finalizeIndustryChain, mergeEnrichedChain, layoutPeersIntoGrid, ensureDownstreamBuyers,
} from './companyintel-chain.js';
const ENRICH_SCHEMA = `{
"profileZh": {
"description": "80-220字繁體中文公司簡介",
"businessModel": "一句話商業模式"
},
"industryChain": {
"upstream": [{ "label": "環節名稱", "entities": ["供應商公司名或代號"], "note": "15字內標 10-K、新聞、AI" }],
"downstream": [{ "label": "客戶類型", "entities": ["購買標的公司產品/服務的公司名或代號"], "note": "15字內說明為何是客戶" }],
"peers": ["同業代號大寫台股如2330.TW"]
},
"managementBrief": [
{ "date": "YYYY-MM-DD", "headline": "標題", "summary": "2-3句繁中", "impact": "positive|neutral|negative", "source": "來源名" }
],
"newsHighlights": [
{ "region": "tw|global", "titleZh": "繁中標題", "summaryZh": "一句摘要", "url": "原文連結", "publisher": "媒體" }
]
}`;
function heuristicChain(symbol, bundle, profile = {}) {
const hints = bundle.hints || {};
const ext = bundle.profileExt || {};
const industry = `${ext.industry || ''} ${ext.sector || profile.industry || ''}`.toLowerCase();
const upstream = (hints.suppliers || []).slice(0, 12).map(s => ({ label: '供應商', entities: [s], note: '10-K' }));
const downstream = (hints.customers || []).slice(0, 12).map(s => ({ label: '購買方10-K', entities: [s], note: 'SEC 10-K' }));
const peers = [...new Set([...(ext.peers || []), ...(hints.competitors || [])])].filter(p => p !== symbol).slice(0, 10);
if (!upstream.length) {
if (/semiconductor|chip/i.test(industry)) {
upstream.push(
{ label: 'EDAIP', entities: ['Synopsys', 'Cadence'], note: '' },
{ label: '晶圓代工', entities: ['TSMC', 'Samsung'], note: '' },
{ label: '封裝測試', entities: ['ASE', 'Amkor'], note: '' },
);
}
}
if (!downstream.length && !/semiconductor|chip/i.test(industry)) {
/* 非半導體不填泛稱下游 */
}
let chain = {
upstream: upstream.length ? upstream : [{ label: '上游供應', entities: ['原物料/設備/服務商'], note: '待查證' }],
downstream: downstream.length ? downstream : [{ label: '下游客戶', entities: ['待查證'], note: '按強制更新由 AI 整理' }],
peers,
};
const news = [...(bundle.newsTw || []), ...(bundle.newsGlobal || [])];
chain = mergeNewsIntoChain(chain, news, symbol);
return ensureDownstreamBuyers(finalizeIndustryChain(chain, symbol), symbol, profile);
}
function fallbackManagementBrief(bundle) {
return (bundle.managementNewsRaw || []).slice(0, 6).map(n => ({
date: n.created || null,
headline: n.titleZh || n.title || '',
summary: (n.descriptionZh || n.description || '').slice(0, 180),
impact: 'neutral',
source: n.publisher || n.source || '',
url: n.url || null,
}));
}
function normalizeEnriched(parsed, symbol, bundle, profile) {
const chain = parsed?.industryChain || heuristicChain(symbol, bundle, profile);
const upDetail = (chain.upstream || []).some(g => g?.entities)
? chain.upstream
: (chain.upstreamDetail || []);
const downDetail = (chain.downstream || []).some(g => g?.entities)
? chain.downstream
: (chain.downstreamDetail || []);
const flatUpstream = upDetail.flatMap(u => (u.entities || []).map(e => String(typeof e === 'object' ? e.name : e)));
const flatDownstream = downDetail.flatMap(d => (d.entities || []).map(e => String(typeof e === 'object' ? e.name : e)));
const mgmt = (parsed?.managementBrief || []).length ? parsed.managementBrief : fallbackManagementBrief(bundle);
return {
profileZh: parsed?.profileZh || {
description: bundle.profileExt?.longBusinessSummary?.slice(0, 400) || bundle.hints?.excerpt?.slice(0, 400) || '',
businessModel: bundle.profileExt?.industry || '',
},
industryChain: finalizeIndustryChain({
upstream: flatUpstream.slice(0, 12),
downstream: flatDownstream.slice(0, 12),
peers: (chain.peers || []).map(s => String(s).toUpperCase()).filter(Boolean).slice(0, 12),
upstreamFlat: flatUpstream.slice(0, 12),
downstreamFlat: flatDownstream.slice(0, 12),
upstreamDetail: upDetail,
downstreamDetail: downDetail,
}, symbol),
managementBrief: mgmt.slice(0, 8).map(m => ({
date: m.date || null,
headline: m.headline || '',
summary: m.summary || '',
impact: m.impact || 'neutral',
source: m.source || '',
url: m.url || null,
})),
newsHighlights: (parsed?.newsHighlights || []).slice(0, 16),
enrichedAt: new Date().toISOString(),
aiUsed: !!parsed?._aiUsed,
provider: parsed?._provider || null,
};
}
function buildForceEnrichPrompt(symbol, profile = {}) {
const name = profile.name || profile.companyName || symbol;
const industry = profile.industry || profile.sector || '';
return [
`【強制更新】標的:${symbol}${name}${industry ? `,產業:${industry}` : ''}`,
'請依固定 JSON 結構輸出(不要 midstream頁面只顯示上游、下游兩欄。',
`upstream25 組供應商;每組 entities 為 26 個具體公司名或股票代號(美股 1-5 字大寫;台股 2330.TW`,
`downstream24 組「誰購買 ${symbol} 的產品或服務」;必須具名客戶(公司名或代號),禁止只寫終端客戶、企業客戶、通路等泛稱;優先採用 10-K customers 與新聞中的買方。`,
/NVDA|AMD/i.test(symbol)
? 'GPU 範例下游DELL、HPE、SMCIAI 伺服器 OEM採購 GPU 組裝再銷售、MSFT、AMZN、GOOGL、META雲端部署同業 AMD 放 peers 勿放 downstream。'
: null,
'peers38 個同業代號。',
'每個 downstream 的 note 用 15 字內說明客戶與標的公司的關係(如雲端採購 GPU、OEM 採購晶片)。',
'資料僅能來自提供的原始摘要;沒有依據則該組 entities 填「待查證」。',
].join('\n');
}
export async function enrichWithAI(symbol, bundle, profile = {}, { force = false } = {}) {
const compact = {
symbol,
sector: bundle.profileExt?.sector,
industry: bundle.profileExt?.industry,
summary: bundle.profileExt?.longBusinessSummary?.slice(0, 1200),
tenK: {
excerpt: bundle.hints?.excerpt?.slice(0, 1500),
customers: bundle.hints?.customers,
suppliers: bundle.hints?.suppliers,
competitors: bundle.hints?.competitors,
},
headlines8k: (bundle.headlines8k || []).slice(0, 6),
managementNews: (bundle.managementNewsRaw || []).slice(0, 8).map(n => ({
title: n.title, publisher: n.publisher, created: n.created, url: n.url,
})),
newsTw: (bundle.newsTw || []).slice(0, 10).map(n => ({
title: n.title, publisher: n.publisher, url: n.url,
summary: (n.description || n.descriptionZh || '').slice(0, 200),
})),
newsGlobal: (bundle.newsGlobal || []).slice(0, 10).map(n => ({
title: n.title, publisher: n.publisher, url: n.url,
summary: (n.description || n.descriptionZh || '').slice(0, 200),
})),
};
const system = force
? [
'你是股票研究資料編輯。只輸出一段合法 JSON不要 markdown不要解釋。',
'產業鏈僅 upstream供應商與 downstream購買標的公司產品服務的客戶不要 midstream。',
'downstream 是本次重點:具名買方公司或代號,結構穩定以利網頁兩欄顯示。',
'managementBrief 3-6 則newsHighlights 6-10 則region 為 tw 或 global。',
].join('\n')
: [
'你是股票研究資料編輯。只輸出一段合法 JSON不要 markdown不要解釋。',
'資料來自公開來源摘要,不可捏造未出現的公司名;不確定處用「待查證」。',
'upstream供應商downstream購買標的公司產品服務的客戶不可只寫泛稱。',
'不要輸出 midstream。entities 盡量用可交易代號note 標 10-K、新聞、AI。',
'managementBrief 只收經營層、治理、策略、併購、指引相關 3-6 則。',
'newsHighlights 從新聞挑選 6-10 則region 為 tw 或 global。',
].join('\n');
const task = force ? buildForceEnrichPrompt(symbol, { ...profile, ...bundle.profileExt }) : `股票代號 ${symbol}。請整理產業鏈與新聞。`;
const user = `${task}\n\nJSON 結構:\n${ENRICH_SCHEMA}\n\n原始資料:\n${JSON.stringify(compact, null, 2)}`;
const ai = await callAI({ system, user, temperature: 0.1 });
if (!ai.ok) {
return { data: normalizeEnriched(null, symbol, bundle, profile), aiError: ai.error };
}
const parsed = extractJSONObject(ai.text);
if (!parsed) {
return { data: normalizeEnriched(null, symbol, bundle, profile), aiError: 'json_parse_failed' };
}
parsed._aiUsed = true;
parsed._provider = ai.providerId;
return { data: normalizeEnriched(parsed, symbol, bundle, profile), aiError: null };
}
export async function syncCompanyIntelEnriched(symbol, profile = {}, { force = false, useAI = true, management = null } = {}) {
symbol = String(symbol || '').trim().toUpperCase();
const existing = getCompanyIntelEnriched(symbol);
const gate = shouldRunIntelSync(existing, { force });
if (!gate.run) {
return {
symbol,
skipped: true,
enriched: existing?.data,
sources: existing?.sources || [],
skipReason: gate.skipReason,
nextRefreshAfter: gate.nextRefreshAfter,
};
}
const { gatherIntelSources } = await import('./companyintel-sources.js');
const bundle = await gatherIntelSources(symbol, profile);
const sources = ['Yahoo', 'SEC 10-K', 'Google 新聞 TW', 'Google 新聞 EN', 'Nasdaq', 'Yahoo Finance'];
let enriched;
let aiError = null;
if (useAI) {
const r = await enrichWithAI(symbol, bundle, profile, { force });
enriched = r.data;
aiError = r.aiError;
if (aiError) sources.push(`AI 略過(${aiError}`);
else sources.push(`AI ${enriched.provider || 'active'}`);
} else {
enriched = normalizeEnriched(null, symbol, bundle, profile);
}
const pub = await computeNextPublicRefresh(symbol);
enriched.rawBundleAt = bundle.gatheredAt;
enriched.sources = sources;
enriched.enrichedAt = new Date().toISOString();
enriched.lastSyncAt = Date.now();
enriched.chainLayout = 'upstream_downstream_v2';
if (force) enriched.forceRefreshAt = enriched.enrichedAt;
enriched.nextRefreshAfter = pub.nextRefreshAfter;
enriched.nextPublicLabel = pub.nextPublicLabel;
enriched.nextPublicDate = pub.nextPublicDate;
if (management?.officers?.length) {
enriched.officers = management.officers;
enriched.managementSource = management.source || null;
}
const newsAll = [...(bundle.newsTw || []), ...(bundle.newsGlobal || [])];
enriched.industryChain = ensureDownstreamBuyers(
layoutPeersIntoGrid(
mergeNewsIntoChain(
finalizeIndustryChain(enriched.industryChain || {}, symbol),
newsAll,
symbol,
),
symbol,
),
symbol,
profile,
);
saveCompanyIntelEnriched(symbol, enriched, sources);
return {
symbol, skipped: false, enriched, bundle, sources, aiError,
nextRefreshAfter: pub.nextRefreshAfter,
nextPublicLabel: pub.nextPublicLabel,
};
}
export function applyEnrichedToIntel(intel, enriched) {
if (!enriched) return intel;
const chain = enriched.industryChain || {};
const newsTw = (intel.newsTw || []).length ? intel.newsTw : (intel.news || []).filter(n => n.region === 'tw');
const newsGlobal = (intel.newsGlobal || []).length ? intel.newsGlobal : (intel.news || []).filter(n => n.region === 'global');
const highlights = enriched.newsHighlights || [];
const hlTw = highlights.filter(h => h.region === 'tw').map(h => ({
title: h.titleZh, titleZh: h.titleZh, descriptionZh: h.summaryZh, description: h.summaryZh,
url: h.url, publisher: h.publisher, region: 'tw', source: 'AI 精選',
}));
const hlGl = highlights.filter(h => h.region === 'global').map(h => ({
title: h.titleZh, titleZh: h.titleZh, descriptionZh: h.summaryZh, description: h.summaryZh,
url: h.url, publisher: h.publisher, region: 'global', source: 'AI 精選',
}));
return {
...intel,
profileZh: enriched.profileZh || intel.profileZh,
industryChain: ensureDownstreamBuyers(
mergeNewsIntoChain(
mergeEnrichedChain(intel.industryChain, enriched.industryChain, intel.symbol),
[...newsTw, ...newsGlobal],
intel.symbol,
),
intel.symbol,
intel.profile || {},
),
managementBrief: enriched.managementBrief || [],
management: (() => {
const off = sanitizeOfficers(enriched.officers);
if (!off.length) return intel.management;
return {
...(intel.management || {}),
officers: off.map(localizeOfficer),
source: enriched.managementSource || intel.management?.source,
};
})(),
newsTw: [...hlTw, ...newsTw].slice(0, 14),
newsGlobal: [...hlGl, ...newsGlobal].slice(0, 14),
news: [...newsTw, ...newsGlobal].slice(0, 20),
enrichedAt: enriched.enrichedAt || (enriched.lastSyncAt ? new Date(enriched.lastSyncAt).toISOString() : null),
enrichSources: enriched.sources || [],
aiEnriched: !!enriched.aiUsed,
chainLayout: enriched.chainLayout || 'upstream_downstream_v2',
nextRefreshAfter: enriched.nextRefreshAfter || null,
nextPublicLabel: enriched.nextPublicLabel || null,
needsSync: false,
};
}
export function attachIntelSyncStatus(intel, symbol) {
const row = getCompanyIntelEnriched(symbol);
const gate = intelRefreshPolicy(row);
return {
...intel,
needsSync: gate.needsSync,
nextRefreshAfter: gate.nextRefreshAfter || intel.nextRefreshAfter,
nextPublicLabel: gate.nextPublicLabel || intel.nextPublicLabel,
syncSkipReason: gate.skipReason,
lastSyncAt: gate.lastSyncAt || intel.enrichedAt,
};
}