@reconcrap/boss-recommend-mcp 1.2.7 → 1.2.9

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.
@@ -250,7 +250,7 @@ function testSchoolTagOverrideMixedValidAndInvalidShouldKeepValidOnes() {
250
250
  instruction: "推荐页筛选候选人,有算法经验",
251
251
  confirmation: null,
252
252
  overrides: {
253
- school_tag: ["985", "211", "qs100"]
253
+ school_tag: ["985", "211", "foo_tag"]
254
254
  }
255
255
  });
256
256
 
@@ -263,13 +263,37 @@ function testSchoolTagOverrideAllInvalidShouldFallbackToUnlimited() {
263
263
  instruction: "推荐页筛选候选人,有算法经验",
264
264
  confirmation: null,
265
265
  overrides: {
266
- school_tag: ["qs100", "abc"]
266
+ school_tag: ["abc", "foo"]
267
267
  }
268
268
  });
269
269
 
270
270
  assert.deepEqual(result.searchParams.school_tag, ["不限"]);
271
271
  }
272
272
 
273
+ function testSchoolTagQsAliasShouldNormalizeToDomesticAndOverseasTop() {
274
+ const result = parseRecommendInstruction({
275
+ instruction: "推荐页筛选候选人,有算法经验",
276
+ confirmation: null,
277
+ overrides: {
278
+ school_tag: ["985", "QS前200"]
279
+ }
280
+ });
281
+
282
+ assert.deepEqual(result.searchParams.school_tag, ["985", "国内外名校"]);
283
+ }
284
+
285
+ function testRecentNotViewSpacedOverrideShouldNormalize() {
286
+ const result = parseRecommendInstruction({
287
+ instruction: "推荐页筛选985男生,有算法经验",
288
+ confirmation: null,
289
+ overrides: {
290
+ recent_not_view: "近 14 天没有"
291
+ }
292
+ });
293
+
294
+ assert.equal(result.searchParams.recent_not_view, "近14天没有");
295
+ }
296
+
273
297
  function testCriteriaCanBeProvidedViaOverrides() {
274
298
  const result = parseRecommendInstruction({
275
299
  instruction: "推荐页筛选211女生",
@@ -488,6 +512,30 @@ function testFeaturedKeywordShouldProposeFeaturedPageScope() {
488
512
  assert.equal(result.pending_questions.some((item) => item.field === "page_scope"), true);
489
513
  }
490
514
 
515
+ function testClosedQuestionsShouldExposeStructuredOptions() {
516
+ const result = parseRecommendInstruction({
517
+ instruction: "推荐页筛选候选人,有 Agent 经验,符合标准收藏",
518
+ confirmation: null,
519
+ overrides: null
520
+ });
521
+ const schoolTagQuestion = result.pending_questions.find((item) => item.field === "school_tag");
522
+ const recentNotViewQuestion = result.pending_questions.find((item) => item.field === "recent_not_view");
523
+ const filtersQuestion = result.pending_questions.find((item) => item.field === "filters");
524
+
525
+ assert.equal(Boolean(schoolTagQuestion), true);
526
+ assert.equal(Array.isArray(schoolTagQuestion.options), true);
527
+ assert.equal(schoolTagQuestion.options.some((item) => item.value === "国内外名校"), true);
528
+ assert.equal(schoolTagQuestion.options.every((item) => typeof item.label === "string" && typeof item.value === "string"), true);
529
+
530
+ assert.equal(Boolean(recentNotViewQuestion), true);
531
+ assert.equal(Array.isArray(recentNotViewQuestion.options), true);
532
+ assert.equal(recentNotViewQuestion.options.some((item) => item.value === "近14天没有"), true);
533
+
534
+ assert.equal(Boolean(filtersQuestion), true);
535
+ assert.equal(Array.isArray(filtersQuestion.options), true);
536
+ assert.equal(filtersQuestion.options.some((item) => item.value === "confirm"), true);
537
+ }
538
+
491
539
  function testLatestKeywordShouldProposeLatestPageScope() {
492
540
  const result = parseRecommendInstruction({
493
541
  instruction: "在推荐页最新里筛选候选人,有 Agent 经验,符合标准收藏",
@@ -547,6 +595,8 @@ function main() {
547
595
  testSchoolTagOverrideCanBeArray();
548
596
  testSchoolTagOverrideMixedValidAndInvalidShouldKeepValidOnes();
549
597
  testSchoolTagOverrideAllInvalidShouldFallbackToUnlimited();
598
+ testSchoolTagQsAliasShouldNormalizeToDomesticAndOverseasTop();
599
+ testRecentNotViewSpacedOverrideShouldNormalize();
550
600
  testCriteriaCanBeProvidedViaOverrides();
551
601
  testMissingCriteriaTriggersNeedInput();
552
602
  testMcpMentionShouldStayInCriteria();
@@ -558,6 +608,7 @@ function main() {
558
608
  testPostActionNoneCanBeConfirmed();
559
609
  testJobSelectionHintCanComeFromOverrides();
560
610
  testFeaturedKeywordShouldProposeFeaturedPageScope();
611
+ testClosedQuestionsShouldExposeStructuredOptions();
561
612
  testLatestKeywordShouldProposeLatestPageScope();
562
613
  testConfirmedPageScopeShouldBeResolved();
563
614
  testPageScopeOverrideShouldNotBypassConfirmation();
@@ -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
  }