@hedgehog-finance/hedgehog-plugin 1.0.12 → 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.
- package/dist/index.d.ts +10 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/src/channel.d.ts +6 -0
- package/dist/src/channel.js +620 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/core/database.d.ts +2 -0
- package/dist/src/core/database.js +220 -0
- package/dist/src/core/database.js.map +1 -0
- package/dist/src/core/logger.d.ts +3 -0
- package/dist/src/core/logger.js +20 -0
- package/dist/src/core/logger.js.map +1 -0
- package/dist/src/features/index.d.ts +22 -0
- package/dist/src/features/index.js +8 -0
- package/dist/src/features/index.js.map +1 -0
- package/dist/src/features/watchlist/logic.d.ts +48 -0
- package/dist/src/features/watchlist/logic.js +607 -0
- package/dist/src/features/watchlist/logic.js.map +1 -0
- package/dist/src/features/watchlist/schema.d.ts +85 -0
- package/dist/src/features/watchlist/schema.js +29 -0
- package/dist/src/features/watchlist/schema.js.map +1 -0
- package/dist/src/features/watchlist/store.d.ts +1 -0
- package/dist/src/features/watchlist/store.js +2 -0
- package/dist/src/features/watchlist/store.js.map +1 -0
- package/dist/src/features/watchlist/tools.d.ts +135 -0
- package/dist/src/features/watchlist/tools.js +572 -0
- package/dist/src/features/watchlist/tools.js.map +1 -0
- package/dist/src/runtime.d.ts +5 -0
- package/dist/src/runtime.js +40 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/types.d.ts +99 -0
- package/dist/src/types.js +16 -0
- package/dist/src/types.js.map +1 -0
- package/index.ts +4 -4
- package/package.json +23 -6
- package/src/channel.ts +26 -4
- package/src/core/database.ts +90 -3
- package/src/features/index.ts +2 -1
- package/src/features/watchlist/logic.ts +503 -128
- package/src/features/watchlist/schema.ts +1 -6
- package/src/features/watchlist/tools.ts +248 -103
- package/src/runtime.ts +3 -3
- package/src/types.ts +1 -1
- package/tsconfig.json +0 -16
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import { completeSimple } from "@mariozechner/pi-ai";
|
|
3
3
|
import { PluginRuntime } from "openclaw/plugin-sdk";
|
|
4
|
-
import {
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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.
|
|
453
|
+
const aiText = await watchlistLogic._callClassifierAi(rt, `classify-${stockCode}`, prompt, SINGLE_CLASSIFICATION_TIMEOUT_MS);
|
|
177
454
|
|
|
178
455
|
try {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
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
|
|
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
|
}
|