@hedgehog-finance/hedgehog-plugin 1.0.11 → 1.0.13

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 (45) hide show
  1. package/dist/index.d.ts +10 -0
  2. package/dist/index.js +24 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/src/channel.d.ts +6 -0
  5. package/dist/src/channel.js +620 -0
  6. package/dist/src/channel.js.map +1 -0
  7. package/dist/src/core/database.d.ts +2 -0
  8. package/dist/src/core/database.js +220 -0
  9. package/dist/src/core/database.js.map +1 -0
  10. package/dist/src/core/logger.d.ts +3 -0
  11. package/dist/src/core/logger.js +20 -0
  12. package/dist/src/core/logger.js.map +1 -0
  13. package/dist/src/features/index.d.ts +22 -0
  14. package/dist/src/features/index.js +8 -0
  15. package/dist/src/features/index.js.map +1 -0
  16. package/dist/src/features/watchlist/logic.d.ts +48 -0
  17. package/dist/src/features/watchlist/logic.js +607 -0
  18. package/dist/src/features/watchlist/logic.js.map +1 -0
  19. package/dist/src/features/watchlist/schema.d.ts +85 -0
  20. package/dist/src/features/watchlist/schema.js +29 -0
  21. package/dist/src/features/watchlist/schema.js.map +1 -0
  22. package/dist/src/features/watchlist/store.d.ts +1 -0
  23. package/dist/src/features/watchlist/store.js +2 -0
  24. package/dist/src/features/watchlist/store.js.map +1 -0
  25. package/dist/src/features/watchlist/tools.d.ts +135 -0
  26. package/dist/src/features/watchlist/tools.js +572 -0
  27. package/dist/src/features/watchlist/tools.js.map +1 -0
  28. package/dist/src/runtime.d.ts +5 -0
  29. package/dist/src/runtime.js +40 -0
  30. package/dist/src/runtime.js.map +1 -0
  31. package/dist/src/types.d.ts +99 -0
  32. package/dist/src/types.js +16 -0
  33. package/dist/src/types.js.map +1 -0
  34. package/index.ts +5 -5
  35. package/openclaw.plugin.json +2 -2
  36. package/package.json +24 -7
  37. package/src/channel.ts +35 -13
  38. package/src/core/database.ts +90 -3
  39. package/src/features/index.ts +2 -1
  40. package/src/features/watchlist/logic.ts +503 -128
  41. package/src/features/watchlist/schema.ts +1 -6
  42. package/src/features/watchlist/tools.ts +248 -103
  43. package/src/runtime.ts +3 -3
  44. package/src/types.ts +1 -1
  45. package/tsconfig.json +0 -16
@@ -1,8 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import path from "node:path";
2
+ import { completeSimple } from "@mariozechner/pi-ai";
3
3
  import { PluginRuntime } from "openclaw/plugin-sdk";
4
- import { getDB } from "../../core/database";
5
- import { StockClassification, StockClassificationSchema } from "../../types";
4
+ import {
5
+ extractAssistantText,
6
+ prepareSimpleCompletionModel
7
+ } from "openclaw/plugin-sdk/simple-completion-runtime";
8
+ import { getDB } from "../../core/database.js";
9
+ import { logger } from "../../core/logger.js";
10
+ import { StockClassification, StockClassificationSchema } from "../../types.js";
6
11
 
7
12
  interface GlobalStockMetadataRow {
8
13
  industryJson: string;
@@ -11,10 +16,190 @@ interface GlobalStockMetadataRow {
11
16
  lastUpdated: string;
12
17
  }
13
18
 
19
+ const MAIN_AGENT_ID = "hedgehog-finance";
20
+ const SINGLE_CLASSIFICATION_TIMEOUT_MS = 180000;
21
+ const SMART_SORT_TIMEOUT_MS = 180000;
22
+ const BATCH_CLASSIFICATION_BASE_TIMEOUT_MS = 300000;
23
+ const BATCH_CLASSIFICATION_PER_STOCK_TIMEOUT_MS = 30000;
24
+ const BATCH_CLASSIFICATION_MAX_TIMEOUT_MS = 600000;
25
+ const CLASSIFIER_OUTPUT_BASE_TOKENS = 1000;
26
+ const CLASSIFIER_OUTPUT_PER_STOCK_TOKENS = 260;
27
+ const CLASSIFIER_OUTPUT_MAX_TOKENS = 4096;
28
+ const CLASSIFIER_SYSTEM_PROMPT = [
29
+ "你是一个股票行业/主题分类器,只能完成当前 JSON 分类任务。",
30
+ "禁止检查、加载、调用或提及任何技能、工具、外部数据源或工作区文件。",
31
+ "禁止输出推理过程、解释、Markdown 或代码块。",
32
+ "只允许根据用户消息中提供的行业/主题分类字典和股票列表输出纯 JSON 数组。"
33
+ ].join("\n");
34
+
35
+ function nowMs(): number {
36
+ return Date.now();
37
+ }
38
+
39
+ function resolveModelRef(modelConfig: unknown): string | undefined {
40
+ if (typeof modelConfig === "string" && modelConfig.trim()) return modelConfig.trim();
41
+ if (!modelConfig || typeof modelConfig !== "object" || Array.isArray(modelConfig)) return undefined;
42
+ const primary = (modelConfig as { primary?: unknown }).primary;
43
+ return typeof primary === "string" && primary.trim() ? primary.trim() : undefined;
44
+ }
45
+
46
+ function getConfiguredProviderConfig(cfg: any, provider: string): any | undefined {
47
+ const providerConfigs = cfg.models?.providers;
48
+ if (!providerConfigs) return undefined;
49
+ const exact = providerConfigs[provider];
50
+ if (exact) return exact;
51
+ const normalized = provider.trim().toLowerCase();
52
+ return Object.entries(providerConfigs).find(([key]) => key.trim().toLowerCase() === normalized)?.[1];
53
+ }
54
+
55
+ function resolveClassifierModelSelection(
56
+ cfg: any,
57
+ defaultProvider: string,
58
+ defaultModel: string
59
+ ): { provider: string; model: string } {
60
+ const agentEntry = ((cfg.agents?.list || []) as any[]).find((agent) => agent?.id === MAIN_AGENT_ID);
61
+ const primary = resolveModelRef(agentEntry?.model)
62
+ || resolveModelRef(cfg.agents?.defaults?.model)
63
+ || `${defaultProvider}/${defaultModel}`;
64
+ const slash = primary.indexOf("/");
65
+ if (slash > 0) {
66
+ return {
67
+ provider: primary.slice(0, slash),
68
+ model: primary.slice(slash + 1)
69
+ };
70
+ }
71
+ return {
72
+ provider: defaultProvider,
73
+ model: primary
74
+ };
75
+ }
76
+
77
+ function normalizeStockCodeForCache(stockCode: string, exchange?: string): string {
78
+ const code = String(stockCode || "")
79
+ .trim()
80
+ .toUpperCase();
81
+ if (/\.(SH|SS|SZ|HK|US)$/i.test(code)) {
82
+ return code.replace(/\.SS$/i, ".SH");
83
+ }
84
+ switch (exchange) {
85
+ case "SSE":
86
+ return `${code}.SH`;
87
+ case "SZSE":
88
+ return `${code}.SZ`;
89
+ case "HKEX":
90
+ return `${code}.HK`;
91
+ default:
92
+ return code;
93
+ }
94
+ }
95
+
96
+ function legacyStockCodeWithoutSuffix(stockCode: string): string {
97
+ return String(stockCode || "")
98
+ .trim()
99
+ .toUpperCase()
100
+ .replace(/\.(SH|SS|SZ|HK|US)$/i, "");
101
+ }
102
+
103
+ function getCachedClassificationRow(db: any, stockCode: string, exchange: string): GlobalStockMetadataRow | undefined {
104
+ const cacheCode = normalizeStockCodeForCache(stockCode, exchange);
105
+ const legacyCode = legacyStockCodeWithoutSuffix(stockCode);
106
+ const stmt = db.prepare(`
107
+ SELECT industryJson, themeJson FROM global_stock_metadata
108
+ WHERE stockCode = ? AND exchange = ?
109
+ `);
110
+ return (stmt.get(cacheCode, exchange) || (legacyCode !== cacheCode ? stmt.get(legacyCode, exchange) : undefined)) as GlobalStockMetadataRow | undefined;
111
+ }
112
+
113
+ function resolveBatchClassificationTimeoutMs(stockCount: number): number {
114
+ return Math.min(
115
+ BATCH_CLASSIFICATION_MAX_TIMEOUT_MS,
116
+ BATCH_CLASSIFICATION_BASE_TIMEOUT_MS + Math.max(0, stockCount - 1) * BATCH_CLASSIFICATION_PER_STOCK_TIMEOUT_MS
117
+ );
118
+ }
119
+
120
+ function estimateClassifierStockCount(prompt: string): number {
121
+ const codeMatches = prompt.match(/\b\d{6}\.(?:SH|SS|SZ|HK|US)\b/gi);
122
+ return Math.max(1, codeMatches?.length ?? 1);
123
+ }
124
+
125
+ function resolveClassifierOutputMaxTokens(prompt: string): number {
126
+ return Math.min(
127
+ CLASSIFIER_OUTPUT_MAX_TOKENS,
128
+ CLASSIFIER_OUTPUT_BASE_TOKENS + estimateClassifierStockCount(prompt) * CLASSIFIER_OUTPUT_PER_STOCK_TOKENS
129
+ );
130
+ }
131
+
132
+ function normalizeCategoryMatchKey(value: string): string {
133
+ return String(value || "")
134
+ .trim()
135
+ .toLowerCase()
136
+ .replace(/[\s\u00a0\u3000_\-—–·・,,、//||]+/g, "");
137
+ }
138
+
139
+ function disableClassifierReasoningPayload(payload: unknown, model: unknown): unknown {
140
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
141
+ const next = { ...(payload as Record<string, unknown>) };
142
+ delete next.reasoning_effort;
143
+ delete next.reasoningEffort;
144
+ if (next.reasoning && typeof next.reasoning === "object" && !Array.isArray(next.reasoning)) {
145
+ next.reasoning = { ...(next.reasoning as Record<string, unknown>), effort: "none" };
146
+ } else {
147
+ delete next.reasoning;
148
+ }
149
+
150
+ const modelInfo = (model && typeof model === "object" ? model : {}) as Record<string, unknown>;
151
+ const provider = String(modelInfo.provider || "").toLowerCase();
152
+ const baseUrl = String(modelInfo.baseUrl || "").toLowerCase();
153
+ const compat = modelInfo.compat && typeof modelInfo.compat === "object"
154
+ ? modelInfo.compat as Record<string, unknown>
155
+ : {};
156
+ const thinkingFormat = String(compat.thinkingFormat || "").toLowerCase();
157
+ const usesBooleanThinkingToggle =
158
+ thinkingFormat === "qwen"
159
+ || thinkingFormat === "qwen-chat-template"
160
+ || thinkingFormat === "zai"
161
+ || provider === "qwen"
162
+ || provider === "modelstudio"
163
+ || provider === "zai"
164
+ || baseUrl.includes("dashscope.aliyuncs.com")
165
+ || baseUrl.includes("api.z.ai")
166
+ || "enable_thinking" in next
167
+ || "chat_template_kwargs" in next;
168
+
169
+ if (usesBooleanThinkingToggle) {
170
+ next.enable_thinking = false;
171
+ next.chat_template_kwargs = {
172
+ ...(next.chat_template_kwargs && typeof next.chat_template_kwargs === "object" && !Array.isArray(next.chat_template_kwargs)
173
+ ? next.chat_template_kwargs as Record<string, unknown>
174
+ : {}),
175
+ enable_thinking: false
176
+ };
177
+ }
178
+ return next;
179
+ }
180
+
181
+ function extractJsonArray(text: string): unknown[] {
182
+ const trimmed = text.trim();
183
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
184
+ const source = fenced?.[1]?.trim() || trimmed;
185
+ const start = source.indexOf("[");
186
+ const end = source.lastIndexOf("]");
187
+ if (start < 0 || end < start) {
188
+ throw new Error("AI 未返回有效分类 JSON");
189
+ }
190
+ const parsed = JSON.parse(source.slice(start, end + 1));
191
+ if (!Array.isArray(parsed)) {
192
+ throw new Error("AI 分类结果不是数组");
193
+ }
194
+ return parsed;
195
+ }
196
+
14
197
  /**
15
198
  * 智能分类元数据引擎
16
199
  */
17
200
  export const watchlistLogic = {
201
+ _normalizeStockCodeForCache: normalizeStockCodeForCache,
202
+
18
203
  /**
19
204
  * 获取单只股票的分类与权重(带全局缓存)
20
205
  */
@@ -28,18 +213,20 @@ export const watchlistLogic = {
28
213
  const db = getDB();
29
214
 
30
215
  // 1. 尝试从全局缓存读取
31
- const cached = db.prepare(`
32
- SELECT industryJson, themeJson FROM global_stock_metadata
33
- WHERE stockCode = ? AND exchange = ?
34
- `).get(stockCode, exchange) as GlobalStockMetadataRow | undefined;
216
+ const cached = getCachedClassificationRow(db, stockCode, exchange);
35
217
 
36
218
  if (cached && cached.industryJson) {
37
219
  try {
38
- return {
220
+ logger.info({
221
+ stockCode,
222
+ cacheCode: normalizeStockCodeForCache(stockCode, exchange),
223
+ exchange
224
+ }, "[Watchlist] classification cache hit");
225
+ return watchlistLogic._normalizeCachedClassification({
39
226
  industry: JSON.parse(cached.industryJson),
40
227
  theme: JSON.parse(cached.themeJson || '[]'),
41
228
  weight: 50
42
- };
229
+ });
43
230
  } catch (e) {
44
231
  // 容错
45
232
  }
@@ -48,45 +235,138 @@ export const watchlistLogic = {
48
235
  // 2. 缓存未命中,调用 AI 进行推断
49
236
  const classification = await watchlistLogic._autoClassifyWithAI(rt, stockName, stockCode, exchange);
50
237
 
51
- if (classification) {
52
- // 3. 结果存入全局缓存
53
- db.prepare(`
54
- INSERT OR REPLACE INTO global_stock_metadata (stockCode, exchange, stockName, industryJson, themeJson)
55
- VALUES (?, ?, ?, ?, ?)
56
- `).run(
57
- stockCode,
58
- exchange,
59
- stockName,
60
- JSON.stringify(classification.industry),
61
- JSON.stringify(classification.theme)
62
- );
63
- return classification;
64
- }
65
-
66
- return null;
238
+ return classification;
67
239
  },
68
240
 
69
241
  /**
70
242
  * 批量获取股票分类
71
243
  */
72
- async getBatchStockClassification(
244
+ async classifyStocksTogether(
73
245
  rt: PluginRuntime,
74
246
  stocks: any[],
75
247
  _userId: string
248
+ ): Promise<StockClassification[]> {
249
+ const db = getDB();
250
+ const results = new Array<StockClassification | null>(stocks.length).fill(null);
251
+ const pendingStocks: { idx: number, stockName: string, stockCode: string, exchange: string }[] = [];
252
+
253
+ stocks.forEach((stock, idx) => {
254
+ const cached = getCachedClassificationRow(db, stock.stockCode, stock.exchange);
255
+
256
+ if (cached && cached.industryJson) {
257
+ try {
258
+ logger.info({
259
+ stockCode: stock.stockCode,
260
+ cacheCode: normalizeStockCodeForCache(stock.stockCode, stock.exchange),
261
+ exchange: stock.exchange
262
+ }, "[Watchlist] classification cache hit");
263
+ results[idx] = watchlistLogic._normalizeCachedClassification({
264
+ industry: JSON.parse(cached.industryJson),
265
+ theme: JSON.parse(cached.themeJson || '[]'),
266
+ weight: 50
267
+ });
268
+ return;
269
+ } catch {
270
+ // 缓存损坏时重新分析
271
+ }
272
+ }
273
+
274
+ pendingStocks.push({
275
+ idx,
276
+ stockName: stock.stockName,
277
+ stockCode: stock.stockCode,
278
+ exchange: stock.exchange
279
+ });
280
+ });
281
+
282
+ logger.info({
283
+ total: stocks.length,
284
+ cached: stocks.length - pendingStocks.length,
285
+ pending: pendingStocks.length,
286
+ pendingCodes: pendingStocks.map(stock => stock.stockCode)
287
+ }, "[Watchlist] batch add classification input");
288
+
289
+ if (pendingStocks.length === 0) {
290
+ return results as StockClassification[];
291
+ }
292
+
293
+ const cats = watchlistLogic._getKnownCategories(db);
294
+ if (cats.industries.length === 0) {
295
+ throw new Error("行业分类字典为空,无法分析行业/主题关系");
296
+ }
297
+
298
+ const stocksList = pendingStocks.map(s => `- ${s.stockName} (${s.stockCode})`).join("\n");
299
+ const prompt = watchlistLogic._buildAiPrompt(cats.industries, cats.themes, stocksList, true);
300
+ const sessionId = `classify-batch-${randomUUID()}`;
301
+ const aiText = await watchlistLogic._callClassifierAi(rt, sessionId, prompt, resolveBatchClassificationTimeoutMs(pendingStocks.length));
302
+ let parsed: unknown[];
303
+ try {
304
+ parsed = extractJsonArray(aiText);
305
+ } catch (e) {
306
+ logger.warn({
307
+ err: e instanceof Error ? e.message : String(e),
308
+ pendingCodes: pendingStocks.map(stock => stock.stockCode),
309
+ aiTextLength: aiText.length,
310
+ aiText
311
+ }, "[Watchlist] batch classification AI parse failed");
312
+ throw e;
313
+ }
314
+
315
+ const parsedByCode = new Map<string, any>();
316
+ parsed.forEach((raw: any) => {
317
+ if (raw?.code) parsedByCode.set(String(raw.code), raw);
318
+ });
319
+
320
+ let parsedResults: { stock: typeof pendingStocks[number]; data: StockClassification }[];
321
+ try {
322
+ parsedResults = pendingStocks.map((stock, i) => {
323
+ const raw = parsedByCode.get(String(stock.stockCode)) || parsed[i];
324
+ return {
325
+ stock,
326
+ data: watchlistLogic._parseClassification(raw, cats, stock.stockName || stock.stockCode)
327
+ };
328
+ });
329
+ } catch (e) {
330
+ logger.warn({
331
+ err: e instanceof Error ? e.message : String(e),
332
+ pendingCodes: pendingStocks.map(stock => stock.stockCode),
333
+ aiTextLength: aiText.length,
334
+ aiText
335
+ }, "[Watchlist] batch classification AI semantic parse failed");
336
+ throw e;
337
+ }
338
+
339
+ for (const { stock, data } of parsedResults) {
340
+ results[stock.idx] = data;
341
+ }
342
+
343
+ const missing = stocks.find((_, i) => !results[i]);
344
+ if (missing) {
345
+ throw new Error(`行业/主题关系分析失败: ${missing.stockName || missing.stockCode}`);
346
+ }
347
+ return results as StockClassification[];
348
+ },
349
+
350
+ async getBatchStockClassification(
351
+ rt: PluginRuntime,
352
+ stocks: any[],
353
+ _userId: string,
354
+ options: { requireComplete?: boolean; forceRefresh?: boolean } = {}
76
355
  ): Promise<(StockClassification | null)[]> {
77
356
  const db = getDB();
78
357
  const results = new Array(stocks.length).fill(null);
79
358
  const pendingStocks: { idx: number, name: string, code: string, exchange: string }[] = [];
80
-
81
359
  stocks.forEach((s, i) => {
82
- const cached = db.prepare(`SELECT industryJson, themeJson FROM global_stock_metadata WHERE stockCode = ? AND exchange = ?`).get(s.stockCode, s.exchange) as GlobalStockMetadataRow | undefined;
360
+ const cached = options.forceRefresh
361
+ ? undefined
362
+ : getCachedClassificationRow(db, s.stockCode, s.exchange);
83
363
  if (cached && cached.industryJson) {
84
364
  try {
85
- results[i] = {
365
+ results[i] = watchlistLogic._normalizeCachedClassification({
86
366
  industry: JSON.parse(cached.industryJson),
87
367
  theme: JSON.parse(cached.themeJson || '[]'),
88
368
  weight: 50
89
- };
369
+ });
90
370
  } catch (e) {
91
371
  pendingStocks.push({ idx: i, name: s.stockName, code: s.stockCode, exchange: s.exchange });
92
372
  }
@@ -94,55 +374,52 @@ export const watchlistLogic = {
94
374
  pendingStocks.push({ idx: i, name: s.stockName, code: s.stockCode, exchange: s.exchange });
95
375
  }
96
376
  });
377
+ logger.info({
378
+ total: stocks.length,
379
+ cached: stocks.length - pendingStocks.length,
380
+ pending: pendingStocks.length,
381
+ pendingCodes: pendingStocks.map(s => s.code)
382
+ }, "[Watchlist] batch classification input");
97
383
 
98
384
  if (pendingStocks.length > 0) {
99
- const CHUNK_SIZE = 5;
100
- const chunks: (typeof pendingStocks)[] = [];
101
- for (let i = 0; i < pendingStocks.length; i += CHUNK_SIZE) {
102
- chunks.push(pendingStocks.slice(i, i + CHUNK_SIZE));
103
- }
104
-
105
385
  const cats = watchlistLogic._getKnownCategories(db);
106
386
  if (cats.industries.length === 0) return results;
107
387
 
108
- await Promise.all(chunks.map(async (chunk) => {
109
- const stocksList = chunk.map(s => `- ${s.name} (${s.code})`).join("\n");
110
- const prompt = watchlistLogic._buildAiPrompt(cats.industries, cats.themes, stocksList, true);
388
+ const stocksList = pendingStocks.map(s => `- ${s.name} (${s.code})`).join("\n");
389
+ const prompt = watchlistLogic._buildAiPrompt(cats.industries, cats.themes, stocksList, true);
111
390
 
112
- try {
113
- const aiText = await watchlistLogic._callEmbeddedAi(rt, `classify-batch-${chunk[0].code}`, prompt, 120000);
114
- const jsonMatch = aiText.match(/\[.*\]/s);
115
- if (jsonMatch) {
116
- const parsed = JSON.parse(jsonMatch[0]);
117
- chunk.forEach((ps, i) => {
118
- const raw = parsed[i];
119
- if (raw && Array.isArray(raw.category)) {
120
- const industryItem = raw.category.find((c: any) => cats.industries.includes(watchlistLogic._anchorToCategory(c.name, cats.industries)));
121
- const themesItems = raw.category.filter((c: any) => c !== industryItem);
122
-
123
- if (industryItem) {
124
- const data: StockClassification = {
125
- industry: {
126
- name: watchlistLogic._anchorToCategory(industryItem.name, cats.industries),
127
- weight: industryItem.weight || 100
128
- },
129
- theme: themesItems.map((t: any) => ({
130
- name: watchlistLogic._anchorToCategory(t.name, cats.themes),
131
- weight: t.weight || 0
132
- })),
133
- weight: 50
134
- };
135
- results[ps.idx] = data;
136
- db.prepare(`INSERT OR REPLACE INTO global_stock_metadata (stockCode, exchange, stockName, industryJson, themeJson) VALUES (?, ?, ?, ?, ?)`)
137
- .run(ps.code, ps.exchange, ps.name, JSON.stringify(data.industry), JSON.stringify(data.theme));
138
- }
139
- }
140
- });
141
- }
142
- } catch (e) {
143
- console.error(`[Watchlist] 分块 AI 分类失败:`, e);
391
+ try {
392
+ const aiText = await watchlistLogic._callClassifierAi(rt, `classify-batch-${randomUUID()}`, prompt, resolveBatchClassificationTimeoutMs(pendingStocks.length));
393
+ const parsed = extractJsonArray(aiText);
394
+ const parsedByCode = new Map<string, any>();
395
+ parsed.forEach((raw: any) => {
396
+ if (raw?.code) parsedByCode.set(String(raw.code), raw);
397
+ });
398
+
399
+ const parsedResults = pendingStocks.map((ps, i) => {
400
+ const raw = parsedByCode.get(String(ps.code)) || parsed[i];
401
+ return {
402
+ stock: ps,
403
+ data: watchlistLogic._parseClassification(raw, cats, ps.name || ps.code)
404
+ };
405
+ });
406
+
407
+ for (const { stock, data } of parsedResults) {
408
+ results[stock.idx] = data;
144
409
  }
145
- }));
410
+ } catch (e) {
411
+ logger.error({
412
+ err: e instanceof Error ? e.message : String(e),
413
+ pendingCodes: pendingStocks.map(stock => stock.code)
414
+ }, "[Watchlist] batch AI classification failed");
415
+ if (options.requireComplete) throw e;
416
+ }
417
+ }
418
+ if (options.requireComplete) {
419
+ const missing = stocks.find((_, i) => !results[i]);
420
+ if (missing) {
421
+ throw new Error(`行业/主题关系分析失败: ${missing.stockName || missing.stockCode}`);
422
+ }
146
423
  }
147
424
  return results;
148
425
  },
@@ -168,75 +445,132 @@ export const watchlistLogic = {
168
445
  const db = getDB();
169
446
  const cats = watchlistLogic._getKnownCategories(db);
170
447
 
171
- if (cats.industries.length === 0 || cats.themes.length === 0) {
448
+ if (cats.industries.length === 0) {
172
449
  return null;
173
450
  }
174
451
 
175
452
  const prompt = watchlistLogic._buildAiPrompt(cats.industries, cats.themes, `${stockName} (${stockCode})`, false);
176
- const aiText = await watchlistLogic._callEmbeddedAi(rt, `classify-${stockCode}`, prompt, 60000);
453
+ const aiText = await watchlistLogic._callClassifierAi(rt, `classify-${stockCode}`, prompt, SINGLE_CLASSIFICATION_TIMEOUT_MS);
177
454
 
178
455
  try {
179
- const jsonMatch = aiText.match(/\[.*\]/s);
180
- if (jsonMatch) {
181
- const parsed = JSON.parse(jsonMatch[0]);
182
- const raw = parsed[0]; // 单只股票也是数组格式
183
-
184
- if (raw && Array.isArray(raw.category)) {
185
- const industryItem = raw.category.find((c: any) => cats.industries.includes(watchlistLogic._anchorToCategory(c.name, cats.industries)));
186
- const themesItems = raw.category.filter((c: any) => c !== industryItem);
187
-
188
- if (industryItem) {
189
- const data: StockClassification = {
190
- industry: {
191
- name: watchlistLogic._anchorToCategory(industryItem.name, cats.industries),
192
- weight: industryItem.weight || 100
193
- },
194
- theme: themesItems.map((t: any) => ({
195
- name: watchlistLogic._anchorToCategory(t.name, cats.themes),
196
- weight: t.weight || 0
197
- })),
198
- weight: 50
199
- };
200
- return data;
201
- }
202
- }
456
+ const parsed = extractJsonArray(aiText);
457
+ const raw = parsed[0]; // 单只股票也是数组格式
458
+ if (raw) {
459
+ return watchlistLogic._parseClassification(raw, cats, stockName || stockCode);
203
460
  }
204
461
  } catch (e) {
205
- console.error("[Watchlist] AI 分类解析异常:", e);
462
+ logger.warn({ err: e instanceof Error ? e.message : String(e), stockCode, stockName }, "[Watchlist] AI 分类解析异常");
206
463
  }
207
464
  return null;
208
465
  },
209
466
 
210
- async _callEmbeddedAi(rt: PluginRuntime, sessionId: string, prompt: string, timeoutMs: number): Promise<string> {
211
- const fullCfg = rt.config.loadConfig();
212
- const workspaceDir = rt.agent.resolveAgentWorkspaceDir(fullCfg, "hedgehog-workspace");
213
- const sessionFile = path.join(workspaceDir, "data", "sessions", `${sessionId}.json`);
214
- let provider: string | undefined;
215
- let model: string | undefined;
216
- const resolveModelRef = (agentId: string): string | null => {
217
- const agent = fullCfg.agents?.list?.find(a => a.id === agentId);
218
- const modelCfg = agent?.model || fullCfg.agents?.defaults?.model;
219
- if (!modelCfg) return null;
220
- if (typeof modelCfg === 'string') return modelCfg;
221
- return (modelCfg as { primary?: string }).primary || null;
467
+ async _callClassifierCompletion(rt: PluginRuntime, sessionId: string, prompt: string, timeoutMs: number): Promise<string> {
468
+ const cfg = rt.config.loadConfig();
469
+ const { provider, model } = resolveClassifierModelSelection(cfg, rt.agent.defaults.provider, rt.agent.defaults.model);
470
+ const maxTokens = resolveClassifierOutputMaxTokens(prompt);
471
+ const providerAuth = await rt.modelAuth.resolveApiKeyForProvider({ provider, cfg });
472
+ if (!providerAuth.apiKey) {
473
+ throw new Error(`No API key found for provider "${provider}".`);
474
+ }
475
+ const providerConfigs = cfg.models?.providers;
476
+ const providerConfig = getConfiguredProviderConfig(cfg, provider);
477
+ if (!providerConfig) {
478
+ throw new Error(`No model provider config found for "${provider}".`);
479
+ }
480
+ const embeddedCfg = {
481
+ ...cfg,
482
+ agents: {
483
+ ...cfg.agents,
484
+ defaults: {
485
+ ...cfg.agents?.defaults,
486
+ systemPromptOverride: CLASSIFIER_SYSTEM_PROMPT
487
+ }
488
+ },
489
+ models: {
490
+ ...cfg.models,
491
+ providers: {
492
+ ...providerConfigs,
493
+ [provider]: {
494
+ ...providerConfig,
495
+ auth: "api-key" as const,
496
+ apiKey: providerAuth.apiKey
497
+ }
498
+ }
499
+ }
222
500
  };
223
- const modelRef = resolveModelRef("hedgehog-workspace") || resolveModelRef(fullCfg.agents?.list?.[0]?.id || "");
224
- if (modelRef) {
225
- const parts = modelRef.split('/');
226
- if (parts.length >= 2) {
227
- provider = parts[0];
228
- model = parts.slice(1).join('/');
501
+ const startedAt = nowMs();
502
+ const prepared = await prepareSimpleCompletionModel({
503
+ cfg: embeddedCfg,
504
+ provider,
505
+ modelId: model
506
+ });
507
+ const preparedAt = nowMs();
508
+ if ("error" in prepared) {
509
+ throw new Error(prepared.error);
510
+ }
511
+ const abortController = new AbortController();
512
+ const abortTimer = setTimeout(() => {
513
+ abortController.abort(new Error(`classifier completion timed out after ${timeoutMs}ms`));
514
+ }, timeoutMs);
515
+ try {
516
+ logger.info({
517
+ sessionId,
518
+ provider,
519
+ model,
520
+ maxTokens
521
+ }, "[Watchlist] classifier simple completion start");
522
+
523
+ const result = await completeSimple(prepared.model, {
524
+ systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
525
+ messages: [{
526
+ role: "user",
527
+ content: prompt,
528
+ timestamp: Date.now()
529
+ }]
530
+ }, {
531
+ apiKey: prepared.auth.apiKey,
532
+ maxTokens,
533
+ signal: abortController.signal,
534
+ onPayload: disableClassifierReasoningPayload
535
+ });
536
+ const completedAt = nowMs();
537
+ const aiText = extractAssistantText(result)?.trim() || "";
538
+ if (!aiText) {
539
+ throw new Error("AI 分类分析未返回内容");
229
540
  }
541
+ extractJsonArray(aiText);
542
+ logger.info({
543
+ sessionId,
544
+ responseId: result.responseId,
545
+ usage: result.usage,
546
+ stopReason: result.stopReason,
547
+ contentBlocks: Array.isArray(result.content)
548
+ ? result.content.map((block: any) => block?.type || typeof block)
549
+ : [],
550
+ timingMs: {
551
+ prepare: preparedAt - startedAt,
552
+ complete: completedAt - preparedAt,
553
+ total: completedAt - startedAt
554
+ },
555
+ aiTextLength: aiText.length,
556
+ aiText
557
+ }, "[Watchlist] classifier simple completion completed");
558
+ return aiText;
559
+ } finally {
560
+ clearTimeout(abortTimer);
561
+ }
562
+ },
563
+
564
+ async _callClassifierAi(rt: PluginRuntime, sessionId: string, prompt: string, timeoutMs: number): Promise<string> {
565
+ try {
566
+ return await watchlistLogic._callClassifierCompletion(rt, sessionId, prompt, timeoutMs);
567
+ } catch (e) {
568
+ logger.warn({
569
+ err: e instanceof Error ? e.message : String(e),
570
+ sessionId
571
+ }, "[Watchlist] classifier simple completion failed");
572
+ throw e;
230
573
  }
231
- const result = await rt.agent.runEmbeddedAgent({
232
- sessionId, runId: randomUUID(), timeoutMs: 30000,
233
- provider: provider || String(rt.agent.defaults.provider),
234
- model: model || String(rt.agent.defaults.model),
235
- workspaceDir, sessionFile, prompt,
236
- bootstrapContextMode: "lightweight",
237
- extraSystemPrompt: "你是一个金融专家,只输出纯 JSON。请基于公司主营业务进行客观分类,确保相同逻辑下结果唯一。绝对禁止输出任何推理过程。"
238
- });
239
- return result.meta.finalAssistantVisibleText || "";
240
574
  },
241
575
 
242
576
  _ensureCategory(db: any, name: string, type: 'industry' | 'theme', userId: string): string {
@@ -258,7 +592,7 @@ export const watchlistLogic = {
258
592
 
259
593
  async applySmartSort(rt: PluginRuntime, sessionId: string, stocks: { name: string, code: string }[]): Promise<any[]> {
260
594
  const prompt = watchlistLogic._buildSmartSortPrompt(stocks);
261
- const aiText = await watchlistLogic._callEmbeddedAi(rt, `smart-sort-${sessionId}`, prompt, 60000);
595
+ const aiText = await watchlistLogic._callClassifierAi(rt, `smart-sort-${sessionId}`, prompt, SMART_SORT_TIMEOUT_MS);
262
596
  const jsonMatch = aiText.match(/\[.*\]/s);
263
597
  if (jsonMatch) {
264
598
  try { return JSON.parse(jsonMatch[0]); } catch (e) { return []; }
@@ -266,6 +600,39 @@ export const watchlistLogic = {
266
600
  return [];
267
601
  },
268
602
 
603
+ _parseClassification(raw: any, cats: { industries: string[]; themes: string[] }, label: string): StockClassification {
604
+ if (!raw || !Array.isArray(raw.category)) {
605
+ throw new Error(`行业/主题关系分析失败: ${label}`);
606
+ }
607
+ const industryItem = raw.category.find((c: any) => cats.industries.includes(watchlistLogic._anchorToCategory(c.name, cats.industries)));
608
+ if (!industryItem) {
609
+ throw new Error(`行业分类分析失败: ${label}`);
610
+ }
611
+ const industryName = watchlistLogic._anchorToCategory(industryItem.name, cats.industries);
612
+ const themesItems = raw.category.filter((c: any) => c !== industryItem);
613
+ return {
614
+ industry: {
615
+ name: industryName,
616
+ weight: industryItem.weight || 100
617
+ },
618
+ theme: themesItems
619
+ .map((t: any) => ({
620
+ name: watchlistLogic._anchorToCategory(t.name, cats.themes),
621
+ weight: t.weight || 0
622
+ }))
623
+ .filter((t: { name: string; weight: number }) => cats.themes.includes(t.name)),
624
+ weight: 50
625
+ };
626
+ },
627
+
628
+ _normalizeCachedClassification(value: StockClassification): StockClassification {
629
+ const parsed = StockClassificationSchema.safeParse(value);
630
+ if (!parsed.success || !parsed.data.industry?.name) {
631
+ throw new Error("缓存分类缺少行业");
632
+ }
633
+ return parsed.data;
634
+ },
635
+
269
636
  _buildAiPrompt(industries: string[], themes: string[], input: string, isBatch: boolean): string {
270
637
  let stocksJson = input;
271
638
  if (isBatch) {
@@ -290,7 +657,15 @@ export const watchlistLogic = {
290
657
  const trimmed = value.trim();
291
658
  const exact = categories.find(c => c === trimmed);
292
659
  if (exact) return exact;
293
- const candidates = categories.filter(c => c.includes(trimmed) || trimmed.includes(c));
660
+ const normalized = normalizeCategoryMatchKey(trimmed);
661
+ const normalizedExact = categories.find(c => normalizeCategoryMatchKey(c) === normalized);
662
+ if (normalizedExact) return normalizedExact;
663
+ const candidates = categories.filter(c => {
664
+ const categoryKey = normalizeCategoryMatchKey(c);
665
+ return c.includes(trimmed)
666
+ || trimmed.includes(c)
667
+ || (normalized.length > 0 && (categoryKey.includes(normalized) || normalized.includes(categoryKey)));
668
+ });
294
669
  if (candidates.length === 1) return candidates[0];
295
670
  return "其他";
296
671
  }