313 lines
14 KiB
JavaScript
313 lines
14 KiB
JavaScript
|
|
// 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: 'EDA/IP', 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;頁面只顯示上游、下游兩欄)。',
|
|||
|
|
`upstream:2~5 組供應商;每組 entities 為 2~6 個具體公司名或股票代號(美股 1-5 字大寫;台股 2330.TW)。`,
|
|||
|
|
`downstream:2~4 組「誰購買 ${symbol} 的產品或服務」;必須具名客戶(公司名或代號),禁止只寫終端客戶、企業客戶、通路等泛稱;優先採用 10-K customers 與新聞中的買方。`,
|
|||
|
|
/NVDA|AMD/i.test(symbol)
|
|||
|
|
? 'GPU 範例下游:DELL、HPE、SMCI(AI 伺服器 OEM,採購 GPU 組裝再銷售)、MSFT、AMZN、GOOGL、META(雲端部署);同業 AMD 放 peers 勿放 downstream。'
|
|||
|
|
: null,
|
|||
|
|
'peers:3~8 個同業代號。',
|
|||
|
|
'每個 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,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|