@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
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
- /目标(?:处理|筛选)?(?:人数|数量)?(?:为|是|:|:)?\s*(\d+)/i,
86
- /至少(?:处理|筛选)\s*(\d+)\s*(?:位|人)/i,
87
- /(?:处理|筛选)\s*(\d+)\s*(?:位|人)/i
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
- /目标(?:处理|筛选)?(?:人数|数量)?|至少(?:处理|筛选)|(?:处理|筛选)\s*\d+\s*(?:位|人)/i,
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/目标人数/post_action/max_greet_count)无误。",
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 流水线。", {
@@ -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
- ok: false,
1449
- stdout: "",
1450
- stderr: "boom",
1451
- structured: null,
1452
- error: {
1453
- code: "RECOMMEND_FILTER_PANEL_UNAVAILABLE",
1454
- message: "筛选面板不可用。"
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
- "请输入目标筛选人数(--targetCount,可留空表示不设上限): ",
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.prepareVisionImageForModel(imagePath, primaryLimit, "primary");
3301
+ const preparedPrimary = await this.prepareVisionImageSegmentsForModel(imagePath, primaryLimit, "primary");
3245
3302
  try {
3246
- return await this.requestVisionModel(preparedPrimary.imagePath);
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.prepareVisionImageForModel(imagePath, retryLimit, "retry");
3315
+ const preparedRetry = await this.prepareVisionImageSegmentsForModel(imagePath, retryLimit, "retry");
3258
3316
  try {
3259
- return await this.requestVisionModel(preparedRetry.imagePath);
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 imageBase64 = fs.readFileSync(imagePath, "base64");
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: "你是一位严谨的招聘筛选助手。你只能返回 JSON,不要输出任何额外文字。"
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: normalizeText(parsed.reason),
3416
- summary: normalizeText(parsed.summary)
3593
+ reason: reason || "未满足筛选标准。",
3594
+ summary: summary || reason || "未满足筛选标准。",
3595
+ evidence
3417
3596
  };
3418
3597
  }
3419
3598
 
3420
3599
  async callTextModel(resumeText) {
3421
- const safeResumeText = String(resumeText || "").slice(0, 28000);
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: "你是一位严谨的招聘筛选助手。你只能返回 JSON,不要输出任何额外文字。"
3670
+ content:
3671
+ "你是一位严谨的招聘筛选助手。必须完整阅读输入内容,严禁编造不存在的简历经历。" +
3672
+ "只能返回 JSON,不要输出任何额外文字。"
3432
3673
  },
3433
3674
  {
3434
3675
  role: "user",
3435
- content: `请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${this.args.criteria}\n\n简历内容:\n${safeResumeText}\n\n请返回严格 JSON: {"passed": true/false, "reason": "...", "summary": "..."}`
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: parsed.passed === true,
3462
- reason: normalizeText(parsed.reason),
3463
- summary: normalizeText(parsed.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
- log(`[关闭详情] 连续 ESC 后仍未确认关闭(${state?.reason || "unknown"}),按策略视为检测误差并继续下一位。`);
3690
- return true;
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(`[恢复] 已从 checkpoint 恢复,已处理 ${this.processedCount} 位候选人。`);
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.processedCount < this.args.targetCount) {
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.processedCount < this.args.targetCount) {
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
- `推荐列表已到底,但当前仅处理 ${this.processedCount} 位,尚未达到目标 ${this.args.targetCount} 位。`,
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.processedCount >= this.args.targetCount
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.processedCount >= this.args.targetCount
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