fi-pool-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +26 -0
  2. package/dist/db/index.d.ts +18 -0
  3. package/dist/db/index.d.ts.map +1 -0
  4. package/dist/db/index.js +58 -0
  5. package/dist/db/index.js.map +1 -0
  6. package/dist/db/migrate.d.ts +11 -0
  7. package/dist/db/migrate.d.ts.map +1 -0
  8. package/dist/db/migrate.js +42 -0
  9. package/dist/db/migrate.js.map +1 -0
  10. package/dist/db/schema.d.ts +1101 -0
  11. package/dist/db/schema.d.ts.map +1 -0
  12. package/dist/db/schema.js +149 -0
  13. package/dist/db/schema.js.map +1 -0
  14. package/dist/index.d.ts +50 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +69 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/services/analysis.d.ts +62 -0
  19. package/dist/services/analysis.d.ts.map +1 -0
  20. package/dist/services/analysis.js +74 -0
  21. package/dist/services/analysis.js.map +1 -0
  22. package/dist/services/daily-info.d.ts +150 -0
  23. package/dist/services/daily-info.d.ts.map +1 -0
  24. package/dist/services/daily-info.js +293 -0
  25. package/dist/services/daily-info.js.map +1 -0
  26. package/dist/services/embedding.d.ts +89 -0
  27. package/dist/services/embedding.d.ts.map +1 -0
  28. package/dist/services/embedding.js +227 -0
  29. package/dist/services/embedding.js.map +1 -0
  30. package/dist/services/llm.d.ts +64 -0
  31. package/dist/services/llm.d.ts.map +1 -0
  32. package/dist/services/llm.js +109 -0
  33. package/dist/services/llm.js.map +1 -0
  34. package/dist/services/pipeline.d.ts +161 -0
  35. package/dist/services/pipeline.d.ts.map +1 -0
  36. package/dist/services/pipeline.js +844 -0
  37. package/dist/services/pipeline.js.map +1 -0
  38. package/dist/services/pool.d.ts +142 -0
  39. package/dist/services/pool.d.ts.map +1 -0
  40. package/dist/services/pool.js +208 -0
  41. package/dist/services/pool.js.map +1 -0
  42. package/dist/services/sentiment.d.ts +31 -0
  43. package/dist/services/sentiment.d.ts.map +1 -0
  44. package/dist/services/sentiment.js +76 -0
  45. package/dist/services/sentiment.js.map +1 -0
  46. package/dist/services/session.d.ts +117 -0
  47. package/dist/services/session.d.ts.map +1 -0
  48. package/dist/services/session.js +176 -0
  49. package/dist/services/session.js.map +1 -0
  50. package/dist/services/stock.d.ts +82 -0
  51. package/dist/services/stock.d.ts.map +1 -0
  52. package/dist/services/stock.js +99 -0
  53. package/dist/services/stock.js.map +1 -0
  54. package/dist/services/word-count.d.ts +61 -0
  55. package/dist/services/word-count.d.ts.map +1 -0
  56. package/dist/services/word-count.js +120 -0
  57. package/dist/services/word-count.js.map +1 -0
  58. package/dist/tools/auxiliary.d.ts +93 -0
  59. package/dist/tools/auxiliary.d.ts.map +1 -0
  60. package/dist/tools/auxiliary.js +204 -0
  61. package/dist/tools/auxiliary.js.map +1 -0
  62. package/dist/tools/command.d.ts +193 -0
  63. package/dist/tools/command.d.ts.map +1 -0
  64. package/dist/tools/command.js +263 -0
  65. package/dist/tools/command.js.map +1 -0
  66. package/dist/tools/execute.d.ts +109 -0
  67. package/dist/tools/execute.d.ts.map +1 -0
  68. package/dist/tools/execute.js +112 -0
  69. package/dist/tools/execute.js.map +1 -0
  70. package/dist/tools/manager.d.ts +150 -0
  71. package/dist/tools/manager.d.ts.map +1 -0
  72. package/dist/tools/manager.js +200 -0
  73. package/dist/tools/manager.js.map +1 -0
  74. package/dist/tools/query.d.ts +163 -0
  75. package/dist/tools/query.d.ts.map +1 -0
  76. package/dist/tools/query.js +190 -0
  77. package/dist/tools/query.js.map +1 -0
  78. package/dist/utils/http-client.d.ts +87 -0
  79. package/dist/utils/http-client.d.ts.map +1 -0
  80. package/dist/utils/http-client.js +211 -0
  81. package/dist/utils/http-client.js.map +1 -0
  82. package/dist/utils/indicators.d.ts +194 -0
  83. package/dist/utils/indicators.d.ts.map +1 -0
  84. package/dist/utils/indicators.js +395 -0
  85. package/dist/utils/indicators.js.map +1 -0
  86. package/dist/utils/signals.d.ts +65 -0
  87. package/dist/utils/signals.d.ts.map +1 -0
  88. package/dist/utils/signals.js +171 -0
  89. package/dist/utils/signals.js.map +1 -0
  90. package/drizzle/0000_equal_marvel_apes.sql +124 -0
  91. package/drizzle/meta/0000_snapshot.json +858 -0
  92. package/drizzle/meta/_journal.json +13 -0
  93. package/package.json +58 -0
@@ -0,0 +1,844 @@
1
+ /**
2
+ * 流水线编排器
3
+ *
4
+ * 将数据获取、LLM 分析、舆情搜索、多角色讨论和最终报告
5
+ * 组装为可执行的个股分析流水线。
6
+ *
7
+ * @module services/pipeline
8
+ */
9
+ // ─── 导入 ────────────────────────────────────────────────────────
10
+ import * as stockService from './stock.js';
11
+ import * as dailyInfoService from './daily-info.js';
12
+ import * as analysisService from './analysis.js';
13
+ import * as llmService from './llm.js';
14
+ import * as sentimentService from './sentiment.js';
15
+ import * as sessionService from './session.js';
16
+ import * as embeddingService from './embedding.js';
17
+ import { getDatabase } from '../db/index.js';
18
+ import { dailyAnalysisReport, sentimentReport, analysisRoler, finalReport, } from '../db/schema.js';
19
+ import { eq, and, desc, sql } from 'drizzle-orm';
20
+ import { countWords } from './word-count.js';
21
+ const ROLES = [
22
+ {
23
+ name: '技术分析师',
24
+ responsibility: '技术指标解读',
25
+ wordLimitRound1: 400,
26
+ wordLimitRound2: 300,
27
+ systemPrompt: '你是一位A股技术分析师。请专注于技术面分析,解读均线、MACD、RSI、KDJ、布林带等指标信号,分析量价关系和趋势形态。不要讨论基本面或消息面。',
28
+ },
29
+ {
30
+ name: '基本面分析师',
31
+ responsibility: '基本面评估',
32
+ wordLimitRound1: 400,
33
+ wordLimitRound2: 300,
34
+ systemPrompt: '你是一位A股基本面分析师。请专注于基本面分析,包括估值水平、财务健康度、行业地位和成长性评估。结合行情数据推断基本面表现。不要讨论技术面或消息面。',
35
+ },
36
+ {
37
+ name: '舆情分析师',
38
+ responsibility: '市场情绪解读',
39
+ wordLimitRound1: 300,
40
+ wordLimitRound2: 200,
41
+ systemPrompt: '你是一位A股舆情分析师。请专注于市场情绪和消息面解读,分析舆情对股价的潜在影响,判断当前市场情绪倾向。',
42
+ },
43
+ {
44
+ name: '风控官',
45
+ responsibility: '风险评估',
46
+ wordLimitRound1: 300,
47
+ wordLimitRound2: 300,
48
+ systemPrompt: '你是一位A股风控官。请全面评估下行风险,关注流动性问题、估值泡沫风险、板块回调风险和个股利空因素,给出风险等级判断。',
49
+ },
50
+ ];
51
+ // ─── 辅助函数 ────────────────────────────────────────────────────
52
+ /**
53
+ * 生成流水线唯一 ID。
54
+ * 格式:{时间戳}-{随机6位hex}
55
+ */
56
+ function generatePipelineId() {
57
+ const ts = Date.now().toString(36);
58
+ const rand = Math.random().toString(16).slice(2, 8);
59
+ return `pipe-${ts}-${rand}`;
60
+ }
61
+ /**
62
+ * 获取今日日期字符串(北京时间,yyyy-MM-dd 格式)。
63
+ */
64
+ function getTodayDate() {
65
+ const now = new Date();
66
+ // 调整为北京时间(UTC+8)
67
+ const local = new Date(now.getTime() + 8 * 60 * 60 * 1000);
68
+ const y = local.getUTCFullYear();
69
+ const m = String(local.getUTCMonth() + 1).padStart(2, '0');
70
+ const d = String(local.getUTCDate()).padStart(2, '0');
71
+ return `${y}-${m}-${d}`;
72
+ }
73
+ /**
74
+ * 获取会话当前消息列表。
75
+ */
76
+ function getSessionMessages(sessionId) {
77
+ const session = sessionService.getSession(sessionId);
78
+ if (!session) {
79
+ throw new Error(`会话不存在: ${sessionId}`);
80
+ }
81
+ return session.messages;
82
+ }
83
+ /**
84
+ * 将 OHLCV 数组格式化为紧凑表格字符串(用于 LLM prompt)。
85
+ *
86
+ * @param data - OHLCV 数组(按日期升序)
87
+ * @param maxRows - 最多显示的行数,不指定则全部显示
88
+ * @returns 格式化字符串
89
+ */
90
+ function formatOHLCVTable(data, maxRows) {
91
+ const rows = maxRows ? data.slice(-maxRows) : data;
92
+ const lines = ['日期|开盘|收盘|最高|最低|成交量(股)'];
93
+ for (const r of rows) {
94
+ lines.push(`${r.date}|${r.open.toFixed(2)}|${r.close.toFixed(2)}|${r.high.toFixed(2)}|${r.low.toFixed(2)}|${r.volume}`);
95
+ }
96
+ return lines.join('\n');
97
+ }
98
+ /**
99
+ * 从 LLM 回复中尝试提取并解析 JSON。
100
+ *
101
+ * 处理以下格式:
102
+ * 1. 纯 JSON 字符串
103
+ * 2. ```json ... ``` 代码块包裹
104
+ * 3. ``` ... ``` 代码块包裹(无 json 标记)
105
+ * 4. 文本中包含 { ... } 对象
106
+ *
107
+ * @param text - LLM 回复文本
108
+ * @returns 解析成功的对象,失败时返回 null
109
+ */
110
+ function parseLLMJsonResponse(text) {
111
+ // 尝试直接解析
112
+ try {
113
+ return JSON.parse(text);
114
+ }
115
+ catch {
116
+ // 忽略
117
+ }
118
+ // 尝试从 markdown 代码块提取
119
+ const blockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
120
+ if (blockMatch) {
121
+ try {
122
+ return JSON.parse(blockMatch[1].trim());
123
+ }
124
+ catch {
125
+ // 忽略
126
+ }
127
+ }
128
+ // 尝试提取第一个 { ... } 对象
129
+ const objectMatch = text.match(/\{[\s\S]*\}/);
130
+ if (objectMatch) {
131
+ try {
132
+ return JSON.parse(objectMatch[0]);
133
+ }
134
+ catch {
135
+ // 忽略
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+ // ─── Prompt 构造 ─────────────────────────────────────────────────
141
+ /**
142
+ * 构造 Stage 2 客观分析 prompt。
143
+ */
144
+ function buildObjectivePrompt(params) {
145
+ const ohlcvTable = formatOHLCVTable(params.dailyData, 60);
146
+ const prevContext = params.prevReports.length > 0
147
+ ? params.prevReports
148
+ .map((r, i) => `[前第 ${i + 1} 日报告]\n摘要: ${r.summary}\n指标: ${r.indicators}`)
149
+ .join('\n\n')
150
+ : '无历史报告';
151
+ return `你是一个客观的A股数据分析师。请基于以下数据生成一份客观分析报告,仅陈述客观事实和数据,不要给出投资建议。
152
+
153
+ [股票信息]
154
+ 代码:${params.code}
155
+ 名称:${params.name}
156
+
157
+ [最近 ${params.dailyData.length} 个交易日行情]
158
+ ${ohlcvTable}
159
+
160
+ [技术指标摘要(最新交易日)]
161
+ - 涨跌幅: ${params.indicators.priceChangePct.toFixed(2)}%
162
+ - 振幅: ${params.indicators.amplitude.toFixed(2)}%
163
+ - MA5: ${params.indicators.ma.ma5 ?? 'N/A'} | MA10: ${params.indicators.ma.ma10 ?? 'N/A'} | MA20: ${params.indicators.ma.ma20 ?? 'N/A'} | MA60: ${params.indicators.ma.ma60 ?? 'N/A'}
164
+ - MACD: DIF=${params.indicators.macd.dif ?? 'N/A'} DEA=${params.indicators.macd.dea ?? 'N/A'} 柱=${params.indicators.macd.histogram ?? 'N/A'}
165
+ - RSI(6): ${params.indicators.rsi.rsi6 ?? 'N/A'} | RSI(14): ${params.indicators.rsi.rsi14 ?? 'N/A'}
166
+ - KDJ: K=${params.indicators.kdj.k ?? 'N/A'} D=${params.indicators.kdj.d ?? 'N/A'} J=${params.indicators.kdj.j ?? 'N/A'}
167
+ - 布林带: 上=${params.indicators.bb.upper ?? 'N/A'} 中=${params.indicators.bb.mid ?? 'N/A'} 下=${params.indicators.bb.lower ?? 'N/A'}
168
+
169
+ [信号检测]
170
+ - 金叉: ${params.signals.goldenCross}
171
+ - 死叉: ${params.signals.deadCross}
172
+ - 超买: ${params.signals.overbought}
173
+ - 超卖: ${params.signals.oversold}
174
+ - 放量: ${params.signals.volumeSpike}
175
+ - 量比: ${params.signals.volumeRatio.toFixed(2)}
176
+
177
+ [历史报告参考]
178
+ ${prevContext}
179
+
180
+ 请按以下JSON格式输出(仅输出JSON,不要额外文字):
181
+ {
182
+ "summary": "简洁的客观数据总结(500字以内,对比前一日指标变化)",
183
+ "indicators": {
184
+ "priceChangePct": 数值,
185
+ "amplitude": 数值,
186
+ "ma": { "ma5": 数值, "ma10": 数值, "ma20": 数值, "ma60": 数值 },
187
+ "macd": { "dif": 数值, "dea": 数值, "histogram": 数值 },
188
+ "rsi": { "rsi6": 数值, "rsi14": 数值 },
189
+ "kdj": { "k": 数值, "d": 数值, "j": 数值 },
190
+ "bb": { "upper": 数值, "mid": 数值, "lower": 数值 }
191
+ },
192
+ "signals": {
193
+ "goldenCross": 布尔,
194
+ "deadCross": 布尔,
195
+ "overbought": 布尔,
196
+ "oversold": 布尔,
197
+ "volumeSpike": 布尔,
198
+ "volumeRatio": 数值
199
+ }
200
+ }`;
201
+ }
202
+ /**
203
+ * 构造 Stage 4 单个角色的 prompt。
204
+ */
205
+ function buildRolePrompt(params) {
206
+ const roundSuffix = params.round === 2
207
+ ? '\n\n如有不同意见,请明确反驳前序发言的观点并说明理由。'
208
+ : '';
209
+ const sentimentSection = params.srReport
210
+ ? `\n[舆情报告]\n${params.srReport}`
211
+ : '';
212
+ return `${params.role.systemPrompt}${roundSuffix}
213
+
214
+ [股票信息]
215
+ 代码:${params.code}
216
+ 名称:${params.name}
217
+
218
+ [客观数据报告]
219
+ 指标: ${params.darIndicators}
220
+ 摘要: ${params.darSummary}${sentimentSection}
221
+
222
+ [前序发言]
223
+ ${params.previousSpeeches || '无'}
224
+
225
+ 请输出你的分析报告。字数限制:${params.round === 1
226
+ ? params.role.wordLimitRound1
227
+ : params.role.wordLimitRound2}字以内。`;
228
+ }
229
+ /**
230
+ * 构造 Stage 5 最终报告 prompt。
231
+ */
232
+ function buildFinalReportPrompt(params) {
233
+ const sentimentSection = params.srReport
234
+ ? `\n[舆情分析]\n${params.srReport}`
235
+ : '';
236
+ return `你是一位资深的A股投资分析师。请综合以下材料,生成一份投资分析报告。
237
+
238
+ [股票信息]
239
+ 代码:${params.code}
240
+ 名称:${params.name}
241
+
242
+ [客观数据报告]
243
+ 指标: ${params.darIndicators}
244
+ 摘要: ${params.darSummary}${sentimentSection}
245
+
246
+ [多角色分析讨论记录]
247
+ ${params.roleDiscussions || '无'}
248
+
249
+ 请生成以下两个部分:
250
+
251
+ 1. 【概述】(200字以内)
252
+ 核心结论、关键信号、主要风险
253
+
254
+ 2. 【完整报告】(不限字数)
255
+ 分为以下章节:
256
+ a. 技术面分析
257
+ b. 基本面分析
258
+ c. 市场情绪分析
259
+ d. 风险提示
260
+ e. 综合判断
261
+
262
+ 请按以下JSON格式输出(仅输出JSON,不要额外文字):
263
+ {
264
+ "summary": "概述内容(200字以内)",
265
+ "fullReport": "完整报告,包含技术面分析、基本面分析、市场情绪分析、风险提示、综合判断等章节",
266
+ "roleSummary": [
267
+ { "role": "技术分析师", "keyPoint": "核心观点" },
268
+ { "role": "基本面分析师", "keyPoint": "核心观点" },
269
+ { "role": "舆情分析师", "keyPoint": "核心观点" },
270
+ { "role": "风控官", "keyPoint": "核心观点" }
271
+ ]
272
+ }`;
273
+ }
274
+ // ─── Pipeline 类 ─────────────────────────────────────────────────
275
+ export class Pipeline {
276
+ code;
277
+ sessionId;
278
+ constructor(code) {
279
+ this.code = code;
280
+ this.sessionId = sessionService.createSession();
281
+ }
282
+ // ─── Stage 1: 数据获取 ──────────────────────────────────────────
283
+ /**
284
+ * Stage 1 — 数据获取
285
+ *
286
+ * 从腾讯财经接口获取最近 60 个交易日数据,
287
+ * 写入 daily_info 表并更新 stock.current_price。
288
+ *
289
+ * @returns 最新交易日日期和记录数
290
+ *
291
+ * @throws 股票不存在或数据获取失败时抛出
292
+ */
293
+ async stage1FetchData() {
294
+ // 验证股票存在
295
+ const stockInfo = await stockService.getStockByCode(this.code);
296
+ if (!stockInfo) {
297
+ throw new Error(`股票 ${this.code} 不存在,请先添加该股票`);
298
+ }
299
+ // 从腾讯财经获取 K 线数据
300
+ const ohlcvData = await dailyInfoService.fetchFromTencent(this.code);
301
+ if (!ohlcvData || ohlcvData.length === 0) {
302
+ throw new Error(`获取股票 ${this.code} 行情数据失败: 返回空数据`);
303
+ }
304
+ // 构造 upsert 记录
305
+ const records = ohlcvData.map((o) => ({
306
+ code: this.code,
307
+ date: o.date,
308
+ open: o.open,
309
+ high: o.high,
310
+ low: o.low,
311
+ close: o.close,
312
+ volume: o.volume,
313
+ }));
314
+ // 写入 daily_info 表
315
+ const count = await dailyInfoService.upsertDailyInfo(records);
316
+ // 更新股票最新价格
317
+ const latest = ohlcvData[ohlcvData.length - 1];
318
+ await stockService.upsertStock(this.code, stockInfo.name, latest.close);
319
+ return {
320
+ date: latest.date,
321
+ records: count,
322
+ };
323
+ }
324
+ // ─── Stage 2: 客观报告(LLM)────────────────────────────────────
325
+ /**
326
+ * Stage 2 — 客观报告(LLM)
327
+ *
328
+ * 基于最近 60 个交易日的行情数据和技术指标,
329
+ * 调用本地 LLM 生成客观分析报告。
330
+ *
331
+ * 报告包含结构化技术指标和文字摘要,
332
+ * 写入 daily_analysis_report 表并生成向量嵌入。
333
+ *
334
+ * @param date - 报告日期(yyyy-MM-dd)
335
+ * @returns 创建的客观报告信息
336
+ */
337
+ async stage2ObjectiveReport(date) {
338
+ const db = getDatabase();
339
+ const { code, sessionId } = this;
340
+ // 1. 获取股票信息
341
+ const stockInfo = await stockService.getStockByCode(code);
342
+ if (!stockInfo) {
343
+ throw new Error(`股票 ${code} 不存在`);
344
+ }
345
+ // 2. 获取日行情数据(最近 60 个交易日)
346
+ const allDailyData = await dailyInfoService.getDailyInfo(code);
347
+ if (allDailyData.length < 5) {
348
+ throw new Error(`股票 ${code} 行情数据不足 (${allDailyData.length} 天,至少需要 5 天)`);
349
+ }
350
+ const recentData = allDailyData.slice(-60);
351
+ const ohlcvData = recentData.map((d) => ({
352
+ date: d.date,
353
+ open: d.open,
354
+ high: d.high,
355
+ low: d.low,
356
+ close: d.close,
357
+ volume: d.volume,
358
+ }));
359
+ // 3. 获取前 3 个交易日的分析报告
360
+ const prevReports = db
361
+ .select({
362
+ summary: dailyAnalysisReport.summary,
363
+ indicators: dailyAnalysisReport.indicators,
364
+ })
365
+ .from(dailyAnalysisReport)
366
+ .where(and(eq(dailyAnalysisReport.code, code), sql `${dailyAnalysisReport.date} < ${date}`))
367
+ .orderBy(desc(dailyAnalysisReport.date))
368
+ .limit(3)
369
+ .all();
370
+ // 4. 计算技术指标和信号
371
+ const indicators = analysisService.computeIndicators(ohlcvData);
372
+ const signals = analysisService.computeSignals(ohlcvData);
373
+ // 5. 构造 prompt 并调用 LLM
374
+ const prompt = buildObjectivePrompt({
375
+ code,
376
+ name: stockInfo.name,
377
+ dailyData: ohlcvData,
378
+ prevReports,
379
+ indicators,
380
+ signals,
381
+ });
382
+ // 追加到 session
383
+ sessionService.appendMessage(sessionId, 'system', '你是一个客观的A股数据分析师。请基于数据生成客观分析报告,不要给出投资建议。');
384
+ sessionService.appendMessage(sessionId, 'user', prompt);
385
+ // 调用 LLM
386
+ const llmResponse = await llmService.chatCompletion({
387
+ messages: getSessionMessages(sessionId),
388
+ maxTokens: 1500,
389
+ temperature: 0.3,
390
+ sessionId,
391
+ });
392
+ sessionService.appendMessage(sessionId, 'assistant', llmResponse);
393
+ // 6. 解析 LLM 回复
394
+ let summary = llmResponse;
395
+ let indicatorsStr = JSON.stringify(indicators);
396
+ let signalsStr = JSON.stringify(signals);
397
+ const parsed = parseLLMJsonResponse(llmResponse);
398
+ if (parsed) {
399
+ if (parsed.summary)
400
+ summary = parsed.summary;
401
+ if (parsed.indicators)
402
+ indicatorsStr = JSON.stringify(parsed.indicators);
403
+ if (parsed.signals)
404
+ signalsStr = JSON.stringify(parsed.signals);
405
+ }
406
+ // 7. 写入 daily_analysis_report 表
407
+ db.insert(dailyAnalysisReport)
408
+ .values({
409
+ code,
410
+ date,
411
+ summary,
412
+ indicators: indicatorsStr,
413
+ signals: signalsStr,
414
+ })
415
+ .onConflictDoUpdate({
416
+ target: [dailyAnalysisReport.code, dailyAnalysisReport.date],
417
+ set: {
418
+ summary,
419
+ indicators: indicatorsStr,
420
+ signals: signalsStr,
421
+ },
422
+ })
423
+ .run();
424
+ const inserted = db
425
+ .select()
426
+ .from(dailyAnalysisReport)
427
+ .where(and(eq(dailyAnalysisReport.code, code), eq(dailyAnalysisReport.date, date)))
428
+ .get();
429
+ // 8. 向量化并存储嵌入
430
+ try {
431
+ const embeddingText = `[${code} ${date}] ${summary}`;
432
+ const embedding = await embeddingService.getEmbedding(embeddingText);
433
+ // 先清理旧的 analysis 类型向量
434
+ await embeddingService.deleteEmbeddings(code, 'analysis');
435
+ await embeddingService.storeEmbedding({
436
+ contentType: 'analysis',
437
+ contentCode: code,
438
+ contentDate: date,
439
+ contentText: summary,
440
+ embedding,
441
+ });
442
+ }
443
+ catch (err) {
444
+ console.warn(`[pipeline] 向量化失败 (${code} ${date}):`, err.message);
445
+ }
446
+ return {
447
+ id: inserted.id,
448
+ summary,
449
+ indicators: indicatorsStr,
450
+ signals: signalsStr,
451
+ };
452
+ }
453
+ // ─── Stage 3: 舆情获取 ──────────────────────────────────────────
454
+ /**
455
+ * Stage 3 — 舆情获取
456
+ *
457
+ * 调用 DashScope API 搜索股票最近三天的新闻和市场舆情,
458
+ * 将结果写入 sentiment_report 表。
459
+ *
460
+ * @param date - 报告日期(yyyy-MM-dd)
461
+ * @returns 舆情报告信息
462
+ */
463
+ async stage3Sentiment(date) {
464
+ const db = getDatabase();
465
+ const code = this.code;
466
+ // 获取股票名称
467
+ const stockInfo = await stockService.getStockByCode(code);
468
+ const name = stockInfo?.name || code;
469
+ // 调用舆情搜索
470
+ const { report, sources } = await sentimentService.fetchSentiment(code, name);
471
+ // 写入 sentiment_report 表
472
+ db.insert(sentimentReport)
473
+ .values({
474
+ code,
475
+ date,
476
+ report,
477
+ sources: JSON.stringify(sources),
478
+ })
479
+ .onConflictDoUpdate({
480
+ target: [sentimentReport.code, sentimentReport.date],
481
+ set: {
482
+ report,
483
+ sources: JSON.stringify(sources),
484
+ },
485
+ })
486
+ .run();
487
+ const inserted = db
488
+ .select()
489
+ .from(sentimentReport)
490
+ .where(and(eq(sentimentReport.code, code), eq(sentimentReport.date, date)))
491
+ .get();
492
+ return {
493
+ id: inserted.id,
494
+ report,
495
+ sources,
496
+ };
497
+ }
498
+ // ─── Stage 4: 多角色分析 ────────────────────────────────────────
499
+ /**
500
+ * Stage 4 — 多角色分析
501
+ *
502
+ * 4 个角色按顺序发言,每轮每人聚焦各自专业领域。
503
+ * 第一轮(必须):技术分析师 → 基本面分析师 → 舆情分析师 → 风控官
504
+ * 第二轮(可选):风控官 → 舆情分析师 → 基本面分析师 → 技术分析师(回应分歧)
505
+ *
506
+ * 每个角色的发言写入 analysis_roler 表。
507
+ *
508
+ * @param date - 分析日期
509
+ * @param darId - 客观报告 ID
510
+ * @param srId - 舆情报告 ID(可为 null)
511
+ * @returns 所有角色发言记录
512
+ */
513
+ async stage4MultiRole(date, darId, srId) {
514
+ const db = getDatabase();
515
+ const { code, sessionId } = this;
516
+ // 1. 读取客观报告和舆情报告
517
+ const dar = db
518
+ .select()
519
+ .from(dailyAnalysisReport)
520
+ .where(eq(dailyAnalysisReport.id, darId))
521
+ .get();
522
+ if (!dar) {
523
+ throw new Error(`客观报告不存在 (id=${darId})`);
524
+ }
525
+ const darNonNull = dar;
526
+ const sr = srId
527
+ ? db
528
+ .select()
529
+ .from(sentimentReport)
530
+ .where(eq(sentimentReport.id, srId))
531
+ .get()
532
+ : null;
533
+ // 2. 获取股票名称
534
+ const stockInfo = await stockService.getStockByCode(code);
535
+ const name = stockInfo?.name || code;
536
+ const allRoleRecords = [];
537
+ /**
538
+ * 执行一轮角色发言。
539
+ *
540
+ * @param roleOrder - 角色数组(发言顺序)
541
+ * @param round - 轮次编号
542
+ * @param allRound1Speeches - 第一轮所有发言(仅第二轮需要)
543
+ */
544
+ async function runRound(roleOrder, round, allRound1Speeches) {
545
+ // 收集本轮之前的发言,用于构造上下文
546
+ const previousSpeeches = [];
547
+ for (const role of roleOrder) {
548
+ // 构造本轮上下文(不包括当前角色的发言)
549
+ const previousContext = allRound1Speeches && round === 2
550
+ ? allRound1Speeches
551
+ : previousSpeeches.join('\n\n---\n\n');
552
+ const prompt = buildRolePrompt({
553
+ role,
554
+ code,
555
+ name,
556
+ darSummary: darNonNull.summary,
557
+ darIndicators: darNonNull.indicators,
558
+ srReport: sr?.report,
559
+ previousSpeeches: previousContext,
560
+ round,
561
+ });
562
+ // 添加到 session
563
+ sessionService.appendMessage(sessionId, 'system', role.systemPrompt);
564
+ sessionService.appendMessage(sessionId, 'user', prompt);
565
+ // 调用 LLM
566
+ const response = await llmService.chatCompletion({
567
+ messages: getSessionMessages(sessionId),
568
+ maxTokens: round === 1 ? role.wordLimitRound1 + 200 : role.wordLimitRound2 + 200,
569
+ temperature: 0.5,
570
+ sessionId,
571
+ });
572
+ sessionService.appendMessage(sessionId, 'assistant', response);
573
+ // 统计字数并写入数据库
574
+ const wordCount = countWords(response);
575
+ db.insert(analysisRoler)
576
+ .values({
577
+ code,
578
+ date,
579
+ role: role.name,
580
+ responsibility: role.responsibility,
581
+ report: response,
582
+ round,
583
+ wordCount,
584
+ })
585
+ .run();
586
+ const inserted = db
587
+ .select()
588
+ .from(analysisRoler)
589
+ .where(and(eq(analysisRoler.code, code), eq(analysisRoler.date, date), eq(analysisRoler.role, role.name), eq(analysisRoler.round, round)))
590
+ .orderBy(desc(analysisRoler.id))
591
+ .limit(1)
592
+ .all();
593
+ if (inserted.length > 0) {
594
+ allRoleRecords.push({
595
+ id: inserted[0].id,
596
+ role: role.name,
597
+ report: response,
598
+ round,
599
+ wordCount,
600
+ });
601
+ }
602
+ previousSpeeches.push(`【${role.name}】\n${response}`);
603
+ }
604
+ }
605
+ // 第一轮:按 1→2→3→4 顺序
606
+ await runRound(ROLES, 1);
607
+ // 第二轮(可选):收集第一轮所有内容作为上下文,按 4→3→2→1 顺序
608
+ const round1Speeches = allRoleRecords
609
+ .filter((r) => r.round === 1)
610
+ .map((r) => `【${r.role}】\n${r.report}`)
611
+ .join('\n\n---\n\n');
612
+ const reversedRoles = [...ROLES].reverse();
613
+ await runRound(reversedRoles, 2, round1Speeches);
614
+ return { roles: allRoleRecords };
615
+ }
616
+ // ─── Stage 5: 最终报告 ──────────────────────────────────────────
617
+ /**
618
+ * Stage 5 — 最终报告
619
+ *
620
+ * 综合客观报告、舆情报告和多角色分析结果,
621
+ * 调用 LLM 生成包含概述(overview)和完整报告(full)的最终报告。
622
+ *
623
+ * 写入 final_report 表并生成向量嵌入。
624
+ *
625
+ * @param date - 报告日期
626
+ * @returns 最终报告信息
627
+ */
628
+ async stage5FinalReport(date) {
629
+ const db = getDatabase();
630
+ const { code, sessionId } = this;
631
+ // 1. 获取股票信息
632
+ const stockInfo = await stockService.getStockByCode(code);
633
+ const name = stockInfo?.name || code;
634
+ // 2. 收集客观报告
635
+ const dar = db
636
+ .select()
637
+ .from(dailyAnalysisReport)
638
+ .where(and(eq(dailyAnalysisReport.code, code), eq(dailyAnalysisReport.date, date)))
639
+ .get();
640
+ // 3. 收集舆情报告
641
+ const sr = db
642
+ .select()
643
+ .from(sentimentReport)
644
+ .where(and(eq(sentimentReport.code, code), eq(sentimentReport.date, date)))
645
+ .get();
646
+ // 4. 收集多角色发言
647
+ const roleRecords = db
648
+ .select()
649
+ .from(analysisRoler)
650
+ .where(and(eq(analysisRoler.code, code), eq(analysisRoler.date, date)))
651
+ .orderBy(analysisRoler.round, analysisRoler.id)
652
+ .all();
653
+ // 5. 编排角色讨论文本
654
+ const roleDiscussions = roleRecords
655
+ .map((r) => `[第${r.round}轮 ${r.role}](${r.responsibility})\n${r.report}`)
656
+ .join('\n\n---\n\n');
657
+ // 6. 构造 prompt 并调用 LLM
658
+ const pipelineId = generatePipelineId();
659
+ const prompt = buildFinalReportPrompt({
660
+ code,
661
+ name,
662
+ darSummary: dar?.summary || '无客观报告',
663
+ darIndicators: dar?.indicators || '{}',
664
+ srReport: sr?.report,
665
+ roleDiscussions,
666
+ });
667
+ sessionService.appendMessage(sessionId, 'system', '你是一位资深的A股投资分析师。请综合所有材料生成投资分析报告。');
668
+ sessionService.appendMessage(sessionId, 'user', prompt);
669
+ const llmResponse = await llmService.chatCompletion({
670
+ messages: getSessionMessages(sessionId),
671
+ maxTokens: 3000,
672
+ temperature: 0.5,
673
+ sessionId,
674
+ });
675
+ sessionService.appendMessage(sessionId, 'assistant', llmResponse);
676
+ // 7. 解析 LLM 回复
677
+ let summary = llmResponse.slice(0, 500);
678
+ let fullReport = llmResponse;
679
+ let roleSummary = '[]';
680
+ const parsed = parseLLMJsonResponse(llmResponse);
681
+ if (parsed) {
682
+ if (parsed.summary)
683
+ summary = parsed.summary;
684
+ if (parsed.fullReport)
685
+ fullReport = parsed.fullReport;
686
+ if (parsed.roleSummary)
687
+ roleSummary = JSON.stringify(parsed.roleSummary);
688
+ }
689
+ // 8. 写入 final_report 表
690
+ db.insert(finalReport)
691
+ .values({
692
+ code,
693
+ date,
694
+ summary,
695
+ fullReport,
696
+ roleSummary,
697
+ pipelineId,
698
+ })
699
+ .onConflictDoUpdate({
700
+ target: [finalReport.code, finalReport.date],
701
+ set: {
702
+ summary,
703
+ fullReport,
704
+ roleSummary,
705
+ pipelineId,
706
+ },
707
+ })
708
+ .run();
709
+ const inserted = db
710
+ .select()
711
+ .from(finalReport)
712
+ .where(and(eq(finalReport.code, code), eq(finalReport.date, date)))
713
+ .get();
714
+ // 9. 向量化并存储嵌入
715
+ try {
716
+ const embeddingText = `[${code} ${date} 最终报告] ${summary}`;
717
+ const embedding = await embeddingService.getEmbedding(embeddingText);
718
+ // 先清理旧的 final 类型向量
719
+ await embeddingService.deleteEmbeddings(code, 'final');
720
+ await embeddingService.storeEmbedding({
721
+ contentType: 'final',
722
+ contentCode: code,
723
+ contentDate: date,
724
+ contentText: fullReport.slice(0, 500),
725
+ embedding,
726
+ });
727
+ }
728
+ catch (err) {
729
+ console.warn(`[pipeline] 最终报告向量化失败 (${code} ${date}):`, err.message);
730
+ }
731
+ return {
732
+ id: inserted.id,
733
+ summary,
734
+ fullReport,
735
+ roleSummary,
736
+ pipelineId,
737
+ };
738
+ }
739
+ // ─── Orchestrators ───────────────────────────────────────────────
740
+ /**
741
+ * 运行完整流水线(Stage 1→5)。
742
+ *
743
+ * 依次执行数据获取、客观报告、
744
+ * 舆情获取、多角色分析和最终报告。
745
+ *
746
+ * @returns 流水线运行结果(日期、流水线 ID、最终报告 ID)
747
+ *
748
+ * @example
749
+ * const result = await new Pipeline('600519').runFull();
750
+ * console.log(result.date, result.pipelineId);
751
+ */
752
+ async runFull() {
753
+ const code = this.code;
754
+ console.log(`[pipeline] 开始全流水线: ${code} (session=${this.sessionId})`);
755
+ // Stage 1: 数据获取
756
+ console.log(`[pipeline] Stage 1/5 — 数据获取: ${code}`);
757
+ const stage1 = await this.stage1FetchData();
758
+ const date = stage1.date;
759
+ console.log(`[pipeline] Stage 1 完成: date=${date}, records=${stage1.records}`);
760
+ // Stage 2: 客观报告
761
+ console.log(`[pipeline] Stage 2/5 — 客观报告: ${code}`);
762
+ const stage2 = await this.stage2ObjectiveReport(date);
763
+ console.log(`[pipeline] Stage 2 完成: id=${stage2.id}`);
764
+ // Stage 3: 舆情获取
765
+ console.log(`[pipeline] Stage 3/5 — 舆情获取: ${code}`);
766
+ const stage3 = await this.stage3Sentiment(date);
767
+ console.log(`[pipeline] Stage 3 完成: id=${stage3.id}`);
768
+ // Stage 4: 多角色分析
769
+ console.log(`[pipeline] Stage 4/5 — 多角色分析: ${code}`);
770
+ const stage4 = await this.stage4MultiRole(date, stage2.id, stage3.id);
771
+ console.log(`[pipeline] Stage 4 完成: ${stage4.roles.length} 条发言`);
772
+ // Stage 5: 最终报告
773
+ console.log(`[pipeline] Stage 5/5 — 最终报告: ${code}`);
774
+ const stage5 = await this.stage5FinalReport(date);
775
+ console.log(`[pipeline] Stage 5 完成: id=${stage5.id}, pipelineId=${stage5.pipelineId}`);
776
+ console.log(`[pipeline] 全流水线完成: ${code} ${date}`);
777
+ return {
778
+ date,
779
+ pipelineId: stage5.pipelineId,
780
+ finalReportId: stage5.id,
781
+ };
782
+ }
783
+ /**
784
+ * 运行本地分析(Stage 1→2)。
785
+ *
786
+ * 仅执行数据获取和客观分析报告生成,跳过舆情和多角色环节。
787
+ *
788
+ * @returns 分析结果(日期和报告 ID)
789
+ *
790
+ * @example
791
+ * const result = await new Pipeline('600519').runLocalAnalysis();
792
+ * console.log(result.date, result.analysisReportId);
793
+ */
794
+ async runLocalAnalysis() {
795
+ const code = this.code;
796
+ console.log(`[pipeline] 开始本地分析: ${code} (session=${this.sessionId})`);
797
+ // Stage 1: 数据获取
798
+ console.log(`[pipeline] Stage 1/2 — 数据获取: ${code}`);
799
+ const stage1 = await this.stage1FetchData();
800
+ const date = stage1.date;
801
+ console.log(`[pipeline] Stage 1 完成: date=${date}, records=${stage1.records}`);
802
+ // Stage 2: 客观报告
803
+ console.log(`[pipeline] Stage 2/2 — 客观报告: ${code}`);
804
+ const stage2 = await this.stage2ObjectiveReport(date);
805
+ console.log(`[pipeline] Stage 2 完成: id=${stage2.id}`);
806
+ console.log(`[pipeline] 本地分析完成: ${code} ${date}`);
807
+ return {
808
+ date,
809
+ analysisReportId: stage2.id,
810
+ };
811
+ }
812
+ }
813
+ // ─── 向后兼容包装函数 ────────────────────────────────────────────
814
+ /**
815
+ * Stage 1 — 数据获取(向后兼容包装)
816
+ *
817
+ * @param code - 六位股票代码
818
+ * @returns 最新交易日日期和记录数
819
+ */
820
+ export async function stage1FetchData(code) {
821
+ const pipeline = new Pipeline(code);
822
+ return pipeline.stage1FetchData();
823
+ }
824
+ /**
825
+ * 运行完整流水线(Stage 1→5,向后兼容包装)。
826
+ *
827
+ * @param code - 股票代码
828
+ * @returns 流水线运行结果(日期、流水线 ID、最终报告 ID)
829
+ */
830
+ export async function runFullPipeline(code) {
831
+ const pipeline = new Pipeline(code);
832
+ return pipeline.runFull();
833
+ }
834
+ /**
835
+ * 运行本地分析(Stage 1→2,向后兼容包装)。
836
+ *
837
+ * @param code - 股票代码
838
+ * @returns 分析结果(日期和报告 ID)
839
+ */
840
+ export async function runLocalAnalysis(code) {
841
+ const pipeline = new Pipeline(code);
842
+ return pipeline.runLocalAnalysis();
843
+ }
844
+ //# sourceMappingURL=pipeline.js.map