@reconcrap/boss-recommend-mcp 2.0.29 → 2.0.31
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/README.md +28 -3
- package/config/screening-config.example.json +4 -2
- package/package.json +1 -1
- package/src/chat-mcp.js +3 -1
- package/src/chat-runtime-config.js +81 -24
- package/src/cli.js +14 -1
- package/src/core/screening/index.js +208 -73
- package/src/domains/chat/run-service.js +6 -0
- package/src/domains/recommend/run-service.js +171 -129
- package/src/recommend-mcp.js +65 -0
package/README.md
CHANGED
|
@@ -227,10 +227,35 @@ config/screening-config.example.json
|
|
|
227
227
|
- `apiKey`
|
|
228
228
|
- `model`
|
|
229
229
|
|
|
230
|
+
也可以用 `llmModels` 配置多个 OpenAI-compatible 模型。运行时会先调用第一个模型;当该模型请求失败、超时、返回非 JSON、或没有返回 `{"passed": true/false}` 决策时,会自动切到下一个模型。未配置 `llmModels` 或数组为空时,继续使用上面的单模型字段,旧配置无需迁移。
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"llmModels": [
|
|
235
|
+
{
|
|
236
|
+
"name": "primary",
|
|
237
|
+
"baseUrl": "https://api.openai.com/v1",
|
|
238
|
+
"apiKey": "sk-primary",
|
|
239
|
+
"model": "gpt-4.1-mini"
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
"name": "backup",
|
|
243
|
+
"baseUrl": "https://api.openai.com/v1",
|
|
244
|
+
"apiKey": "sk-backup",
|
|
245
|
+
"model": "gpt-4.1-nano"
|
|
246
|
+
}
|
|
247
|
+
],
|
|
248
|
+
"greetingMessage": "Hi同学,能麻烦发下简历吗?",
|
|
249
|
+
"llmThinkingLevel": "low",
|
|
250
|
+
"llmMaxRetries": 1
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
230
254
|
可选字段:
|
|
231
255
|
|
|
232
256
|
- `openaiOrganization`
|
|
233
257
|
- `openaiProject`
|
|
258
|
+
- `greetingMessage`:chat 求简历流程发送的招呼语。兼容 `greetingText` / `greeting_text`;本次 run 显式传入的 `greeting_text` 优先级最高。
|
|
234
259
|
- `debugPort`:未显式传 `port` 时,recommend / search / chat CDP-only MCP run 和健康检查默认连接这个 Chrome 调试端口。
|
|
235
260
|
- `outputDir`:recommend / search / chat 完成后的最终 CSV 与 report JSON 会写入这里;run state / checkpoint 仍保留在各自状态目录,方便 pause/resume/cancel。
|
|
236
261
|
- `llmThinkingLevel`:默认 `low`。可设为 `off/minimal/low/medium/high/auto/current`,用于控制 OpenAI-compatible LLM 的 thinking/reasoning 强度。
|
|
@@ -243,7 +268,7 @@ npm 包安装后可直接使用可执行命令 `boss-recommend-mcp`。以下示
|
|
|
243
268
|
```bash
|
|
244
269
|
node src/cli.js install --agent trae-cn
|
|
245
270
|
node src/cli.js init-config
|
|
246
|
-
node src/cli.js config set --base-url https://api.openai.com/v1 --api-key <your-key> --model gpt-4o-mini --thinking-level off
|
|
271
|
+
node src/cli.js config set --base-url https://api.openai.com/v1 --api-key <your-key> --model gpt-4o-mini --thinking-level off --greeting-message "您好,方便发下简历吗?"
|
|
247
272
|
node src/cli.js set-port --port 9222
|
|
248
273
|
node src/cli.js doctor --agent trae-cn
|
|
249
274
|
node src/cli.js launch-chrome --port 9222
|
|
@@ -283,7 +308,7 @@ node src/cli.js chat prepare-run --slow-live --port 9222
|
|
|
283
308
|
- `profile` 可选,默认 `default`
|
|
284
309
|
- `job` 与 `port` 继承 recommend run 已选岗位和调试端口
|
|
285
310
|
- `baseUrl` / `apiKey` / `model` 不再单独传入,固定复用 recommend 的 `screening-config.json`
|
|
286
|
-
- `greeting_text` 默认优先级:本次显式值 >
|
|
311
|
+
- `greeting_text` 默认优先级:本次显式值 > `screening-config.json.greetingMessage` > 内置默认招呼语(`Hi同学,能麻烦发下简历吗?`)
|
|
287
312
|
- 若缺少 `follow_up.chat` 必填项,pipeline 会返回 `NEED_INPUT`
|
|
288
313
|
- 如需聊天页筛选,请调用 `prepare_boss_chat_run` 获取岗位列表,再调用 `start_boss_chat_run`。
|
|
289
314
|
- `boss-chat` 状态统一写入 `~/.boss-recommend-mcp/boss-chat`(或 `BOSS_CHAT_HOME` 指定目录),不再依赖工作区 `cwd`
|
|
@@ -313,7 +338,7 @@ chat-only 交互建议:
|
|
|
313
338
|
|
|
314
339
|
- 先调用一次 `prepare_boss_chat_run`(可不带参数),服务会先导航到 `https://www.zhipin.com/web/chat/index` 并返回 `NEED_INPUT`,其中包含岗位 `job_options` 与待补字段。
|
|
315
340
|
- 然后基于 `job_options` 让用户选择 `job`,并补齐 `start_from`、`target_count`、`criteria` 后调用 `start_boss_chat_run` 启动任务。
|
|
316
|
-
- `greeting_text`
|
|
341
|
+
- `greeting_text` 可选;未传时使用 `screening-config.json.greetingMessage`,若未配置则使用默认招呼语(`Hi同学,能麻烦发下简历吗?`)。
|
|
317
342
|
- `target_count` 支持正整数、`all`、`-1`;若用户给出 `全部候选人` / `所有候选人`,会自动按不限(扫到底)处理。
|
|
318
343
|
|
|
319
344
|
Trae-CN / 长对话防循环建议:
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"baseUrl": "https://api.openai.com/v1",
|
|
3
|
-
"apiKey": "replace-with-openai-api-key",
|
|
2
|
+
"baseUrl": "https://api.openai.com/v1",
|
|
3
|
+
"apiKey": "replace-with-openai-api-key",
|
|
4
4
|
"model": "gpt-4.1-mini",
|
|
5
|
+
"llmModels": [],
|
|
6
|
+
"greetingMessage": "Hi同学,能麻烦发下简历吗?",
|
|
5
7
|
"llmThinkingLevel": "low",
|
|
6
8
|
"llmTimeoutMs": 60000,
|
|
7
9
|
"llmMaxTokens": 512,
|
package/package.json
CHANGED
package/src/chat-mcp.js
CHANGED
|
@@ -874,12 +874,14 @@ async function readChatJobOptionsFromSession(session) {
|
|
|
874
874
|
|
|
875
875
|
function normalizeChatStartInput(args = {}, configResolution = null) {
|
|
876
876
|
const target = normalizeTargetCountInput(getBossChatTargetCountValue(args));
|
|
877
|
+
const explicitGreetingText = normalizeText(args.greeting_text || args.greetingText || args.greeting);
|
|
878
|
+
const configuredGreetingText = normalizeText(configResolution?.config?.greetingMessage || configResolution?.config?.greetingText);
|
|
877
879
|
return {
|
|
878
880
|
profile: normalizeText(args.profile) || "default",
|
|
879
881
|
job: normalizeText(args.job),
|
|
880
882
|
startFrom: normalizeText(args.start_from).toLowerCase(),
|
|
881
883
|
criteria: normalizeText(args.criteria),
|
|
882
|
-
greetingText:
|
|
884
|
+
greetingText: explicitGreetingText || configuredGreetingText,
|
|
883
885
|
target,
|
|
884
886
|
targetCount: target.targetCount,
|
|
885
887
|
publicTargetCount: target.publicValue,
|
|
@@ -243,6 +243,55 @@ function normalizeLlmThinkingLevel(raw, fallback = "low") {
|
|
|
243
243
|
return LLM_THINKING_LEVELS.has(normalized) ? normalized : fallback;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
function firstConfiguredValue(...values) {
|
|
247
|
+
for (const value of values) {
|
|
248
|
+
if (value === undefined || value === null) continue;
|
|
249
|
+
if (typeof value === "string" && !value.trim()) continue;
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
return "";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resolveRawLlmModelEntries(config = {}) {
|
|
256
|
+
if (Array.isArray(config.llmModels) && config.llmModels.length > 0) return config.llmModels;
|
|
257
|
+
if (Array.isArray(config.models) && config.models.length > 0) return config.models;
|
|
258
|
+
return [config];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizeScreeningLlmModel(config = {}, rawEntry = {}, index = 0) {
|
|
262
|
+
const entry = typeof rawEntry === "string"
|
|
263
|
+
? { model: rawEntry }
|
|
264
|
+
: (rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {});
|
|
265
|
+
return {
|
|
266
|
+
name: normalizeText(firstConfiguredValue(entry.name, entry.label, entry.id, entry.providerName, entry.provider)),
|
|
267
|
+
baseUrl: normalizeText(firstConfiguredValue(entry.baseUrl, entry.base_url, config.baseUrl, config.base_url)).replace(/\/+$/, ""),
|
|
268
|
+
apiKey: normalizeText(firstConfiguredValue(entry.apiKey, entry.api_key, config.apiKey, config.api_key)),
|
|
269
|
+
model: normalizeText(firstConfiguredValue(entry.model, entry.modelName, entry.model_name, typeof rawEntry === "string" ? rawEntry : "", config.model)),
|
|
270
|
+
openaiOrganization: normalizeText(firstConfiguredValue(entry.openaiOrganization, entry.organization, config.openaiOrganization, config.organization)),
|
|
271
|
+
openaiProject: normalizeText(firstConfiguredValue(entry.openaiProject, entry.project, config.openaiProject, config.project)),
|
|
272
|
+
llmThinkingLevel: normalizeLlmThinkingLevel(
|
|
273
|
+
firstConfiguredValue(entry.llmThinkingLevel, entry.thinkingLevel, entry.reasoningEffort, config.llmThinkingLevel, config.thinkingLevel, config.reasoningEffort),
|
|
274
|
+
"low"
|
|
275
|
+
),
|
|
276
|
+
llmTimeoutMs: parsePositiveInteger(firstConfiguredValue(entry.llmTimeoutMs, entry.timeoutMs, config.llmTimeoutMs, config.timeoutMs), null),
|
|
277
|
+
llmMaxRetries: parsePositiveInteger(firstConfiguredValue(entry.llmMaxRetries, entry.maxRetries, config.llmMaxRetries, config.maxRetries), null),
|
|
278
|
+
llmMaxTokens: parsePositiveInteger(firstConfiguredValue(entry.llmMaxTokens, entry.maxTokens, config.llmMaxTokens, config.maxTokens), null),
|
|
279
|
+
llmMaxCompletionTokens: parsePositiveInteger(
|
|
280
|
+
firstConfiguredValue(entry.llmMaxCompletionTokens, entry.maxCompletionTokens, config.llmMaxCompletionTokens, config.maxCompletionTokens),
|
|
281
|
+
null
|
|
282
|
+
),
|
|
283
|
+
llmImageLimit: parsePositiveInteger(firstConfiguredValue(entry.llmImageLimit, entry.imageLimit, config.llmImageLimit, config.imageLimit), null),
|
|
284
|
+
llmImageDetail: normalizeText(firstConfiguredValue(entry.llmImageDetail, entry.imageDetail, config.llmImageDetail, config.imageDetail)),
|
|
285
|
+
temperature: parseConfigNumber(firstConfiguredValue(entry.temperature, config.temperature), null),
|
|
286
|
+
topP: parseConfigNumber(firstConfiguredValue(entry.topP, entry.top_p, config.topP, config.top_p), null),
|
|
287
|
+
llmProviderIndex: index
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function normalizeScreeningLlmModels(config = {}) {
|
|
292
|
+
return resolveRawLlmModelEntries(config).map((entry, index) => normalizeScreeningLlmModel(config, entry, index));
|
|
293
|
+
}
|
|
294
|
+
|
|
246
295
|
function resolveConfigPathValue(raw, configDir) {
|
|
247
296
|
const normalized = normalizeText(raw);
|
|
248
297
|
if (!normalized) return "";
|
|
@@ -259,13 +308,14 @@ function validateScreeningConfig(config) {
|
|
|
259
308
|
message: "screening-config.json 缺失或格式无效。请填写 baseUrl、apiKey、model。"
|
|
260
309
|
};
|
|
261
310
|
}
|
|
262
|
-
const
|
|
263
|
-
const apiKey = normalizeText(config.apiKey);
|
|
264
|
-
const model = normalizeText(config.model);
|
|
311
|
+
const llmModels = normalizeScreeningLlmModels(config);
|
|
265
312
|
const missing = [];
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
313
|
+
for (const [index, llmModel] of llmModels.entries()) {
|
|
314
|
+
const prefix = llmModels.length > 1 ? `llmModels[${index}]` : "";
|
|
315
|
+
if (!llmModel.baseUrl) missing.push(prefix ? `${prefix}.baseUrl` : "baseUrl");
|
|
316
|
+
if (!llmModel.apiKey) missing.push(prefix ? `${prefix}.apiKey` : "apiKey");
|
|
317
|
+
if (!llmModel.model) missing.push(prefix ? `${prefix}.model` : "model");
|
|
318
|
+
}
|
|
269
319
|
if (missing.length > 0) {
|
|
270
320
|
return {
|
|
271
321
|
ok: false,
|
|
@@ -273,17 +323,20 @@ function validateScreeningConfig(config) {
|
|
|
273
323
|
message: `screening-config.json 缺少必填字段:${missing.join(", ")}。`
|
|
274
324
|
};
|
|
275
325
|
}
|
|
276
|
-
|
|
326
|
+
const placeholderModel = llmModels.find((item) => /^replace-with/i.test(item.apiKey) || item.apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey);
|
|
327
|
+
if (placeholderModel) {
|
|
277
328
|
return {
|
|
278
329
|
ok: false,
|
|
279
330
|
reason: "PLACEHOLDER_API_KEY",
|
|
280
331
|
message: "screening-config.json 的 apiKey 仍是模板占位符,请填写真实 API Key。"
|
|
281
332
|
};
|
|
282
333
|
}
|
|
334
|
+
const firstModel = llmModels[0] || {};
|
|
283
335
|
if (
|
|
284
|
-
|
|
285
|
-
&&
|
|
286
|
-
&&
|
|
336
|
+
llmModels.length === 1
|
|
337
|
+
&& firstModel.baseUrl === SCREEN_CONFIG_TEMPLATE_DEFAULTS.baseUrl
|
|
338
|
+
&& firstModel.apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey
|
|
339
|
+
&& firstModel.model === SCREEN_CONFIG_TEMPLATE_DEFAULTS.model
|
|
287
340
|
) {
|
|
288
341
|
return {
|
|
289
342
|
ok: false,
|
|
@@ -393,24 +446,28 @@ export function resolveBossScreeningConfig(workspaceRoot) {
|
|
|
393
446
|
candidate_paths: candidatePaths
|
|
394
447
|
};
|
|
395
448
|
}
|
|
449
|
+
const llmModels = normalizeScreeningLlmModels(parsed);
|
|
450
|
+
const primaryLlmModel = llmModels[0] || {};
|
|
451
|
+
const greetingText = normalizeText(
|
|
452
|
+
parsed.greetingMessage
|
|
453
|
+
|| parsed.greeting_message
|
|
454
|
+
|| parsed.greetingText
|
|
455
|
+
|| parsed.greeting_text
|
|
456
|
+
|| parsed.greeting
|
|
457
|
+
);
|
|
396
458
|
return {
|
|
397
459
|
ok: true,
|
|
398
460
|
config: {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
461
|
+
...primaryLlmModel,
|
|
462
|
+
baseUrl: primaryLlmModel.baseUrl,
|
|
463
|
+
apiKey: primaryLlmModel.apiKey,
|
|
464
|
+
model: primaryLlmModel.model,
|
|
465
|
+
openaiOrganization: primaryLlmModel.openaiOrganization,
|
|
466
|
+
openaiProject: primaryLlmModel.openaiProject,
|
|
467
|
+
llmModels,
|
|
468
|
+
greetingMessage: greetingText,
|
|
469
|
+
greetingText,
|
|
404
470
|
debugPort: parsePositiveInteger(parsed.debugPort, 9222),
|
|
405
|
-
llmThinkingLevel: normalizeLlmThinkingLevel(parsed.llmThinkingLevel || parsed.thinkingLevel || parsed.reasoningEffort, "low"),
|
|
406
|
-
llmTimeoutMs: parsePositiveInteger(parsed.llmTimeoutMs || parsed.timeoutMs, null),
|
|
407
|
-
llmMaxRetries: parsePositiveInteger(parsed.llmMaxRetries || parsed.maxRetries, null),
|
|
408
|
-
llmMaxTokens: parsePositiveInteger(parsed.llmMaxTokens || parsed.maxTokens, null),
|
|
409
|
-
llmMaxCompletionTokens: parsePositiveInteger(parsed.llmMaxCompletionTokens || parsed.maxCompletionTokens, null),
|
|
410
|
-
llmImageLimit: parsePositiveInteger(parsed.llmImageLimit || parsed.imageLimit, null),
|
|
411
|
-
llmImageDetail: normalizeText(parsed.llmImageDetail || parsed.imageDetail),
|
|
412
|
-
temperature: parseConfigNumber(parsed.temperature, null),
|
|
413
|
-
topP: parseConfigNumber(parsed.topP || parsed.top_p, null),
|
|
414
471
|
outputDir: resolveConfigPathValue(parsed.outputDir, configDir),
|
|
415
472
|
humanRestEnabled: parseConfigBoolean(parsed.humanRestEnabled, false)
|
|
416
473
|
},
|
package/src/cli.js
CHANGED
|
@@ -57,6 +57,7 @@ const autoSyncSkipCommands = new Set(["install", "install-skill", "where", "help
|
|
|
57
57
|
const externalMcpTargetsEnv = "BOSS_RECOMMEND_MCP_CONFIG_TARGETS";
|
|
58
58
|
const externalSkillDirsEnv = "BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS";
|
|
59
59
|
const installConfigDefaults = Object.freeze({
|
|
60
|
+
greetingMessage: "Hi同学,能麻烦发下简历吗?",
|
|
60
61
|
llmThinkingLevel: "low",
|
|
61
62
|
llmMaxTokens: 512,
|
|
62
63
|
llmMaxRetries: 3,
|
|
@@ -1431,6 +1432,8 @@ async function setScreeningConfig(options = {}) {
|
|
|
1431
1432
|
apiKey,
|
|
1432
1433
|
model
|
|
1433
1434
|
};
|
|
1435
|
+
delete nextConfig.llmModels;
|
|
1436
|
+
delete nextConfig.models;
|
|
1434
1437
|
if (typeof options["thinking-level"] === "string" && options["thinking-level"].trim()) {
|
|
1435
1438
|
nextConfig.llmThinkingLevel = options["thinking-level"].trim();
|
|
1436
1439
|
} else if (typeof options.llmThinkingLevel === "string" && options.llmThinkingLevel.trim()) {
|
|
@@ -1445,6 +1448,16 @@ async function setScreeningConfig(options = {}) {
|
|
|
1445
1448
|
if (typeof options["output-dir"] === "string" && options["output-dir"].trim()) {
|
|
1446
1449
|
nextConfig.outputDir = options["output-dir"].trim();
|
|
1447
1450
|
}
|
|
1451
|
+
const greetingMessage = String(
|
|
1452
|
+
options["greeting-message"]
|
|
1453
|
+
?? options.greetingMessage
|
|
1454
|
+
?? options.greeting_text
|
|
1455
|
+
?? options.greetingText
|
|
1456
|
+
?? ""
|
|
1457
|
+
).trim();
|
|
1458
|
+
if (greetingMessage) {
|
|
1459
|
+
nextConfig.greetingMessage = greetingMessage;
|
|
1460
|
+
}
|
|
1448
1461
|
const debugPort = parsePositivePort(options.port || options["debug-port"]);
|
|
1449
1462
|
if (debugPort) {
|
|
1450
1463
|
nextConfig.debugPort = debugPort;
|
|
@@ -2538,7 +2551,7 @@ function printHelp() {
|
|
|
2538
2551
|
console.log(" boss-recommend-mcp run --detached --instruction \"...\" --overrides-file overrides.json --confirmation-file confirmation.json");
|
|
2539
2552
|
console.log(" boss-recommend-mcp list-jobs --slow-live --port 9222");
|
|
2540
2553
|
console.log(" boss-recommend-mcp chat prepare-run --slow-live --port 9222 # CDP-only preflight; start runs through MCP start_boss_chat_run");
|
|
2541
|
-
console.log(" boss-recommend-mcp config set --base-url <url> --api-key <key> --model <model> [--thinking-level off|low|medium|high|current] [--openai-organization <id>] [--openai-project <id>]");
|
|
2554
|
+
console.log(" boss-recommend-mcp config set --base-url <url> --api-key <key> --model <model> [--thinking-level off|low|medium|high|current] [--greeting-message <text>] [--openai-organization <id>] [--openai-project <id>]");
|
|
2542
2555
|
console.log(" boss-recommend-mcp install --agent trae-cn");
|
|
2543
2556
|
console.log(" boss-recommend-mcp install --agent qclaw # updates ~/.qclaw/openclaw.json mcp.servers and mirrors skills");
|
|
2544
2557
|
console.log(" boss-recommend-mcp doctor --agent trae-cn --page-scope featured");
|
|
@@ -58,6 +58,74 @@ function buildChatCompletionsUrl(baseUrl) {
|
|
|
58
58
|
return `${normalized}/chat/completions`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function redactBaseUrl(baseUrl) {
|
|
62
|
+
const normalized = normalizeBaseUrl(baseUrl);
|
|
63
|
+
return normalized ? normalized.replace(/\/\/[^/]+/, "//[redacted-host]") : "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function firstConfiguredValue(...values) {
|
|
67
|
+
for (const value of values) {
|
|
68
|
+
if (value === undefined || value === null) continue;
|
|
69
|
+
if (typeof value === "string" && !value.trim()) continue;
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeLlmProviderEntry(rawEntry, inherited = {}, index = 0) {
|
|
76
|
+
const entry = typeof rawEntry === "string"
|
|
77
|
+
? { model: rawEntry }
|
|
78
|
+
: (rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {});
|
|
79
|
+
const providerName = firstConfiguredValue(
|
|
80
|
+
entry.name,
|
|
81
|
+
entry.label,
|
|
82
|
+
entry.id,
|
|
83
|
+
entry.providerName,
|
|
84
|
+
entry.provider,
|
|
85
|
+
""
|
|
86
|
+
);
|
|
87
|
+
const next = {
|
|
88
|
+
...inherited,
|
|
89
|
+
...entry,
|
|
90
|
+
baseUrl: firstConfiguredValue(entry.baseUrl, entry.base_url, inherited.baseUrl, inherited.base_url),
|
|
91
|
+
apiKey: firstConfiguredValue(entry.apiKey, entry.api_key, inherited.apiKey, inherited.api_key),
|
|
92
|
+
model: firstConfiguredValue(entry.model, entry.modelName, entry.model_name, typeof rawEntry === "string" ? rawEntry : "", inherited.model),
|
|
93
|
+
openaiOrganization: firstConfiguredValue(entry.openaiOrganization, entry.organization, inherited.openaiOrganization, inherited.organization),
|
|
94
|
+
openaiProject: firstConfiguredValue(entry.openaiProject, entry.project, inherited.openaiProject, inherited.project),
|
|
95
|
+
topP: firstConfiguredValue(entry.topP, entry.top_p, inherited.topP, inherited.top_p),
|
|
96
|
+
llmProviderName: normalizeText(providerName),
|
|
97
|
+
llmProviderIndex: index
|
|
98
|
+
};
|
|
99
|
+
delete next.llmModels;
|
|
100
|
+
delete next.models;
|
|
101
|
+
return next;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeLlmProviderConfigs(config = {}) {
|
|
105
|
+
if (Array.isArray(config)) {
|
|
106
|
+
return config.map((entry, index) => normalizeLlmProviderEntry(entry, {}, index));
|
|
107
|
+
}
|
|
108
|
+
const inherited = config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {};
|
|
109
|
+
const rawProviders = Array.isArray(inherited.llmModels) && inherited.llmModels.length > 0
|
|
110
|
+
? inherited.llmModels
|
|
111
|
+
: (Array.isArray(inherited.models) && inherited.models.length > 0 ? inherited.models : [inherited]);
|
|
112
|
+
delete inherited.llmModels;
|
|
113
|
+
delete inherited.models;
|
|
114
|
+
return rawProviders.map((entry, index) => normalizeLlmProviderEntry(entry, inherited, index));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function compactLlmProviderFailure(error, providerConfig = {}, providerIndex = 0) {
|
|
118
|
+
return {
|
|
119
|
+
index: providerIndex + 1,
|
|
120
|
+
name: normalizeText(providerConfig.llmProviderName || providerConfig.name || providerConfig.label || providerConfig.id) || null,
|
|
121
|
+
baseUrl: redactBaseUrl(providerConfig.baseUrl),
|
|
122
|
+
model: normalizeText(providerConfig.model) || null,
|
|
123
|
+
status: Number.isFinite(Number(error?.status)) ? Number(error.status) : null,
|
|
124
|
+
attempts: Number(error?.llm_attempt_count) || 0,
|
|
125
|
+
message: String(error?.message || error || "").slice(0, 500)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
61
129
|
function isVolcengineModel(baseUrl, model) {
|
|
62
130
|
return /volces|volcengine|ark\.cn|doubao|seed/i.test(`${baseUrl || ""} ${model || ""}`);
|
|
63
131
|
}
|
|
@@ -1325,6 +1393,8 @@ export function compactScreeningLlmResult(llmResult) {
|
|
|
1325
1393
|
finish_reason: llmResult.finish_reason || null,
|
|
1326
1394
|
image_input_count: llmResult.image_input_count || 0,
|
|
1327
1395
|
attempt_count: llmResult.attempt_count || 0,
|
|
1396
|
+
fallback_count: llmResult.fallback_count || 0,
|
|
1397
|
+
llm_model_failures: Array.isArray(llmResult.llm_model_failures) ? llmResult.llm_model_failures : [],
|
|
1328
1398
|
error: llmResult.error || null,
|
|
1329
1399
|
screened_at: llmResult.screened_at || null
|
|
1330
1400
|
};
|
|
@@ -1358,6 +1428,8 @@ export function createFailedLlmScreeningResult(error) {
|
|
|
1358
1428
|
image_input_count: Number(error?.image_input_count) || 0,
|
|
1359
1429
|
image_inputs: Array.isArray(error?.image_inputs) ? error.image_inputs : [],
|
|
1360
1430
|
attempt_count: Number(error?.llm_attempt_count) || 0,
|
|
1431
|
+
fallback_count: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures.length : 0,
|
|
1432
|
+
llm_model_failures: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures : [],
|
|
1361
1433
|
error: error?.message || String(error || "unknown"),
|
|
1362
1434
|
screened_at: nowIso()
|
|
1363
1435
|
};
|
|
@@ -1435,7 +1507,7 @@ function sleepMs(ms) {
|
|
|
1435
1507
|
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
|
|
1436
1508
|
}
|
|
1437
1509
|
|
|
1438
|
-
|
|
1510
|
+
async function callScreeningLlmWithProvider({
|
|
1439
1511
|
candidate,
|
|
1440
1512
|
criteria,
|
|
1441
1513
|
config = {},
|
|
@@ -1488,81 +1560,89 @@ export async function callScreeningLlm({
|
|
|
1488
1560
|
thinkingLevel
|
|
1489
1561
|
});
|
|
1490
1562
|
|
|
1563
|
+
const effectiveTimeoutMs = parsePositiveNumber(config.llmTimeoutMs ?? config.timeoutMs, timeoutMs) || timeoutMs;
|
|
1491
1564
|
const maxRetries = normalizeLlmMaxRetries(config.llmMaxRetries ?? config.maxRetries);
|
|
1492
1565
|
const maxAttempts = maxRetries + 1;
|
|
1493
1566
|
let lastError = null;
|
|
1494
1567
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
1495
1568
|
const controller = new AbortController();
|
|
1496
|
-
const timer = setTimeout(() => controller.abort(),
|
|
1569
|
+
const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs);
|
|
1497
1570
|
try {
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1571
|
+
const headers = {
|
|
1572
|
+
"Content-Type": "application/json",
|
|
1573
|
+
Authorization: `Bearer ${apiKey}`
|
|
1574
|
+
};
|
|
1575
|
+
if (config.openaiOrganization) headers["OpenAI-Organization"] = config.openaiOrganization;
|
|
1576
|
+
if (config.openaiProject) headers["OpenAI-Project"] = config.openaiProject;
|
|
1577
|
+
|
|
1578
|
+
const response = await fetch(buildChatCompletionsUrl(baseUrl), {
|
|
1579
|
+
method: "POST",
|
|
1580
|
+
headers,
|
|
1581
|
+
body: JSON.stringify(payload),
|
|
1582
|
+
signal: controller.signal
|
|
1583
|
+
});
|
|
1584
|
+
const responseText = await response.text();
|
|
1585
|
+
if (!response.ok) {
|
|
1586
|
+
const error = new Error(`LLM request failed: ${response.status} ${responseText.slice(0, 400)}`);
|
|
1587
|
+
error.status = response.status;
|
|
1588
|
+
throw error;
|
|
1589
|
+
}
|
|
1590
|
+
const json = tryParseJson(responseText);
|
|
1591
|
+
if (!json) {
|
|
1592
|
+
throw new Error("LLM response was not valid JSON");
|
|
1593
|
+
}
|
|
1594
|
+
const choice = json?.choices?.[0] || {};
|
|
1595
|
+
const content = flattenChatMessageContent(choice?.message?.content);
|
|
1596
|
+
const reasoningContent = collectLlmReasoningText(choice);
|
|
1597
|
+
const parsed = tryExtractJsonObject(content) || tryExtractJsonObject(reasoningContent);
|
|
1598
|
+
const passed = parsePassedDecision(parsed?.passed);
|
|
1599
|
+
if (passed === null) {
|
|
1600
|
+
throw new Error(`LLM response missing boolean passed decision: ${content.slice(0, 240)}`);
|
|
1601
|
+
}
|
|
1602
|
+
const evidence = Array.isArray(parsed?.evidence)
|
|
1603
|
+
? parsed.evidence.map(normalizeText).filter(Boolean)
|
|
1604
|
+
: [];
|
|
1605
|
+
const decisionCot = firstUsefulLine([
|
|
1606
|
+
parsed?.cot,
|
|
1607
|
+
parsed?.decision_cot,
|
|
1608
|
+
parsed?.reasoning,
|
|
1609
|
+
parsed?.chain_of_thought,
|
|
1610
|
+
reasoningContent
|
|
1611
|
+
].map(normalizeBlockText).filter(Boolean)) || reasoningContent;
|
|
1612
|
+
const providerName = normalizeText(config.llmProviderName || config.name || config.label || config.id);
|
|
1613
|
+
const providerIndex = Number.isFinite(Number(config.llmProviderIndex)) ? Number(config.llmProviderIndex) : 0;
|
|
1614
|
+
const providerCount = Number.isFinite(Number(config.llmProviderCount)) ? Number(config.llmProviderCount) : 1;
|
|
1615
|
+
return {
|
|
1616
|
+
ok: true,
|
|
1617
|
+
provider: {
|
|
1618
|
+
baseUrl: redactBaseUrl(baseUrl),
|
|
1619
|
+
model,
|
|
1620
|
+
name: providerName || null,
|
|
1621
|
+
index: providerIndex + 1,
|
|
1622
|
+
total: providerCount,
|
|
1623
|
+
thinking_level: normalizeLlmThinkingLevel(thinkingLevel) || "low",
|
|
1624
|
+
thinking: payload.thinking || null,
|
|
1625
|
+
reasoning_effort: payload.reasoning_effort || null,
|
|
1626
|
+
max_tokens: payload.max_tokens,
|
|
1627
|
+
max_completion_tokens: payload.max_completion_tokens || null
|
|
1628
|
+
},
|
|
1629
|
+
passed,
|
|
1630
|
+
reason: "",
|
|
1631
|
+
evidence,
|
|
1632
|
+
cot: decisionCot,
|
|
1633
|
+
decision_cot: decisionCot,
|
|
1634
|
+
reasoning_content: reasoningContent,
|
|
1635
|
+
raw_model_output: content,
|
|
1636
|
+
usage: json.usage || null,
|
|
1637
|
+
finish_reason: choice.finish_reason || null,
|
|
1638
|
+
raw_content_length: content.length,
|
|
1639
|
+
image_input_count: imageInputs.length,
|
|
1640
|
+
image_inputs: summarizeLlmImageInputs(imageInputs),
|
|
1641
|
+
attempt_count: attempt,
|
|
1642
|
+
provider_attempt_count: attempt,
|
|
1643
|
+
screened_at: nowIso()
|
|
1644
|
+
};
|
|
1645
|
+
} catch (error) {
|
|
1566
1646
|
lastError = error;
|
|
1567
1647
|
if (attempt >= maxAttempts || !isRetryableLlmRequestError(error)) {
|
|
1568
1648
|
error.image_input_count = imageInputs.length;
|
|
@@ -1571,9 +1651,9 @@ export async function callScreeningLlm({
|
|
|
1571
1651
|
throw error;
|
|
1572
1652
|
}
|
|
1573
1653
|
await sleepMs(Math.min(2500, 500 * attempt));
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1654
|
+
} finally {
|
|
1655
|
+
clearTimeout(timer);
|
|
1656
|
+
}
|
|
1577
1657
|
}
|
|
1578
1658
|
lastError = lastError || new Error("LLM request failed without response");
|
|
1579
1659
|
lastError.image_input_count = imageInputs.length;
|
|
@@ -1581,3 +1661,58 @@ export async function callScreeningLlm({
|
|
|
1581
1661
|
lastError.llm_attempt_count = maxAttempts;
|
|
1582
1662
|
throw lastError;
|
|
1583
1663
|
}
|
|
1664
|
+
|
|
1665
|
+
export async function callScreeningLlm(args = {}) {
|
|
1666
|
+
const providers = normalizeLlmProviderConfigs(args.config || {});
|
|
1667
|
+
if (providers.length <= 1) {
|
|
1668
|
+
return callScreeningLlmWithProvider({
|
|
1669
|
+
...args,
|
|
1670
|
+
config: {
|
|
1671
|
+
...(providers[0] || args.config || {}),
|
|
1672
|
+
llmProviderCount: 1
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const providerFailures = [];
|
|
1678
|
+
let lastError = null;
|
|
1679
|
+
for (let index = 0; index < providers.length; index += 1) {
|
|
1680
|
+
const providerConfig = {
|
|
1681
|
+
...providers[index],
|
|
1682
|
+
llmProviderIndex: index,
|
|
1683
|
+
llmProviderCount: providers.length
|
|
1684
|
+
};
|
|
1685
|
+
try {
|
|
1686
|
+
const previousAttempts = providerFailures.reduce((sum, item) => sum + (Number(item.attempts) || 0), 0);
|
|
1687
|
+
const result = await callScreeningLlmWithProvider({
|
|
1688
|
+
...args,
|
|
1689
|
+
config: providerConfig
|
|
1690
|
+
});
|
|
1691
|
+
const providerAttempts = Number(result.provider_attempt_count ?? result.attempt_count) || 0;
|
|
1692
|
+
return {
|
|
1693
|
+
...result,
|
|
1694
|
+
attempt_count: previousAttempts + providerAttempts,
|
|
1695
|
+
llm_model_failures: providerFailures,
|
|
1696
|
+
fallback_count: providerFailures.length
|
|
1697
|
+
};
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
lastError = error;
|
|
1700
|
+
providerFailures.push(compactLlmProviderFailure(error, providerConfig, index));
|
|
1701
|
+
if (index < providers.length - 1) {
|
|
1702
|
+
await sleepMs(Math.min(1500, 250 * (index + 1)));
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
const totalAttempts = providerFailures.reduce((sum, item) => sum + (Number(item.attempts) || 0), 0);
|
|
1708
|
+
const finalError = new Error(
|
|
1709
|
+
`All configured LLM models failed (${providers.length}); last error: ${lastError?.message || "unknown error"}`
|
|
1710
|
+
);
|
|
1711
|
+
finalError.cause = lastError || null;
|
|
1712
|
+
finalError.llm_provider_failures = providerFailures;
|
|
1713
|
+
finalError.llm_model_failures = providerFailures;
|
|
1714
|
+
finalError.llm_attempt_count = totalAttempts;
|
|
1715
|
+
finalError.image_input_count = lastError?.image_input_count || 0;
|
|
1716
|
+
finalError.image_inputs = lastError?.image_inputs || [];
|
|
1717
|
+
throw finalError;
|
|
1718
|
+
}
|
|
@@ -109,6 +109,8 @@ function compactLlmResult(llmResult) {
|
|
|
109
109
|
finish_reason: llmResult.finish_reason || null,
|
|
110
110
|
image_input_count: llmResult.image_input_count || 0,
|
|
111
111
|
attempt_count: llmResult.attempt_count || 0,
|
|
112
|
+
fallback_count: llmResult.fallback_count || 0,
|
|
113
|
+
llm_model_failures: Array.isArray(llmResult.llm_model_failures) ? llmResult.llm_model_failures : [],
|
|
112
114
|
error: llmResult.error || null
|
|
113
115
|
};
|
|
114
116
|
}
|
|
@@ -287,6 +289,9 @@ function createFailedLlmResult(error) {
|
|
|
287
289
|
decision_cot: "",
|
|
288
290
|
reasoning_content: "",
|
|
289
291
|
raw_model_output: "",
|
|
292
|
+
attempt_count: Number(error?.llm_attempt_count) || 0,
|
|
293
|
+
fallback_count: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures.length : 0,
|
|
294
|
+
llm_model_failures: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures : [],
|
|
290
295
|
error: error?.message || String(error || "unknown"),
|
|
291
296
|
screened_at: new Date().toISOString()
|
|
292
297
|
};
|
|
@@ -1638,6 +1643,7 @@ export function createChatRunService({
|
|
|
1638
1643
|
close_resume: closeResume,
|
|
1639
1644
|
request_resume_for_passed: Boolean(requestResumeForPassed),
|
|
1640
1645
|
dry_run_request_cv: Boolean(dryRunRequestCv),
|
|
1646
|
+
greeting_text: greetingText,
|
|
1641
1647
|
cv_acquisition_mode: cvAcquisitionMode,
|
|
1642
1648
|
call_llm_on_image: Boolean(callLlmOnImage),
|
|
1643
1649
|
screening_mode: normalizedScreeningMode,
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
closeRecommendDetail,
|
|
42
42
|
createRecommendDetailNetworkRecorder,
|
|
43
43
|
extractRecommendDetailCandidate,
|
|
44
|
+
isStaleRecommendNodeError,
|
|
44
45
|
openRecommendCardDetailWithFreshRetry,
|
|
45
46
|
waitForRecommendDetailNetworkEvents
|
|
46
47
|
} from "./detail.js";
|
|
@@ -381,6 +382,7 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
|
|
|
381
382
|
export function isRecoverableImageCaptureError(error) {
|
|
382
383
|
const code = String(error?.code || "");
|
|
383
384
|
if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
|
|
385
|
+
if (isStaleRecommendNodeError(error)) return true;
|
|
384
386
|
return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
|
|
385
387
|
}
|
|
386
388
|
|
|
@@ -420,7 +422,7 @@ export function createRecoverableImageCaptureEvidence(error, {
|
|
|
420
422
|
llm_total_byte_length: 0,
|
|
421
423
|
llm_original_total_byte_length: 0,
|
|
422
424
|
llm_composition_error: null,
|
|
423
|
-
error_code: error?.code || "IMAGE_CAPTURE_FAILED",
|
|
425
|
+
error_code: error?.code || (isStaleRecommendNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
|
|
424
426
|
error: error?.message || String(error || "Image capture failed"),
|
|
425
427
|
file_paths: filePaths,
|
|
426
428
|
llm_file_paths: []
|
|
@@ -438,6 +440,27 @@ function createImageCaptureFailureScreening(candidate, error) {
|
|
|
438
440
|
};
|
|
439
441
|
}
|
|
440
442
|
|
|
443
|
+
export function isRecoverableRecommendDetailError(error) {
|
|
444
|
+
return isStaleRecommendNodeError(error);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function compactRecoverableDetailError(error) {
|
|
448
|
+
return compactError(error, isStaleRecommendNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function createRecoverableDetailFailureScreening(candidate, error) {
|
|
452
|
+
return {
|
|
453
|
+
status: "fail",
|
|
454
|
+
passed: false,
|
|
455
|
+
score: 0,
|
|
456
|
+
reasons: isStaleRecommendNodeError(error)
|
|
457
|
+
? ["detail_open_failed", "stale_node"]
|
|
458
|
+
: ["detail_open_failed"],
|
|
459
|
+
error: compactRecoverableDetailError(error),
|
|
460
|
+
candidate
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
441
464
|
export async function runRecommendWorkflow({
|
|
442
465
|
client,
|
|
443
466
|
targetUrl = "",
|
|
@@ -746,139 +769,149 @@ export async function runRecommendWorkflow({
|
|
|
746
769
|
|
|
747
770
|
let screeningCandidate = cardCandidate;
|
|
748
771
|
let detailResult = null;
|
|
772
|
+
let recoverableDetailError = null;
|
|
749
773
|
if (index < effectiveDetailLimit) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
|
|
765
|
-
cardNodeId = openedDetail.card_node_id || cardNodeId;
|
|
766
|
-
cardCandidate = openedDetail.card_candidate || cardCandidate;
|
|
767
|
-
screeningCandidate = cardCandidate;
|
|
768
|
-
const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
|
|
769
|
-
const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
|
|
770
|
-
waitForRecommendDetailNetworkEvents,
|
|
771
|
-
networkRecorder,
|
|
772
|
-
{
|
|
773
|
-
waitPlan,
|
|
774
|
-
minCount: 1,
|
|
775
|
-
requireLoaded: true,
|
|
776
|
-
intervalMs: 120
|
|
777
|
-
}
|
|
778
|
-
));
|
|
779
|
-
if (networkWait?.elapsed_ms != null) {
|
|
780
|
-
timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
|
|
781
|
-
}
|
|
782
|
-
detailResult = await extractRecommendDetailCandidate(client, {
|
|
783
|
-
cardCandidate,
|
|
784
|
-
cardNodeId,
|
|
785
|
-
detailState: openedDetail.detail_state,
|
|
786
|
-
networkEvents: networkRecorder.events,
|
|
787
|
-
targetUrl,
|
|
788
|
-
closeDetail: false,
|
|
789
|
-
networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
|
|
790
|
-
networkParseIntervalMs: 250
|
|
791
|
-
});
|
|
792
|
-
addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
|
|
793
|
-
|
|
794
|
-
const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
|
|
795
|
-
let source = "network";
|
|
796
|
-
let imageEvidence = null;
|
|
797
|
-
if (parsedNetworkProfileCount > 0) {
|
|
798
|
-
recordCvNetworkHit(cvAcquisitionState, {
|
|
799
|
-
parsedNetworkProfileCount,
|
|
800
|
-
waitResult: networkWait
|
|
774
|
+
try {
|
|
775
|
+
await runControl.waitIfPaused();
|
|
776
|
+
runControl.throwIfCanceled();
|
|
777
|
+
runControl.setPhase("recommend:detail");
|
|
778
|
+
rootState = await ensureRecommendViewport(rootState, "detail");
|
|
779
|
+
networkRecorder.clear();
|
|
780
|
+
const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
|
|
781
|
+
cardNodeId,
|
|
782
|
+
candidateKey,
|
|
783
|
+
cardCandidate,
|
|
784
|
+
rootState,
|
|
785
|
+
targetUrl,
|
|
786
|
+
retryTimeoutMs: 8000,
|
|
787
|
+
maxAttempts: 3
|
|
801
788
|
});
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
filePath: imageEvidencePath,
|
|
817
|
-
format: "jpeg",
|
|
818
|
-
quality: 72,
|
|
819
|
-
optimize: true,
|
|
820
|
-
resizeMaxWidth: 1100,
|
|
821
|
-
captureViewport: true,
|
|
822
|
-
padding: 4,
|
|
823
|
-
maxScreenshots: maxImagePages,
|
|
824
|
-
wheelDeltaY: imageWheelDeltaY,
|
|
825
|
-
settleMs: 350,
|
|
826
|
-
scrollMethod: "dom-anchor-fallback-input",
|
|
827
|
-
stepTimeoutMs: 45000,
|
|
828
|
-
totalTimeoutMs: 90000,
|
|
829
|
-
duplicateStopCount: 1,
|
|
830
|
-
skipDuplicateScreenshots: true,
|
|
831
|
-
composeForLlm: true,
|
|
832
|
-
llmPagesPerImage: 3,
|
|
833
|
-
llmResizeMaxWidth: 1100,
|
|
834
|
-
llmQuality: 72,
|
|
835
|
-
metadata: {
|
|
836
|
-
domain: "recommend",
|
|
837
|
-
capture_mode: "scroll_sequence",
|
|
838
|
-
acquisition_reason: "network_miss_image_fallback",
|
|
839
|
-
run_candidate_index: index,
|
|
840
|
-
candidate_key: candidateKey
|
|
841
|
-
}
|
|
842
|
-
}));
|
|
843
|
-
source = "image";
|
|
844
|
-
} catch (error) {
|
|
845
|
-
if (!isRecoverableImageCaptureError(error)) throw error;
|
|
846
|
-
imageEvidence = createRecoverableImageCaptureEvidence(error, {
|
|
847
|
-
elapsedMs: timings.screenshot_capture_ms,
|
|
848
|
-
filePath: imageEvidencePath,
|
|
849
|
-
extension: "jpg",
|
|
850
|
-
maxScreenshots: maxImagePages
|
|
851
|
-
});
|
|
852
|
-
source = "image_capture_failed";
|
|
789
|
+
addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
|
|
790
|
+
addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
|
|
791
|
+
cardNodeId = openedDetail.card_node_id || cardNodeId;
|
|
792
|
+
cardCandidate = openedDetail.card_candidate || cardCandidate;
|
|
793
|
+
screeningCandidate = cardCandidate;
|
|
794
|
+
const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
|
|
795
|
+
const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
|
|
796
|
+
waitForRecommendDetailNetworkEvents,
|
|
797
|
+
networkRecorder,
|
|
798
|
+
{
|
|
799
|
+
waitPlan,
|
|
800
|
+
minCount: 1,
|
|
801
|
+
requireLoaded: true,
|
|
802
|
+
intervalMs: 120
|
|
853
803
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
804
|
+
));
|
|
805
|
+
if (networkWait?.elapsed_ms != null) {
|
|
806
|
+
timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
|
|
807
|
+
}
|
|
808
|
+
detailResult = await extractRecommendDetailCandidate(client, {
|
|
809
|
+
cardCandidate,
|
|
810
|
+
cardNodeId,
|
|
811
|
+
detailState: openedDetail.detail_state,
|
|
812
|
+
networkEvents: networkRecorder.events,
|
|
813
|
+
targetUrl,
|
|
814
|
+
closeDetail: false,
|
|
815
|
+
networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
|
|
816
|
+
networkParseIntervalMs: 250
|
|
817
|
+
});
|
|
818
|
+
addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
|
|
819
|
+
|
|
820
|
+
const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
|
|
821
|
+
let source = "network";
|
|
822
|
+
let imageEvidence = null;
|
|
823
|
+
if (parsedNetworkProfileCount > 0) {
|
|
824
|
+
recordCvNetworkHit(cvAcquisitionState, {
|
|
866
825
|
parsedNetworkProfileCount,
|
|
867
826
|
waitResult: networkWait
|
|
868
827
|
});
|
|
828
|
+
} else {
|
|
829
|
+
const captureNodeId = openedDetail.detail_state?.popup?.node_id
|
|
830
|
+
|| openedDetail.detail_state?.resumeIframe?.node_id
|
|
831
|
+
|| null;
|
|
832
|
+
if (captureNodeId) {
|
|
833
|
+
const imageEvidencePath = imageEvidenceFilePath({
|
|
834
|
+
imageOutputDir,
|
|
835
|
+
domain: "recommend",
|
|
836
|
+
runId: runControl?.runId,
|
|
837
|
+
index,
|
|
838
|
+
extension: "jpg"
|
|
839
|
+
});
|
|
840
|
+
try {
|
|
841
|
+
imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
|
|
842
|
+
filePath: imageEvidencePath,
|
|
843
|
+
format: "jpeg",
|
|
844
|
+
quality: 72,
|
|
845
|
+
optimize: true,
|
|
846
|
+
resizeMaxWidth: 1100,
|
|
847
|
+
captureViewport: true,
|
|
848
|
+
padding: 4,
|
|
849
|
+
maxScreenshots: maxImagePages,
|
|
850
|
+
wheelDeltaY: imageWheelDeltaY,
|
|
851
|
+
settleMs: 350,
|
|
852
|
+
scrollMethod: "dom-anchor-fallback-input",
|
|
853
|
+
stepTimeoutMs: 45000,
|
|
854
|
+
totalTimeoutMs: 90000,
|
|
855
|
+
duplicateStopCount: 1,
|
|
856
|
+
skipDuplicateScreenshots: true,
|
|
857
|
+
composeForLlm: true,
|
|
858
|
+
llmPagesPerImage: 3,
|
|
859
|
+
llmResizeMaxWidth: 1100,
|
|
860
|
+
llmQuality: 72,
|
|
861
|
+
metadata: {
|
|
862
|
+
domain: "recommend",
|
|
863
|
+
capture_mode: "scroll_sequence",
|
|
864
|
+
acquisition_reason: "network_miss_image_fallback",
|
|
865
|
+
run_candidate_index: index,
|
|
866
|
+
candidate_key: candidateKey
|
|
867
|
+
}
|
|
868
|
+
}));
|
|
869
|
+
source = "image";
|
|
870
|
+
} catch (error) {
|
|
871
|
+
if (!isRecoverableImageCaptureError(error)) throw error;
|
|
872
|
+
imageEvidence = createRecoverableImageCaptureEvidence(error, {
|
|
873
|
+
elapsedMs: timings.screenshot_capture_ms,
|
|
874
|
+
filePath: imageEvidencePath,
|
|
875
|
+
extension: "jpg",
|
|
876
|
+
maxScreenshots: maxImagePages
|
|
877
|
+
});
|
|
878
|
+
source = "image_capture_failed";
|
|
879
|
+
}
|
|
880
|
+
recordCvImageFallback(cvAcquisitionState, {
|
|
881
|
+
reason: source === "image_capture_failed"
|
|
882
|
+
? "network_miss_image_capture_failed"
|
|
883
|
+
: "network_miss_image_fallback",
|
|
884
|
+
parsedNetworkProfileCount,
|
|
885
|
+
waitResult: networkWait,
|
|
886
|
+
imageEvidence
|
|
887
|
+
});
|
|
888
|
+
} else {
|
|
889
|
+
source = "missing_capture_node";
|
|
890
|
+
recordCvNetworkMiss(cvAcquisitionState, {
|
|
891
|
+
reason: "network_miss_no_capture_node",
|
|
892
|
+
parsedNetworkProfileCount,
|
|
893
|
+
waitResult: networkWait
|
|
894
|
+
});
|
|
895
|
+
}
|
|
869
896
|
}
|
|
870
|
-
}
|
|
871
897
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
898
|
+
detailResult.image_evidence = imageEvidence;
|
|
899
|
+
detailResult.cv_acquisition = {
|
|
900
|
+
source,
|
|
901
|
+
mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
|
|
902
|
+
wait_plan: waitPlan,
|
|
903
|
+
network_wait: networkWait,
|
|
904
|
+
parsed_network_profile_count: parsedNetworkProfileCount,
|
|
905
|
+
image_evidence: summarizeImageEvidence(imageEvidence)
|
|
906
|
+
};
|
|
907
|
+
screeningCandidate = detailResult.candidate;
|
|
908
|
+
} catch (error) {
|
|
909
|
+
if (!isRecoverableRecommendDetailError(error)) throw error;
|
|
910
|
+
recoverableDetailError = error;
|
|
911
|
+
detailResult = null;
|
|
912
|
+
timings.detail_recovered_error = compactRecoverableDetailError(error);
|
|
913
|
+
await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
|
|
914
|
+
}
|
|
882
915
|
}
|
|
883
916
|
|
|
884
917
|
await runControl.waitIfPaused();
|
|
@@ -886,7 +919,7 @@ export async function runRecommendWorkflow({
|
|
|
886
919
|
runControl.setPhase("recommend:screening");
|
|
887
920
|
let llmResult = null;
|
|
888
921
|
if (useLlmScreening) {
|
|
889
|
-
if (detailResult?.image_evidence?.ok === false) {
|
|
922
|
+
if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
|
|
890
923
|
llmResult = null;
|
|
891
924
|
} else if (!llmConfig) {
|
|
892
925
|
llmResult = createMissingLlmConfigResult();
|
|
@@ -910,7 +943,9 @@ export async function runRecommendWorkflow({
|
|
|
910
943
|
}
|
|
911
944
|
if (detailResult) detailResult.llm_result = llmResult;
|
|
912
945
|
}
|
|
913
|
-
const screening =
|
|
946
|
+
const screening = recoverableDetailError
|
|
947
|
+
? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
|
|
948
|
+
: detailResult?.image_evidence?.ok === false
|
|
914
949
|
? createImageCaptureFailureScreening(screeningCandidate, {
|
|
915
950
|
code: detailResult.image_evidence.error_code,
|
|
916
951
|
message: detailResult.image_evidence.error
|
|
@@ -959,7 +994,9 @@ export async function runRecommendWorkflow({
|
|
|
959
994
|
screening: compactScreening(screening),
|
|
960
995
|
action_discovery: compactActionDiscovery(actionDiscovery),
|
|
961
996
|
post_action: postActionResult,
|
|
962
|
-
error:
|
|
997
|
+
error: recoverableDetailError
|
|
998
|
+
? compactRecoverableDetailError(recoverableDetailError)
|
|
999
|
+
: detailResult?.image_evidence?.ok === false
|
|
963
1000
|
? compactError({
|
|
964
1001
|
code: detailResult.image_evidence.error_code,
|
|
965
1002
|
message: detailResult.image_evidence.error
|
|
@@ -987,6 +1024,8 @@ export async function runRecommendWorkflow({
|
|
|
987
1024
|
greet_count: greetCount,
|
|
988
1025
|
post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
|
|
989
1026
|
image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
|
|
1027
|
+
detail_open_failed: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "DETAIL_OPEN_FAILED").length,
|
|
1028
|
+
transient_recovered: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_TIMEOUT" || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT").length,
|
|
990
1029
|
unique_seen: compactInfiniteListState(listState).seen_count,
|
|
991
1030
|
scroll_count: compactInfiniteListState(listState).scroll_count,
|
|
992
1031
|
refresh_rounds: refreshRounds,
|
|
@@ -1050,6 +1089,9 @@ export async function runRecommendWorkflow({
|
|
|
1050
1089
|
screened: results.length,
|
|
1051
1090
|
detail_opened: results.filter((item) => item.detail).length,
|
|
1052
1091
|
llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
|
|
1092
|
+
detail_open_failed: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "DETAIL_OPEN_FAILED").length,
|
|
1093
|
+
image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
|
|
1094
|
+
transient_recovered: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_TIMEOUT" || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT").length,
|
|
1053
1095
|
passed: results.filter((item) => item.screening.passed).length,
|
|
1054
1096
|
greet_count: greetCount,
|
|
1055
1097
|
post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
|
package/src/recommend-mcp.js
CHANGED
|
@@ -288,6 +288,66 @@ function completionReason(status) {
|
|
|
288
288
|
return null;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
function normalizeErrorText(error = {}) {
|
|
292
|
+
return normalizeText([
|
|
293
|
+
error?.code || "",
|
|
294
|
+
error?.message || error || ""
|
|
295
|
+
].join(" "));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function classifyRecommendRecovery(error = {}) {
|
|
299
|
+
const text = normalizeErrorText(error);
|
|
300
|
+
if (!text) return null;
|
|
301
|
+
if (/BOSS_LOGIN_REQUIRED/i.test(text)) return "login_required";
|
|
302
|
+
if (/Could not find node with given id|No node with given id|Node is detached|Cannot find node|DETAIL_STALE_NODE|IMAGE_CAPTURE_STALE_NODE/i.test(text)) {
|
|
303
|
+
return "transient_stale_dom";
|
|
304
|
+
}
|
|
305
|
+
if (/IMAGE_CAPTURE_TIMEOUT|IMAGE_CAPTURE_TOTAL_TIMEOUT|Image fallback capture timed out/i.test(text)) {
|
|
306
|
+
return "transient_image_capture";
|
|
307
|
+
}
|
|
308
|
+
if (/(?:aborted|abort|timeout|timed out|fetch failed|socket|network|ECONNRESET|ETIMEDOUT|EAI_AGAIN)/i.test(text)) {
|
|
309
|
+
return "transient_network_or_llm";
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function buildConstrainedAgentRecovery(snapshot = {}, meta = {}, artifacts = null) {
|
|
315
|
+
const error = snapshot?.error || snapshot?.result?.error || null;
|
|
316
|
+
const classification = classifyRecommendRecovery(error);
|
|
317
|
+
if (!classification) return null;
|
|
318
|
+
const canRestartSameRequest = classification !== "login_required";
|
|
319
|
+
return {
|
|
320
|
+
policy_version: 1,
|
|
321
|
+
classification,
|
|
322
|
+
safe_for_outer_ai_agent: true,
|
|
323
|
+
recommended_action: canRestartSameRequest
|
|
324
|
+
? "restart_same_recommend_request_only"
|
|
325
|
+
: "ask_user_to_login_then_retry_same_recommend_request",
|
|
326
|
+
package_requirement: "@reconcrap/boss-recommend-mcp@>=2.0.30",
|
|
327
|
+
run_id: snapshot?.runId || snapshot?.run_id || null,
|
|
328
|
+
retryable: true,
|
|
329
|
+
same_request_sources: {
|
|
330
|
+
instruction: "run.context.instruction",
|
|
331
|
+
confirmation: "run.context.confirmation",
|
|
332
|
+
overrides: "run.context.overrides",
|
|
333
|
+
follow_up: "run.context.follow_up"
|
|
334
|
+
},
|
|
335
|
+
constraints: [
|
|
336
|
+
"Do not change instruction, criteria, filters, job, page_scope, target_count, post_action, or max_greet_count.",
|
|
337
|
+
"Do not switch to search/recruit/chat and do not add follow_up.chat.",
|
|
338
|
+
"Do not summarize, translate, or rewrite criteria.",
|
|
339
|
+
"Do not ask the user to reconfirm business choices unless Boss login is required or the stored context is missing.",
|
|
340
|
+
"Use the same Chrome debug port and recommend page route."
|
|
341
|
+
],
|
|
342
|
+
artifacts: artifacts ? {
|
|
343
|
+
run_state_path: artifacts.run_state_path || null,
|
|
344
|
+
checkpoint_path: artifacts.checkpoint_path || null,
|
|
345
|
+
report_json: artifacts.report_json || null,
|
|
346
|
+
output_csv: artifacts.output_csv || null
|
|
347
|
+
} : null
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
291
351
|
function ensureRecommendRunArtifacts(snapshot) {
|
|
292
352
|
const artifacts = getRecommendRunArtifacts(snapshot?.runId || snapshot?.run_id);
|
|
293
353
|
if (!artifacts) return null;
|
|
@@ -319,6 +379,7 @@ function ensureRecommendRunArtifacts(snapshot) {
|
|
|
319
379
|
checkpoint,
|
|
320
380
|
error: snapshot.error || null,
|
|
321
381
|
last_message: snapshot.error?.message || snapshot.phase || snapshot.stage || null,
|
|
382
|
+
recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
|
|
322
383
|
summary: artifactSummary,
|
|
323
384
|
generated_at: new Date().toISOString()
|
|
324
385
|
});
|
|
@@ -397,6 +458,7 @@ function buildLegacyRecommendResult(snapshot) {
|
|
|
397
458
|
screen_params: clonePlain(meta.parsed?.screenParams || {}, {}),
|
|
398
459
|
target_count_semantics: TARGET_COUNT_SEMANTICS,
|
|
399
460
|
error: snapshot.error || null,
|
|
461
|
+
recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
|
|
400
462
|
results: resultRows
|
|
401
463
|
};
|
|
402
464
|
}
|
|
@@ -411,6 +473,7 @@ function normalizeRunSnapshot(snapshot) {
|
|
|
411
473
|
TERMINAL_STATUSES.has(snapshot.status)
|
|
412
474
|
|| snapshot.status === RUN_STATUS_PAUSED
|
|
413
475
|
) ? buildLegacyRecommendResult({ ...snapshot, progress }) : null;
|
|
476
|
+
const recovery = buildConstrainedAgentRecovery(snapshot, meta, artifacts);
|
|
414
477
|
const oldContext = {
|
|
415
478
|
workspace_root: meta.workspaceRoot || null,
|
|
416
479
|
instruction: meta.args?.instruction || "",
|
|
@@ -451,6 +514,7 @@ function normalizeRunSnapshot(snapshot) {
|
|
|
451
514
|
last_resumed_at: meta.lastResumedAt || null,
|
|
452
515
|
last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
|
|
453
516
|
},
|
|
517
|
+
recovery,
|
|
454
518
|
result: legacyResult,
|
|
455
519
|
artifacts
|
|
456
520
|
};
|
|
@@ -483,6 +547,7 @@ function persistRecommendRunSnapshot(snapshot, {
|
|
|
483
547
|
control: normalized.control,
|
|
484
548
|
resume: normalized.resume,
|
|
485
549
|
error: normalized.error,
|
|
550
|
+
recovery: normalized.recovery,
|
|
486
551
|
result: normalized.result,
|
|
487
552
|
summary: normalized.summary,
|
|
488
553
|
artifacts: normalized.artifacts
|