// 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, }; }