@reconcrap/boss-recommend-mcp 1.2.7 → 1.2.8
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/package.json
CHANGED
package/src/parser.js
CHANGED
|
@@ -82,9 +82,9 @@ const RECENT_NOT_VIEW_NEGATIVE_PATTERNS = [
|
|
|
82
82
|
/保留[^。;;\n]{0,12}14天/i
|
|
83
83
|
];
|
|
84
84
|
const TARGET_COUNT_PATTERNS = [
|
|
85
|
-
/目标(
|
|
86
|
-
/至少(
|
|
87
|
-
/(
|
|
85
|
+
/目标(?:处理|筛选|通过)?(?:人数|数量)?(?:为|是|:|:)?\s*(\d+)/i,
|
|
86
|
+
/至少(?:处理|筛选|通过)\s*(\d+)\s*(?:位|人)/i,
|
|
87
|
+
/(?:处理|筛选|通过)\s*(\d+)\s*(?:位|人)/i
|
|
88
88
|
];
|
|
89
89
|
const MAX_GREET_COUNT_PATTERNS = [
|
|
90
90
|
/最多(?:打招呼|沟通|联系)\s*(\d+)\s*(?:位|人|个)?/i,
|
|
@@ -95,7 +95,7 @@ const FILTER_CLAUSE_PATTERNS = [
|
|
|
95
95
|
/学历|学位|教育|初中及以下|中专|中技|高中|大专|专科|本科|硕士|研究生|博士/i,
|
|
96
96
|
/性别|男生|女生|男性|女性|男\b|女\b/i,
|
|
97
97
|
/近?14天(?:内)?没有|近?14天(?:内)?没看过|近?14天(?:内)?未查看|过滤[^。;;\n]{0,12}14天|排除[^。;;\n]{0,12}14天/i,
|
|
98
|
-
/目标(
|
|
98
|
+
/目标(?:处理|筛选|通过)?(?:人数|数量)?|至少(?:处理|筛选|通过)|(?:处理|筛选|通过)\s*\d+\s*(?:位|人)/i,
|
|
99
99
|
/最多(?:打招呼|沟通|联系)|(?:打招呼|沟通|联系)(?:上限|最多|不超过|至多)/i,
|
|
100
100
|
/收藏|打招呼|直接沟通|什么也不做|不做任何操作|不操作|仅筛选|只筛选/i
|
|
101
101
|
];
|
|
@@ -690,7 +690,7 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
|
|
|
690
690
|
if (needs_target_count_confirmation) {
|
|
691
691
|
pending_questions.push({
|
|
692
692
|
field: "target_count",
|
|
693
|
-
question: "
|
|
693
|
+
question: "本次目标通过人数是多少?可留空表示不设上限。",
|
|
694
694
|
value: targetCountResolution.proposed_target_count
|
|
695
695
|
});
|
|
696
696
|
}
|
package/src/pipeline.js
CHANGED
|
@@ -18,6 +18,17 @@ const FORCED_RECENT_NOT_VIEW_ON_SCREEN_RECOVERY = "近14天没有";
|
|
|
18
18
|
const MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS = 5;
|
|
19
19
|
const MAX_SEARCH_NO_IFRAME_RETRY_ATTEMPTS = 1;
|
|
20
20
|
const SEARCH_NO_IFRAME_RETRY_DELAY_MS = 1200;
|
|
21
|
+
const MAX_SEARCH_FILTER_AUTO_RETRY_ATTEMPTS = 2;
|
|
22
|
+
const SEARCH_FILTER_AUTO_RETRY_DELAY_MS = 1200;
|
|
23
|
+
const SEARCH_FILTER_RETRY_TOKENS = [
|
|
24
|
+
"FILTER_CONFIRM_FAILED",
|
|
25
|
+
"FILTER_DOM_CLASS_VERIFY_FAILED",
|
|
26
|
+
"RECOMMEND_FILTER_PANEL_UNAVAILABLE",
|
|
27
|
+
"RECOMMEND_FILTER_PANEL_NOT_READY",
|
|
28
|
+
"FILTER_PANEL_NOT_FOUND",
|
|
29
|
+
"FILTER_TRIGGER_NOT_FOUND",
|
|
30
|
+
"FILTER_PANEL_OPEN_FAILED"
|
|
31
|
+
];
|
|
21
32
|
const PAGE_SCOPE_TO_TAB_STATUS = {
|
|
22
33
|
recommend: "0",
|
|
23
34
|
latest: "1",
|
|
@@ -46,6 +57,20 @@ function sleep(ms) {
|
|
|
46
57
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
58
|
}
|
|
48
59
|
|
|
60
|
+
function shouldAutoRetrySearchFilterFailure(errorCode, errorMessage) {
|
|
61
|
+
const normalizedCode = normalizeText(errorCode).toUpperCase();
|
|
62
|
+
const normalizedMessage = normalizeText(errorMessage).toUpperCase();
|
|
63
|
+
const combined = `${normalizedCode} ${normalizedMessage}`.trim();
|
|
64
|
+
if (!combined) return false;
|
|
65
|
+
if (combined.includes("LOGIN_REQUIRED") || combined.includes("NO_RECOMMEND_IFRAME")) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (SEARCH_FILTER_RETRY_TOKENS.some((token) => combined.includes(token))) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return /^(RECOMMEND_)?FILTER_/.test(normalizedCode);
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
function normalizePageScope(value) {
|
|
50
75
|
const normalized = normalizeText(value).toLowerCase();
|
|
51
76
|
if (!normalized) return null;
|
|
@@ -336,7 +361,7 @@ function buildNeedConfirmationResponse(parsedResult) {
|
|
|
336
361
|
function buildFinalReviewQuestion({ searchParams, screenParams, selectedJob, selectedPage }) {
|
|
337
362
|
return {
|
|
338
363
|
field: "final_review",
|
|
339
|
-
question: "开始执行搜索和筛选前,请最后确认全部参数(岗位/页面/筛选条件/筛选 criteria
|
|
364
|
+
question: "开始执行搜索和筛选前,请最后确认全部参数(岗位/页面/筛选条件/筛选 criteria/目标通过人数/post_action/max_greet_count)无误。",
|
|
340
365
|
value: {
|
|
341
366
|
job: selectedJob?.title || selectedJob?.label || selectedJob?.value || null,
|
|
342
367
|
page_scope: selectedPage || "recommend",
|
|
@@ -893,6 +918,7 @@ export async function runRecommendPipeline(
|
|
|
893
918
|
let screenAutoRecoveryCount = 0;
|
|
894
919
|
let lastAutoRecovery = null;
|
|
895
920
|
let searchNoIframeRetryCount = 0;
|
|
921
|
+
let searchFilterRetryCount = 0;
|
|
896
922
|
let activeTabStatus = null;
|
|
897
923
|
let currentResumeConfig = {
|
|
898
924
|
checkpoint_path: resume?.checkpoint_path || null,
|
|
@@ -1039,6 +1065,29 @@ export async function runRecommendPipeline(
|
|
|
1039
1065
|
continue;
|
|
1040
1066
|
}
|
|
1041
1067
|
}
|
|
1068
|
+
if (
|
|
1069
|
+
shouldAutoRetrySearchFilterFailure(searchErrorCode, searchErrorMessage)
|
|
1070
|
+
&& searchFilterRetryCount < MAX_SEARCH_FILTER_AUTO_RETRY_ATTEMPTS
|
|
1071
|
+
) {
|
|
1072
|
+
searchFilterRetryCount += 1;
|
|
1073
|
+
const retryDelayMs = SEARCH_FILTER_AUTO_RETRY_DELAY_MS;
|
|
1074
|
+
lastAutoRecovery = {
|
|
1075
|
+
trigger: "SEARCH_FILTER_RETRY",
|
|
1076
|
+
attempt: searchFilterRetryCount,
|
|
1077
|
+
max_attempts: MAX_SEARCH_FILTER_AUTO_RETRY_ATTEMPTS,
|
|
1078
|
+
delay_ms: retryDelayMs,
|
|
1079
|
+
error_code: searchErrorCode || null,
|
|
1080
|
+
error_message: searchErrorMessage || null,
|
|
1081
|
+
action: "retry_search"
|
|
1082
|
+
};
|
|
1083
|
+
runtimeHooks.setStage(
|
|
1084
|
+
"search_recovery",
|
|
1085
|
+
`检测到筛选控件状态异常(${searchErrorCode || "UNKNOWN"}),等待 ${Math.round(retryDelayMs / 1000)} 秒后重试 search(第 ${searchFilterRetryCount}/${MAX_SEARCH_FILTER_AUTO_RETRY_ATTEMPTS} 次)。`
|
|
1086
|
+
);
|
|
1087
|
+
runtimeHooks.heartbeat("search_recovery", lastAutoRecovery);
|
|
1088
|
+
await sleep(retryDelayMs);
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1042
1091
|
return buildFailedResponse(
|
|
1043
1092
|
searchResult.error?.code || "RECOMMEND_SEARCH_FAILED",
|
|
1044
1093
|
searchResult.error?.message || "推荐页筛选执行失败。",
|
|
@@ -1057,6 +1106,7 @@ export async function runRecommendPipeline(
|
|
|
1057
1106
|
);
|
|
1058
1107
|
}
|
|
1059
1108
|
|
|
1109
|
+
searchFilterRetryCount = 0;
|
|
1060
1110
|
searchSummary = searchResult.summary || {};
|
|
1061
1111
|
if (isPauseRequested(runtimeHooks)) {
|
|
1062
1112
|
return buildPausedResponse("已在 screen 阶段开始前暂停 Recommend 流水线。", {
|
package/src/test-pipeline.js
CHANGED
|
@@ -1432,6 +1432,7 @@ async function testCompletedPipeline() {
|
|
|
1432
1432
|
}
|
|
1433
1433
|
|
|
1434
1434
|
async function testSearchFailure() {
|
|
1435
|
+
let searchCallCount = 0;
|
|
1435
1436
|
const result = await runRecommendPipeline(
|
|
1436
1437
|
{
|
|
1437
1438
|
workspaceRoot: process.cwd(),
|
|
@@ -1444,22 +1445,80 @@ async function testSearchFailure() {
|
|
|
1444
1445
|
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
1445
1446
|
ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
|
|
1446
1447
|
listRecommendJobs: async () => createJobListResult(),
|
|
1447
|
-
runRecommendSearchCli: async () =>
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1448
|
+
runRecommendSearchCli: async () => {
|
|
1449
|
+
searchCallCount += 1;
|
|
1450
|
+
return {
|
|
1451
|
+
ok: false,
|
|
1452
|
+
stdout: "",
|
|
1453
|
+
stderr: "boom",
|
|
1454
|
+
structured: null,
|
|
1455
|
+
error: {
|
|
1456
|
+
code: "RECOMMEND_FILTER_PANEL_UNAVAILABLE",
|
|
1457
|
+
message: "筛选面板不可用。"
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
},
|
|
1457
1461
|
runRecommendScreenCli: async () => ({ ok: true, summary: {} })
|
|
1458
1462
|
}
|
|
1459
1463
|
);
|
|
1460
1464
|
|
|
1461
1465
|
assert.equal(result.status, "FAILED");
|
|
1462
1466
|
assert.equal(result.error.code, "RECOMMEND_FILTER_PANEL_UNAVAILABLE");
|
|
1467
|
+
assert.equal(searchCallCount, 3);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
async function testSearchFilterFailureShouldRetryAndRecover() {
|
|
1471
|
+
let searchCallCount = 0;
|
|
1472
|
+
const result = await runRecommendPipeline(
|
|
1473
|
+
{
|
|
1474
|
+
workspaceRoot: process.cwd(),
|
|
1475
|
+
instruction: "test",
|
|
1476
|
+
confirmation: createJobConfirmedConfirmation(),
|
|
1477
|
+
overrides: {}
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
parseRecommendInstruction: () => createParsed(),
|
|
1481
|
+
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
1482
|
+
ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
|
|
1483
|
+
listRecommendJobs: async () => createJobListResult(),
|
|
1484
|
+
runRecommendSearchCli: async () => {
|
|
1485
|
+
searchCallCount += 1;
|
|
1486
|
+
if (searchCallCount === 1) {
|
|
1487
|
+
return {
|
|
1488
|
+
ok: false,
|
|
1489
|
+
stdout: "",
|
|
1490
|
+
stderr: "FILTER_CONFIRM_FAILED",
|
|
1491
|
+
structured: null,
|
|
1492
|
+
error: {
|
|
1493
|
+
code: "FILTER_CONFIRM_FAILED",
|
|
1494
|
+
message: "FILTER_CONFIRM_FAILED"
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
return {
|
|
1499
|
+
ok: true,
|
|
1500
|
+
summary: {
|
|
1501
|
+
candidate_count: 6,
|
|
1502
|
+
applied_filters: {}
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
},
|
|
1506
|
+
runRecommendScreenCli: async () => ({
|
|
1507
|
+
ok: true,
|
|
1508
|
+
summary: {
|
|
1509
|
+
processed_count: 3,
|
|
1510
|
+
passed_count: 2,
|
|
1511
|
+
skipped_count: 1,
|
|
1512
|
+
output_csv: "C:/temp/search-filter-retry.csv"
|
|
1513
|
+
}
|
|
1514
|
+
})
|
|
1515
|
+
}
|
|
1516
|
+
);
|
|
1517
|
+
|
|
1518
|
+
assert.equal(result.status, "COMPLETED");
|
|
1519
|
+
assert.equal(searchCallCount, 2);
|
|
1520
|
+
assert.equal(result.result.auto_recovery.trigger, "SEARCH_FILTER_RETRY");
|
|
1521
|
+
assert.equal(result.result.auto_recovery.attempt, 1);
|
|
1463
1522
|
}
|
|
1464
1523
|
|
|
1465
1524
|
async function testSearchNoIframeWithLoginShouldReturnLoginRequired() {
|
|
@@ -2005,6 +2064,7 @@ async function main() {
|
|
|
2005
2064
|
await testNeedFinalReviewConfirmationGate();
|
|
2006
2065
|
await testCompletedPipeline();
|
|
2007
2066
|
await testSearchFailure();
|
|
2067
|
+
await testSearchFilterFailureShouldRetryAndRecover();
|
|
2008
2068
|
await testSearchNoIframeWithLoginShouldReturnLoginRequired();
|
|
2009
2069
|
await testSearchNoIframeShouldRetryOnceWhenPageRecheckReady();
|
|
2010
2070
|
await testJobTriggerNotFoundShouldMapToLoginRequiredWhenRecheckShowsLogin();
|
|
@@ -15,6 +15,9 @@ const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
|
|
|
15
15
|
const MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES = 10;
|
|
16
16
|
const DEFAULT_VISION_MAX_IMAGE_PIXELS = 36000000;
|
|
17
17
|
const DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS = 30000000;
|
|
18
|
+
const DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS = 24000;
|
|
19
|
+
const DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS = 1200;
|
|
20
|
+
const DEFAULT_TEXT_MODEL_MAX_CHUNKS = 12;
|
|
18
21
|
let visionSharpFactory = null;
|
|
19
22
|
const PAGE_SCOPE_TAB_STATUS = {
|
|
20
23
|
recommend: "0",
|
|
@@ -333,6 +336,60 @@ function isVisionImageSizeLimitMessage(message) {
|
|
|
333
336
|
);
|
|
334
337
|
}
|
|
335
338
|
|
|
339
|
+
function isTextContextLimitMessage(message) {
|
|
340
|
+
const text = normalizeText(message).toLowerCase();
|
|
341
|
+
if (!text) return false;
|
|
342
|
+
return (
|
|
343
|
+
/context length|maximum context|too many tokens|max(?:imum)? token|prompt is too long|input is too long|token limit|上下文|超出.*token|超过.*token|输入过长/i.test(text)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function toStringArray(value, maxItems = 8) {
|
|
348
|
+
if (!Array.isArray(value)) return [];
|
|
349
|
+
const normalized = [];
|
|
350
|
+
for (const item of value) {
|
|
351
|
+
const text = normalizeText(item);
|
|
352
|
+
if (!text) continue;
|
|
353
|
+
normalized.push(text);
|
|
354
|
+
if (normalized.length >= maxItems) break;
|
|
355
|
+
}
|
|
356
|
+
return normalized;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function splitTextByChunks(text, chunkSize, overlap, maxChunks) {
|
|
360
|
+
const source = String(text || "");
|
|
361
|
+
if (!source) return [];
|
|
362
|
+
|
|
363
|
+
const safeChunkSize = Math.max(1000, parsePositiveInteger(chunkSize) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS);
|
|
364
|
+
const safeOverlap = Math.max(0, Math.min(safeChunkSize - 1, parsePositiveInteger(overlap) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS));
|
|
365
|
+
const safeMaxChunks = Math.max(1, parsePositiveInteger(maxChunks) || DEFAULT_TEXT_MODEL_MAX_CHUNKS);
|
|
366
|
+
|
|
367
|
+
const chunks = [];
|
|
368
|
+
let start = 0;
|
|
369
|
+
while (start < source.length && chunks.length < safeMaxChunks) {
|
|
370
|
+
const end = Math.min(source.length, start + safeChunkSize);
|
|
371
|
+
chunks.push({
|
|
372
|
+
text: source.slice(start, end),
|
|
373
|
+
start,
|
|
374
|
+
end
|
|
375
|
+
});
|
|
376
|
+
if (end >= source.length) break;
|
|
377
|
+
start = Math.max(0, end - safeOverlap);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (chunks.length > 0) {
|
|
381
|
+
const last = chunks[chunks.length - 1];
|
|
382
|
+
if (last.end < source.length) {
|
|
383
|
+
chunks[chunks.length - 1] = {
|
|
384
|
+
text: source.slice(last.start),
|
|
385
|
+
start: last.start,
|
|
386
|
+
end: source.length
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return chunks;
|
|
391
|
+
}
|
|
392
|
+
|
|
336
393
|
function normalizePostAction(value) {
|
|
337
394
|
const normalized = normalizeText(value).toLowerCase();
|
|
338
395
|
if (!normalized) return null;
|
|
@@ -795,7 +852,7 @@ async function promptMissingInputs(args) {
|
|
|
795
852
|
if (args.targetCount === null) {
|
|
796
853
|
const targetCount = await askWithValidation(
|
|
797
854
|
ask,
|
|
798
|
-
"
|
|
855
|
+
"请输入目标通过人数(--targetCount,可留空表示不设上限): ",
|
|
799
856
|
(value) => parsePositiveInteger(value),
|
|
800
857
|
{ allowEmpty: true }
|
|
801
858
|
);
|
|
@@ -3241,9 +3298,9 @@ class RecommendScreenCli {
|
|
|
3241
3298
|
DEFAULT_VISION_MAX_IMAGE_PIXELS
|
|
3242
3299
|
);
|
|
3243
3300
|
const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
|
|
3244
|
-
const preparedPrimary = await this.
|
|
3301
|
+
const preparedPrimary = await this.prepareVisionImageSegmentsForModel(imagePath, primaryLimit, "primary");
|
|
3245
3302
|
try {
|
|
3246
|
-
return await this.requestVisionModel(preparedPrimary.
|
|
3303
|
+
return await this.requestVisionModel(preparedPrimary.imagePaths);
|
|
3247
3304
|
} catch (error) {
|
|
3248
3305
|
if (!isVisionImageSizeLimitMessage(error?.message || "")) {
|
|
3249
3306
|
throw error;
|
|
@@ -3251,12 +3308,13 @@ class RecommendScreenCli {
|
|
|
3251
3308
|
log(
|
|
3252
3309
|
`[VISION] 检测到图片尺寸超限,准备降采样重试: ` +
|
|
3253
3310
|
`primary_limit=${primaryLimit} source=${preparedPrimary.source} ` +
|
|
3254
|
-
`source_pixels=${preparedPrimary.sourcePixels ?? "unknown"}`
|
|
3311
|
+
`source_pixels=${preparedPrimary.sourcePixels ?? "unknown"} ` +
|
|
3312
|
+
`segments=${preparedPrimary.imagePaths?.length || 1}`
|
|
3255
3313
|
);
|
|
3256
3314
|
}
|
|
3257
|
-
const preparedRetry = await this.
|
|
3315
|
+
const preparedRetry = await this.prepareVisionImageSegmentsForModel(imagePath, retryLimit, "retry");
|
|
3258
3316
|
try {
|
|
3259
|
-
return await this.requestVisionModel(preparedRetry.
|
|
3317
|
+
return await this.requestVisionModel(preparedRetry.imagePaths);
|
|
3260
3318
|
} catch (retryError) {
|
|
3261
3319
|
if (!isVisionImageSizeLimitMessage(retryError?.message || "")) {
|
|
3262
3320
|
throw retryError;
|
|
@@ -3267,11 +3325,106 @@ class RecommendScreenCli {
|
|
|
3267
3325
|
`primary_limit=${primaryLimit}; retry_limit=${retryLimit}; ` +
|
|
3268
3326
|
`source_pixels=${preparedRetry.sourcePixels ?? "unknown"}; ` +
|
|
3269
3327
|
`retry_pixels=${preparedRetry.currentPixels ?? "unknown"}; ` +
|
|
3328
|
+
`segments=${preparedRetry.imagePaths?.length || 1}; ` +
|
|
3270
3329
|
`last_error=${normalizeText(retryError?.message || retryError)}`
|
|
3271
3330
|
);
|
|
3272
3331
|
}
|
|
3273
3332
|
}
|
|
3274
3333
|
|
|
3334
|
+
async prepareVisionImageSegmentsForModel(imagePath, maxPixels, attemptTag = "primary") {
|
|
3335
|
+
const resolvedMaxPixels = parsePositiveInteger(maxPixels);
|
|
3336
|
+
if (!resolvedMaxPixels) {
|
|
3337
|
+
return {
|
|
3338
|
+
imagePaths: [imagePath],
|
|
3339
|
+
source: "no_limit",
|
|
3340
|
+
sourcePixels: null,
|
|
3341
|
+
currentPixels: null
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
let sharp;
|
|
3346
|
+
try {
|
|
3347
|
+
sharp = loadVisionSharp();
|
|
3348
|
+
} catch (error) {
|
|
3349
|
+
log(`[VISION] 加载 sharp 失败,回退到单图模式: ${error?.message || error}`);
|
|
3350
|
+
const single = await this.prepareVisionImageForModel(imagePath, resolvedMaxPixels, attemptTag);
|
|
3351
|
+
return {
|
|
3352
|
+
imagePaths: [single.imagePath],
|
|
3353
|
+
source: `single_${single.source}`,
|
|
3354
|
+
sourcePixels: single.sourcePixels ?? null,
|
|
3355
|
+
currentPixels: single.currentPixels ?? null
|
|
3356
|
+
};
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
let metadata;
|
|
3360
|
+
try {
|
|
3361
|
+
metadata = await sharp(imagePath).metadata();
|
|
3362
|
+
} catch (error) {
|
|
3363
|
+
log(`[VISION] 读取图片尺寸失败,回退到单图模式: ${error?.message || error}`);
|
|
3364
|
+
const single = await this.prepareVisionImageForModel(imagePath, resolvedMaxPixels, attemptTag);
|
|
3365
|
+
return {
|
|
3366
|
+
imagePaths: [single.imagePath],
|
|
3367
|
+
source: `single_${single.source}`,
|
|
3368
|
+
sourcePixels: single.sourcePixels ?? null,
|
|
3369
|
+
currentPixels: single.currentPixels ?? null
|
|
3370
|
+
};
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
const width = Number(metadata?.width || 0);
|
|
3374
|
+
const height = Number(metadata?.height || 0);
|
|
3375
|
+
const sourcePixels = width > 0 && height > 0 ? width * height : null;
|
|
3376
|
+
if (!sourcePixels || sourcePixels <= resolvedMaxPixels) {
|
|
3377
|
+
return {
|
|
3378
|
+
imagePaths: [imagePath],
|
|
3379
|
+
source: "within_limit",
|
|
3380
|
+
sourcePixels,
|
|
3381
|
+
currentPixels: sourcePixels
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
const maxTileHeight = Math.floor(resolvedMaxPixels / Math.max(1, width));
|
|
3386
|
+
if (!Number.isFinite(maxTileHeight) || maxTileHeight < 64) {
|
|
3387
|
+
const single = await this.prepareVisionImageForModel(imagePath, resolvedMaxPixels, attemptTag);
|
|
3388
|
+
return {
|
|
3389
|
+
imagePaths: [single.imagePath],
|
|
3390
|
+
source: `single_${single.source}`,
|
|
3391
|
+
sourcePixels: single.sourcePixels ?? sourcePixels,
|
|
3392
|
+
currentPixels: single.currentPixels ?? sourcePixels
|
|
3393
|
+
};
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
const parsedPath = path.parse(imagePath);
|
|
3397
|
+
const imagePaths = [];
|
|
3398
|
+
for (let top = 0, index = 0; top < height; top += maxTileHeight, index += 1) {
|
|
3399
|
+
const segmentHeight = Math.min(maxTileHeight, height - top);
|
|
3400
|
+
const segmentPath = path.join(
|
|
3401
|
+
parsedPath.dir,
|
|
3402
|
+
`${parsedPath.name}.${attemptTag}.seg${String(index + 1).padStart(3, "0")}.png`
|
|
3403
|
+
);
|
|
3404
|
+
await sharp(imagePath)
|
|
3405
|
+
.extract({
|
|
3406
|
+
left: 0,
|
|
3407
|
+
top,
|
|
3408
|
+
width,
|
|
3409
|
+
height: segmentHeight
|
|
3410
|
+
})
|
|
3411
|
+
.png()
|
|
3412
|
+
.toFile(segmentPath);
|
|
3413
|
+
imagePaths.push(segmentPath);
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
log(
|
|
3417
|
+
`[VISION] 长简历按分段输入模型: ${width}x${height}(${sourcePixels}) ` +
|
|
3418
|
+
`-> segments=${imagePaths.length}, max_pixels_per_segment=${resolvedMaxPixels}, attempt=${attemptTag}`
|
|
3419
|
+
);
|
|
3420
|
+
return {
|
|
3421
|
+
imagePaths,
|
|
3422
|
+
source: "segmented",
|
|
3423
|
+
sourcePixels,
|
|
3424
|
+
currentPixels: resolvedMaxPixels
|
|
3425
|
+
};
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3275
3428
|
async prepareVisionImageForModel(imagePath, maxPixels, attemptTag = "primary") {
|
|
3276
3429
|
const resolvedMaxPixels = parsePositiveInteger(maxPixels);
|
|
3277
3430
|
if (!resolvedMaxPixels) {
|
|
@@ -3360,7 +3513,38 @@ class RecommendScreenCli {
|
|
|
3360
3513
|
}
|
|
3361
3514
|
|
|
3362
3515
|
async requestVisionModel(imagePath) {
|
|
3363
|
-
const
|
|
3516
|
+
const imagePaths = Array.isArray(imagePath) ? imagePath.filter(Boolean) : [imagePath].filter(Boolean);
|
|
3517
|
+
if (imagePaths.length <= 0) {
|
|
3518
|
+
throw this.buildError("VISION_MODEL_FAILED", "No vision image input provided.");
|
|
3519
|
+
}
|
|
3520
|
+
const userContent = [
|
|
3521
|
+
{
|
|
3522
|
+
type: "text",
|
|
3523
|
+
text:
|
|
3524
|
+
"请根据以下标准判断候选人是否通过筛选。\n\n" +
|
|
3525
|
+
`筛选标准:\n${this.args.criteria}\n\n` +
|
|
3526
|
+
"你将收到候选人完整简历的一个或多个顺序分段图片。必须完整阅读全部分段后再判断," +
|
|
3527
|
+
"严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n\n" +
|
|
3528
|
+
"请返回严格 JSON: " +
|
|
3529
|
+
"{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
|
|
3530
|
+
}
|
|
3531
|
+
];
|
|
3532
|
+
for (let index = 0; index < imagePaths.length; index += 1) {
|
|
3533
|
+
const segmentPath = imagePaths[index];
|
|
3534
|
+
const imageBase64 = fs.readFileSync(segmentPath, "base64");
|
|
3535
|
+
if (imagePaths.length > 1) {
|
|
3536
|
+
userContent.push({
|
|
3537
|
+
type: "text",
|
|
3538
|
+
text: `简历分段 ${index + 1}/${imagePaths.length}`
|
|
3539
|
+
});
|
|
3540
|
+
}
|
|
3541
|
+
userContent.push({
|
|
3542
|
+
type: "image_url",
|
|
3543
|
+
image_url: {
|
|
3544
|
+
url: `data:image/png;base64,${imageBase64}`
|
|
3545
|
+
}
|
|
3546
|
+
});
|
|
3547
|
+
}
|
|
3364
3548
|
const rawBaseUrl = this.args.baseUrl;
|
|
3365
3549
|
log(`[callVisionModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
|
|
3366
3550
|
const baseUrl = String(rawBaseUrl || "").replace(/\/$/, "");
|
|
@@ -3370,22 +3554,13 @@ class RecommendScreenCli {
|
|
|
3370
3554
|
messages: [
|
|
3371
3555
|
{
|
|
3372
3556
|
role: "system",
|
|
3373
|
-
content:
|
|
3557
|
+
content:
|
|
3558
|
+
"你是一位严谨的招聘筛选助手。必须完整阅读所有输入材料,严禁臆造不存在的简历经历。" +
|
|
3559
|
+
"只能返回 JSON,不要输出任何额外文字。"
|
|
3374
3560
|
},
|
|
3375
3561
|
{
|
|
3376
3562
|
role: "user",
|
|
3377
|
-
content:
|
|
3378
|
-
{
|
|
3379
|
-
type: "text",
|
|
3380
|
-
text: `请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${this.args.criteria}\n\n你看到的是整份候选人简历长图。请返回严格 JSON: {\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\"}`
|
|
3381
|
-
},
|
|
3382
|
-
{
|
|
3383
|
-
type: "image_url",
|
|
3384
|
-
image_url: {
|
|
3385
|
-
url: `data:image/png;base64,${imageBase64}`
|
|
3386
|
-
}
|
|
3387
|
-
}
|
|
3388
|
-
]
|
|
3563
|
+
content: userContent
|
|
3389
3564
|
}
|
|
3390
3565
|
]
|
|
3391
3566
|
};
|
|
@@ -3410,15 +3585,79 @@ class RecommendScreenCli {
|
|
|
3410
3585
|
? json.choices[0].message.content.map((item) => item?.text || "").join("\n")
|
|
3411
3586
|
: json?.choices?.[0]?.message?.content || "";
|
|
3412
3587
|
const parsed = extractJsonObject(content);
|
|
3588
|
+
const reason = normalizeText(parsed.reason);
|
|
3589
|
+
const summary = normalizeText(parsed.summary || reason);
|
|
3590
|
+
const evidence = toStringArray(parsed.evidence);
|
|
3413
3591
|
return {
|
|
3414
3592
|
passed: parsed.passed === true,
|
|
3415
|
-
reason:
|
|
3416
|
-
summary:
|
|
3593
|
+
reason: reason || "未满足筛选标准。",
|
|
3594
|
+
summary: summary || reason || "未满足筛选标准。",
|
|
3595
|
+
evidence
|
|
3417
3596
|
};
|
|
3418
3597
|
}
|
|
3419
3598
|
|
|
3420
3599
|
async callTextModel(resumeText) {
|
|
3421
|
-
const
|
|
3600
|
+
const fullResumeText = String(resumeText || "");
|
|
3601
|
+
if (!normalizeText(fullResumeText)) {
|
|
3602
|
+
throw this.buildError("TEXT_MODEL_FAILED", "Resume text is empty.");
|
|
3603
|
+
}
|
|
3604
|
+
try {
|
|
3605
|
+
return await this.requestTextModel(fullResumeText, {
|
|
3606
|
+
chunkIndex: 1,
|
|
3607
|
+
chunkTotal: 1
|
|
3608
|
+
});
|
|
3609
|
+
} catch (error) {
|
|
3610
|
+
if (!isTextContextLimitMessage(error?.message || "")) {
|
|
3611
|
+
throw error;
|
|
3612
|
+
}
|
|
3613
|
+
log("[TEXT_MODEL] 检测到上下文长度限制,启用分段筛选模式。");
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
const chunkSize = parsePositiveInteger(process.env.BOSS_RECOMMEND_TEXT_CHUNK_SIZE_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS;
|
|
3617
|
+
const overlap = parsePositiveInteger(process.env.BOSS_RECOMMEND_TEXT_CHUNK_OVERLAP_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS;
|
|
3618
|
+
const maxChunks = parsePositiveInteger(process.env.BOSS_RECOMMEND_TEXT_MAX_CHUNKS) || DEFAULT_TEXT_MODEL_MAX_CHUNKS;
|
|
3619
|
+
const chunks = splitTextByChunks(fullResumeText, chunkSize, overlap, maxChunks);
|
|
3620
|
+
if (!chunks.length) {
|
|
3621
|
+
throw this.buildError("TEXT_MODEL_FAILED", "Resume text is empty after chunk split.");
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
const chunkResults = [];
|
|
3625
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
3626
|
+
const chunk = chunks[index];
|
|
3627
|
+
const result = await this.requestTextModel(chunk.text, {
|
|
3628
|
+
chunkIndex: index + 1,
|
|
3629
|
+
chunkTotal: chunks.length
|
|
3630
|
+
});
|
|
3631
|
+
chunkResults.push(result);
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
const passedChunks = chunkResults.filter((item) => item?.passed === true);
|
|
3635
|
+
if (passedChunks.length > 0) {
|
|
3636
|
+
const best = passedChunks[0];
|
|
3637
|
+
return {
|
|
3638
|
+
passed: true,
|
|
3639
|
+
reason: best.reason || `分段筛选命中(${best.chunkIndex}/${chunks.length})。`,
|
|
3640
|
+
summary: best.summary || best.reason || "分段筛选命中",
|
|
3641
|
+
evidence: Array.isArray(best.evidence) ? best.evidence : []
|
|
3642
|
+
};
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
const firstReason = chunkResults.map((item) => normalizeText(item?.reason)).find(Boolean);
|
|
3646
|
+
return {
|
|
3647
|
+
passed: false,
|
|
3648
|
+
reason: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
|
|
3649
|
+
summary: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
|
|
3650
|
+
evidence: []
|
|
3651
|
+
};
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
async requestTextModel(resumeText, options = {}) {
|
|
3655
|
+
const safeResumeText = String(resumeText || "");
|
|
3656
|
+
const chunkIndex = Number.isInteger(options.chunkIndex) && options.chunkIndex > 0 ? options.chunkIndex : 1;
|
|
3657
|
+
const chunkTotal = Number.isInteger(options.chunkTotal) && options.chunkTotal > 0 ? options.chunkTotal : 1;
|
|
3658
|
+
const chunkHint = chunkTotal > 1
|
|
3659
|
+
? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 passed=false。`
|
|
3660
|
+
: "";
|
|
3422
3661
|
const rawBaseUrl = this.args.baseUrl;
|
|
3423
3662
|
log(`[callTextModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
|
|
3424
3663
|
const baseUrl = String(rawBaseUrl || "").replace(/\/$/, "");
|
|
@@ -3428,11 +3667,21 @@ class RecommendScreenCli {
|
|
|
3428
3667
|
messages: [
|
|
3429
3668
|
{
|
|
3430
3669
|
role: "system",
|
|
3431
|
-
content:
|
|
3670
|
+
content:
|
|
3671
|
+
"你是一位严谨的招聘筛选助手。必须完整阅读输入内容,严禁编造不存在的简历经历。" +
|
|
3672
|
+
"只能返回 JSON,不要输出任何额外文字。"
|
|
3432
3673
|
},
|
|
3433
3674
|
{
|
|
3434
3675
|
role: "user",
|
|
3435
|
-
content:
|
|
3676
|
+
content:
|
|
3677
|
+
`请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${this.args.criteria}\n\n` +
|
|
3678
|
+
`简历内容:\n${safeResumeText}${chunkHint}\n\n` +
|
|
3679
|
+
"要求:\n" +
|
|
3680
|
+
"1) 必须完整阅读上面的全部简历文本。\n" +
|
|
3681
|
+
"2) 只能依据简历中真实出现的信息判断,严禁编造不存在的经历/项目/学历/公司。\n" +
|
|
3682
|
+
"3) 若证据不足,必须返回 passed=false。\n\n" +
|
|
3683
|
+
"请返回严格 JSON: " +
|
|
3684
|
+
"{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
|
|
3436
3685
|
}
|
|
3437
3686
|
]
|
|
3438
3687
|
};
|
|
@@ -3457,10 +3706,28 @@ class RecommendScreenCli {
|
|
|
3457
3706
|
? json.choices[0].message.content.map((item) => item?.text || "").join("\n")
|
|
3458
3707
|
: json?.choices?.[0]?.message?.content || "";
|
|
3459
3708
|
const parsed = extractJsonObject(content);
|
|
3709
|
+
const reason = normalizeText(parsed.reason);
|
|
3710
|
+
const summary = normalizeText(parsed.summary || reason);
|
|
3711
|
+
const normalizedResume = normalizeText(safeResumeText);
|
|
3712
|
+
const parsedEvidence = toStringArray(parsed.evidence);
|
|
3713
|
+
const evidence = parsedEvidence.filter((item) => {
|
|
3714
|
+
const normalizedEvidence = normalizeText(item);
|
|
3715
|
+
if (!normalizedEvidence) return false;
|
|
3716
|
+
return safeResumeText.includes(item) || normalizedResume.includes(normalizedEvidence);
|
|
3717
|
+
});
|
|
3718
|
+
let passed = parsed.passed === true;
|
|
3719
|
+
let finalReason = reason || (passed ? "满足筛选标准。" : "不满足筛选标准。");
|
|
3720
|
+
if (passed && evidence.length <= 0) {
|
|
3721
|
+
passed = false;
|
|
3722
|
+
finalReason = `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`;
|
|
3723
|
+
}
|
|
3460
3724
|
return {
|
|
3461
|
-
passed
|
|
3462
|
-
reason:
|
|
3463
|
-
summary:
|
|
3725
|
+
passed,
|
|
3726
|
+
reason: finalReason,
|
|
3727
|
+
summary: summary || finalReason,
|
|
3728
|
+
evidence,
|
|
3729
|
+
chunkIndex,
|
|
3730
|
+
chunkTotal
|
|
3464
3731
|
};
|
|
3465
3732
|
}
|
|
3466
3733
|
|
|
@@ -3686,8 +3953,13 @@ class RecommendScreenCli {
|
|
|
3686
3953
|
}
|
|
3687
3954
|
|
|
3688
3955
|
state = await this.getDetailClosedState();
|
|
3689
|
-
|
|
3690
|
-
|
|
3956
|
+
const listState = await this.evaluate(jsGetListState);
|
|
3957
|
+
if (listState?.ok) {
|
|
3958
|
+
log(`[关闭详情] 连续 ESC 后仍未确认关闭(${state?.reason || "unknown"}),但候选人列表已可用,按就绪状态继续。`);
|
|
3959
|
+
return true;
|
|
3960
|
+
}
|
|
3961
|
+
log(`[关闭详情] 连续 ESC 后仍未确认关闭(${state?.reason || "unknown"}),且候选人列表未恢复,判定关闭失败。`);
|
|
3962
|
+
return false;
|
|
3691
3963
|
}
|
|
3692
3964
|
|
|
3693
3965
|
async waitForListReady(maxRounds = 30) {
|
|
@@ -3756,7 +4028,10 @@ class RecommendScreenCli {
|
|
|
3756
4028
|
|
|
3757
4029
|
const restoredFromCheckpoint = this.loadCheckpointIfNeeded();
|
|
3758
4030
|
if (restoredFromCheckpoint) {
|
|
3759
|
-
log(
|
|
4031
|
+
log(
|
|
4032
|
+
`[恢复] 已从 checkpoint 恢复,已处理 ${this.processedCount} 位候选人,` +
|
|
4033
|
+
`其中通过 ${this.passedCandidates.length} 位。`
|
|
4034
|
+
);
|
|
3760
4035
|
}
|
|
3761
4036
|
|
|
3762
4037
|
await this.connect();
|
|
@@ -3790,7 +4065,7 @@ class RecommendScreenCli {
|
|
|
3790
4065
|
}
|
|
3791
4066
|
|
|
3792
4067
|
let pageExhaustion = null;
|
|
3793
|
-
while (!this.args.targetCount || this.
|
|
4068
|
+
while (!this.args.targetCount || this.passedCandidates.length < this.args.targetCount) {
|
|
3794
4069
|
if (this.shouldPauseAtBoundary()) {
|
|
3795
4070
|
this.saveCsv();
|
|
3796
4071
|
this.saveCheckpoint();
|
|
@@ -4048,10 +4323,10 @@ class RecommendScreenCli {
|
|
|
4048
4323
|
}
|
|
4049
4324
|
}
|
|
4050
4325
|
|
|
4051
|
-
if (this.args.targetCount && this.
|
|
4326
|
+
if (this.args.targetCount && this.passedCandidates.length < this.args.targetCount) {
|
|
4052
4327
|
throw this.buildError(
|
|
4053
4328
|
"TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
|
|
4054
|
-
|
|
4329
|
+
`推荐列表已到底,但当前仅通过 ${this.passedCandidates.length} 位,尚未达到目标 ${this.args.targetCount} 位。`,
|
|
4055
4330
|
true,
|
|
4056
4331
|
{
|
|
4057
4332
|
partial_result: this.buildProgressSnapshot("page_exhausted_before_target_count"),
|
|
@@ -4070,11 +4345,11 @@ class RecommendScreenCli {
|
|
|
4070
4345
|
status: "COMPLETED",
|
|
4071
4346
|
result: {
|
|
4072
4347
|
...this.buildProgressSnapshot(
|
|
4073
|
-
this.args.targetCount && this.
|
|
4348
|
+
this.args.targetCount && this.passedCandidates.length >= this.args.targetCount
|
|
4074
4349
|
? "target_count_reached"
|
|
4075
4350
|
: "page_exhausted"
|
|
4076
4351
|
),
|
|
4077
|
-
completion_reason: this.args.targetCount && this.
|
|
4352
|
+
completion_reason: this.args.targetCount && this.passedCandidates.length >= this.args.targetCount
|
|
4078
4353
|
? "target_count_reached"
|
|
4079
4354
|
: "page_exhausted",
|
|
4080
4355
|
}
|
|
@@ -124,6 +124,30 @@ class FakeRecommendScreenCli extends RecommendScreenCli {
|
|
|
124
124
|
saveCheckpoint() {}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
class FakeDetailCloseProbeCli extends RecommendScreenCli {
|
|
128
|
+
constructor(args, options = {}) {
|
|
129
|
+
super(args);
|
|
130
|
+
this.listReady = options.listReady === true;
|
|
131
|
+
this.evaluateCallCount = 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async getDetailClosedState() {
|
|
135
|
+
return { closed: false, reason: "popup visible: .boss-popup__wrapper" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async evaluate() {
|
|
139
|
+
this.evaluateCallCount += 1;
|
|
140
|
+
if (this.evaluateCallCount >= 2) {
|
|
141
|
+
return this.listReady
|
|
142
|
+
? { ok: true, candidate_count: 1 }
|
|
143
|
+
: { ok: false, error: "LIST_NOT_READY" };
|
|
144
|
+
}
|
|
145
|
+
return { ok: false, error: "CLOSE_ACTION_NOOP" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async pressEsc() {}
|
|
149
|
+
}
|
|
150
|
+
|
|
127
151
|
function createResumeCaptureError(message = "Resume canvas not found") {
|
|
128
152
|
const error = new Error(message);
|
|
129
153
|
error.code = "RESUME_CAPTURE_FAILED";
|
|
@@ -306,6 +330,55 @@ async function testPageExhaustedWithoutTargetShouldStillComplete() {
|
|
|
306
330
|
assert.equal(result.result.completion_reason, "page_exhausted");
|
|
307
331
|
}
|
|
308
332
|
|
|
333
|
+
async function testTargetCountShouldStopWhenPassedCountReached() {
|
|
334
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-target-pass-stop-"));
|
|
335
|
+
const args = createArgs(tempDir);
|
|
336
|
+
args.targetCount = 1;
|
|
337
|
+
const first = { key: "pass-1", geek_id: "pass-1", name: "pass-1" };
|
|
338
|
+
const second = { key: "skip-2", geek_id: "skip-2", name: "skip-2" };
|
|
339
|
+
const cli = new FakeRecommendScreenCli(args, {
|
|
340
|
+
candidates: [first, second],
|
|
341
|
+
screeningByKey: new Map([
|
|
342
|
+
["pass-1", { passed: true, reason: "matched", summary: "matched" }],
|
|
343
|
+
["skip-2", { passed: false, reason: "not matched", summary: "not matched" }]
|
|
344
|
+
])
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const result = await cli.run();
|
|
348
|
+
assert.equal(result.status, "COMPLETED");
|
|
349
|
+
assert.equal(result.result.processed_count, 1);
|
|
350
|
+
assert.equal(result.result.passed_count, 1);
|
|
351
|
+
assert.equal(result.result.skipped_count, 0);
|
|
352
|
+
assert.equal(result.result.completion_reason, "target_count_reached");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function testTargetCountShouldNotTreatProcessedCountAsReached() {
|
|
356
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-target-pass-only-"));
|
|
357
|
+
const args = createArgs(tempDir);
|
|
358
|
+
args.targetCount = 1;
|
|
359
|
+
const first = { key: "skip-a", geek_id: "skip-a", name: "skip-a" };
|
|
360
|
+
const second = { key: "skip-b", geek_id: "skip-b", name: "skip-b" };
|
|
361
|
+
const cli = new FakeRecommendScreenCli(args, {
|
|
362
|
+
candidates: [first, second],
|
|
363
|
+
screeningByKey: new Map([
|
|
364
|
+
["skip-a", { passed: false, reason: "not matched", summary: "not matched" }],
|
|
365
|
+
["skip-b", { passed: false, reason: "not matched", summary: "not matched" }]
|
|
366
|
+
])
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await assert.rejects(
|
|
370
|
+
() => cli.run(),
|
|
371
|
+
(error) => {
|
|
372
|
+
assert.equal(error.code, "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED");
|
|
373
|
+
assert.equal(error.retryable, true);
|
|
374
|
+
assert.equal(error.partial_result?.processed_count, 2);
|
|
375
|
+
assert.equal(error.partial_result?.passed_count, 0);
|
|
376
|
+
assert.equal(error.partial_result?.completion_reason, "page_exhausted_before_target_count");
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
309
382
|
async function testFeaturedShouldUseNetworkResumeOnly() {
|
|
310
383
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-network-first-"));
|
|
311
384
|
const candidate = { key: "net-1", geek_id: "net-1", name: "network candidate" };
|
|
@@ -842,12 +915,152 @@ function testParseArgsShouldSupportLatestPageScope() {
|
|
|
842
915
|
assert.equal(parsed.port, 9222);
|
|
843
916
|
}
|
|
844
917
|
|
|
918
|
+
async function testCallTextModelShouldNotTruncateLongResume() {
|
|
919
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-text-full-"));
|
|
920
|
+
const cli = new RecommendScreenCli(createArgs(tempDir));
|
|
921
|
+
const marker = "__END_OF_RESUME_MARKER__";
|
|
922
|
+
const resumeText = `${"A".repeat(32000)}${marker}`;
|
|
923
|
+
const originalFetch = global.fetch;
|
|
924
|
+
let capturedUserContent = "";
|
|
925
|
+
global.fetch = async (_url, options = {}) => {
|
|
926
|
+
const payload = JSON.parse(String(options.body || "{}"));
|
|
927
|
+
capturedUserContent = String(payload?.messages?.[1]?.content || "");
|
|
928
|
+
return {
|
|
929
|
+
ok: true,
|
|
930
|
+
status: 200,
|
|
931
|
+
async json() {
|
|
932
|
+
return {
|
|
933
|
+
choices: [
|
|
934
|
+
{
|
|
935
|
+
message: {
|
|
936
|
+
content: "{\"passed\": false, \"reason\": \"not matched\", \"summary\": \"not matched\", \"evidence\": [\"A\"]}"
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
]
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
};
|
|
944
|
+
try {
|
|
945
|
+
const result = await cli.callTextModel(resumeText);
|
|
946
|
+
assert.equal(result.passed, false);
|
|
947
|
+
assert.equal(capturedUserContent.includes(marker), true);
|
|
948
|
+
} finally {
|
|
949
|
+
global.fetch = originalFetch;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function testCallTextModelShouldFallbackToChunkModeOnContextLimit() {
|
|
954
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-text-chunk-fallback-"));
|
|
955
|
+
const cli = new RecommendScreenCli(createArgs(tempDir));
|
|
956
|
+
const originalFetch = global.fetch;
|
|
957
|
+
const prevChunkSize = process.env.BOSS_RECOMMEND_TEXT_CHUNK_SIZE_CHARS;
|
|
958
|
+
const prevChunkOverlap = process.env.BOSS_RECOMMEND_TEXT_CHUNK_OVERLAP_CHARS;
|
|
959
|
+
const prevMaxChunks = process.env.BOSS_RECOMMEND_TEXT_MAX_CHUNKS;
|
|
960
|
+
process.env.BOSS_RECOMMEND_TEXT_CHUNK_SIZE_CHARS = "80";
|
|
961
|
+
process.env.BOSS_RECOMMEND_TEXT_CHUNK_OVERLAP_CHARS = "0";
|
|
962
|
+
process.env.BOSS_RECOMMEND_TEXT_MAX_CHUNKS = "6";
|
|
963
|
+
|
|
964
|
+
const passMarker = "PASS_MARKER_ABC";
|
|
965
|
+
const resumeText = `${"x".repeat(120)}${passMarker}${"y".repeat(120)}`;
|
|
966
|
+
let callCount = 0;
|
|
967
|
+
global.fetch = async (_url, options = {}) => {
|
|
968
|
+
callCount += 1;
|
|
969
|
+
if (callCount === 1) {
|
|
970
|
+
return {
|
|
971
|
+
ok: false,
|
|
972
|
+
status: 400,
|
|
973
|
+
async text() {
|
|
974
|
+
return "maximum context length exceeded";
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const payload = JSON.parse(String(options.body || "{}"));
|
|
980
|
+
const userContent = String(payload?.messages?.[1]?.content || "");
|
|
981
|
+
const passed = userContent.includes(passMarker);
|
|
982
|
+
const response = passed
|
|
983
|
+
? "{\"passed\": true, \"reason\": \"命中证据\", \"summary\": \"命中\", \"evidence\": [\"PASS_MARKER_ABC\"]}"
|
|
984
|
+
: "{\"passed\": false, \"reason\": \"本段证据不足\", \"summary\": \"不足\", \"evidence\": []}";
|
|
985
|
+
return {
|
|
986
|
+
ok: true,
|
|
987
|
+
status: 200,
|
|
988
|
+
async json() {
|
|
989
|
+
return {
|
|
990
|
+
choices: [
|
|
991
|
+
{
|
|
992
|
+
message: {
|
|
993
|
+
content: response
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
]
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
};
|
|
1001
|
+
try {
|
|
1002
|
+
const result = await cli.callTextModel(resumeText);
|
|
1003
|
+
assert.equal(result.passed, true);
|
|
1004
|
+
assert.equal(callCount >= 2, true);
|
|
1005
|
+
assert.equal(Array.isArray(result.evidence), true);
|
|
1006
|
+
} finally {
|
|
1007
|
+
global.fetch = originalFetch;
|
|
1008
|
+
if (prevChunkSize === undefined) {
|
|
1009
|
+
delete process.env.BOSS_RECOMMEND_TEXT_CHUNK_SIZE_CHARS;
|
|
1010
|
+
} else {
|
|
1011
|
+
process.env.BOSS_RECOMMEND_TEXT_CHUNK_SIZE_CHARS = prevChunkSize;
|
|
1012
|
+
}
|
|
1013
|
+
if (prevChunkOverlap === undefined) {
|
|
1014
|
+
delete process.env.BOSS_RECOMMEND_TEXT_CHUNK_OVERLAP_CHARS;
|
|
1015
|
+
} else {
|
|
1016
|
+
process.env.BOSS_RECOMMEND_TEXT_CHUNK_OVERLAP_CHARS = prevChunkOverlap;
|
|
1017
|
+
}
|
|
1018
|
+
if (prevMaxChunks === undefined) {
|
|
1019
|
+
delete process.env.BOSS_RECOMMEND_TEXT_MAX_CHUNKS;
|
|
1020
|
+
} else {
|
|
1021
|
+
process.env.BOSS_RECOMMEND_TEXT_MAX_CHUNKS = prevMaxChunks;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function testPrepareVisionImageSegmentsShouldSplitLongImage() {
|
|
1027
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-vision-segments-"));
|
|
1028
|
+
const cli = new RecommendScreenCli(createArgs(tempDir));
|
|
1029
|
+
const imagePath = path.join(tempDir, "long.png");
|
|
1030
|
+
await sharp({
|
|
1031
|
+
create: { width: 400, height: 1200, channels: 3, background: { r: 240, g: 240, b: 240 } }
|
|
1032
|
+
}).png().toFile(imagePath);
|
|
1033
|
+
|
|
1034
|
+
const prepared = await cli.prepareVisionImageSegmentsForModel(imagePath, 120000, "test");
|
|
1035
|
+
assert.equal(Array.isArray(prepared.imagePaths), true);
|
|
1036
|
+
assert.equal(prepared.imagePaths.length > 1, true);
|
|
1037
|
+
for (const segmentPath of prepared.imagePaths) {
|
|
1038
|
+
assert.equal(fs.existsSync(segmentPath), true);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function testCloseDetailPageShouldFailWhenDetailStillOpenAndListNotReady() {
|
|
1043
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-close-detail-fail-"));
|
|
1044
|
+
const cli = new FakeDetailCloseProbeCli(createArgs(tempDir), { listReady: false });
|
|
1045
|
+
const closed = await cli.closeDetailPage(1);
|
|
1046
|
+
assert.equal(closed, false);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function testCloseDetailPageShouldContinueWhenListReady() {
|
|
1050
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-close-detail-list-ready-"));
|
|
1051
|
+
const cli = new FakeDetailCloseProbeCli(createArgs(tempDir), { listReady: true });
|
|
1052
|
+
const closed = await cli.closeDetailPage(1);
|
|
1053
|
+
assert.equal(closed, true);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
845
1056
|
async function main() {
|
|
846
1057
|
testShouldAbortResumeProbeEarly();
|
|
847
1058
|
await testSingleResumeCaptureFailureIsSkipped();
|
|
848
1059
|
await testConsecutiveResumeCaptureFailuresStillAbort();
|
|
849
1060
|
await testPageExhaustedBeforeTargetShouldRaiseRecoverableError();
|
|
850
1061
|
await testPageExhaustedWithoutTargetShouldStillComplete();
|
|
1062
|
+
await testTargetCountShouldStopWhenPassedCountReached();
|
|
1063
|
+
await testTargetCountShouldNotTreatProcessedCountAsReached();
|
|
851
1064
|
await testFeaturedShouldUseNetworkResumeOnly();
|
|
852
1065
|
await testRecommendShouldPreferNetworkResumeWhenAvailable();
|
|
853
1066
|
await testNetworkMissShouldFallbackToImageCapture();
|
|
@@ -871,6 +1084,11 @@ async function main() {
|
|
|
871
1084
|
testStitchWithAvailablePythonShouldFailWhenScriptMissing();
|
|
872
1085
|
testParseArgsShouldSupportFeaturedAliasesAndInlinePort();
|
|
873
1086
|
testParseArgsShouldSupportLatestPageScope();
|
|
1087
|
+
await testCallTextModelShouldNotTruncateLongResume();
|
|
1088
|
+
await testCallTextModelShouldFallbackToChunkModeOnContextLimit();
|
|
1089
|
+
await testPrepareVisionImageSegmentsShouldSplitLongImage();
|
|
1090
|
+
await testCloseDetailPageShouldFailWhenDetailStillOpenAndListNotReady();
|
|
1091
|
+
await testCloseDetailPageShouldContinueWhenListReady();
|
|
874
1092
|
console.log("recoverable resume failure tests passed");
|
|
875
1093
|
}
|
|
876
1094
|
|