finance-dashboard/lib/companyintel-ai.js

313 lines
14 KiB
JavaScript
Raw Normal View History

2026-06-04 09:32:28 +00:00
// 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,
};
}