@reconcrap/boss-recommend-mcp 2.0.5 → 2.0.7

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.
@@ -19,7 +19,13 @@ import {
19
19
  resetInfiniteListForRefreshRound
20
20
  } from "../../core/infinite-list/index.js";
21
21
  import { createViewportRunGuard } from "../../core/self-heal/index.js";
22
- import { screenCandidate } from "../../core/screening/index.js";
22
+ import {
23
+ callScreeningLlm,
24
+ compactScreeningLlmResult,
25
+ createFailedLlmScreeningResult,
26
+ llmResultToScreening,
27
+ screenCandidate
28
+ } from "../../core/screening/index.js";
23
29
  import {
24
30
  closeRecruitDetail,
25
31
  createRecruitDetailNetworkRecorder,
@@ -72,10 +78,22 @@ function compactDetail(detailResult) {
72
78
  parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
73
79
  cv_acquisition: detailResult.cv_acquisition || null,
74
80
  image_evidence: summarizeImageEvidence(detailResult.image_evidence),
81
+ llm_screening: compactScreeningLlmResult(detailResult.llm_result),
75
82
  close_result: detailResult.close_result
76
83
  };
77
84
  }
78
85
 
86
+ function normalizeScreeningMode(value) {
87
+ const normalized = String(value || "llm").trim().toLowerCase();
88
+ return ["deterministic", "local", "local_scorer"].includes(normalized)
89
+ ? "deterministic"
90
+ : "llm";
91
+ }
92
+
93
+ function createMissingLlmConfigResult() {
94
+ return createFailedLlmScreeningResult(new Error("LLM screening config is required for production search runs"));
95
+ }
96
+
79
97
  function normalizeSearchParams(searchParams = {}) {
80
98
  return normalizeRecruitSearchParams(searchParams);
81
99
  }
@@ -110,7 +128,7 @@ export async function runRecruitWorkflow({
110
128
  criteria = "",
111
129
  searchParams = {},
112
130
  maxCandidates = 5,
113
- detailLimit = 1,
131
+ detailLimit = null,
114
132
  closeDetail = true,
115
133
  delayMs = 0,
116
134
  cardTimeoutMs = 90000,
@@ -127,12 +145,19 @@ export async function runRecruitWorkflow({
127
145
  listFallbackPoint = null,
128
146
  refreshOnEnd = true,
129
147
  maxRefreshRounds = 2,
130
- refreshResetSettleMs = 5000
148
+ refreshResetSettleMs = 5000,
149
+ screeningMode = "llm",
150
+ llmConfig = null,
151
+ llmTimeoutMs = 120000,
152
+ llmImageLimit = 8,
153
+ llmImageDetail = "high"
131
154
  } = {}, runControl) {
132
155
  if (!client) throw new Error("runRecruitWorkflow requires a guarded CDP client");
133
156
  const normalizedSearchParams = normalizeSearchParams(searchParams);
157
+ const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
158
+ const useLlmScreening = normalizedScreeningMode !== "deterministic";
134
159
  const limit = Math.max(1, Number(maxCandidates) || 1);
135
- const detailCountLimit = Math.max(0, Number(detailLimit) || 0);
160
+ const detailCountLimit = detailLimit == null ? limit : Math.max(0, Number(detailLimit) || 0);
136
161
  const networkRecorder = detailCountLimit > 0
137
162
  ? createRecruitDetailNetworkRecorder(client)
138
163
  : null;
@@ -222,6 +247,8 @@ export async function runRecruitWorkflow({
222
247
  screened: 0,
223
248
  detail_opened: 0,
224
249
  passed: 0,
250
+ screening_mode: normalizedScreeningMode,
251
+ llm_screened: 0,
225
252
  unique_seen: compactInfiniteListState(listState).seen_count,
226
253
  scroll_count: 0,
227
254
  refresh_rounds: 0,
@@ -298,6 +325,7 @@ export async function runRecruitWorkflow({
298
325
  screened: results.length,
299
326
  detail_opened: results.filter((item) => item.detail).length,
300
327
  passed: results.filter((item) => item.screening.passed).length,
328
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
301
329
  unique_seen: compactInfiniteListState(listState).seen_count,
302
330
  scroll_count: compactInfiniteListState(listState).scroll_count,
303
331
  refresh_rounds: refreshRounds,
@@ -426,13 +454,37 @@ export async function runRecruitWorkflow({
426
454
  await runControl.waitIfPaused();
427
455
  runControl.throwIfCanceled();
428
456
  runControl.setPhase("recruit:screening");
429
- const screening = screenCandidate(screeningCandidate, { criteria });
457
+ let llmResult = null;
458
+ if (useLlmScreening) {
459
+ if (!llmConfig) {
460
+ llmResult = createMissingLlmConfigResult();
461
+ } else {
462
+ try {
463
+ llmResult = await callScreeningLlm({
464
+ candidate: screeningCandidate,
465
+ criteria,
466
+ config: llmConfig,
467
+ timeoutMs: llmTimeoutMs,
468
+ imageEvidence: detailResult?.image_evidence || null,
469
+ maxImages: llmImageLimit,
470
+ imageDetail: llmImageDetail
471
+ });
472
+ } catch (error) {
473
+ llmResult = createFailedLlmScreeningResult(error);
474
+ }
475
+ }
476
+ if (detailResult) detailResult.llm_result = llmResult;
477
+ }
478
+ const screening = useLlmScreening
479
+ ? llmResultToScreening(llmResult, screeningCandidate)
480
+ : screenCandidate(screeningCandidate, { criteria });
430
481
  const compactResult = {
431
482
  index,
432
483
  candidate_key: candidateKey,
433
484
  card_node_id: cardNodeId,
434
485
  candidate: compactCandidate(screeningCandidate),
435
486
  detail: compactDetail(detailResult),
487
+ llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
436
488
  screening: compactScreening(screening)
437
489
  };
438
490
  results.push(compactResult);
@@ -450,6 +502,7 @@ export async function runRecruitWorkflow({
450
502
  screened: results.length,
451
503
  detail_opened: results.filter((item) => item.detail).length,
452
504
  passed: results.filter((item) => item.screening.passed).length,
505
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
453
506
  unique_seen: compactInfiniteListState(listState).seen_count,
454
507
  scroll_count: compactInfiniteListState(listState).scroll_count,
455
508
  refresh_rounds: refreshRounds,
@@ -462,6 +515,7 @@ export async function runRecruitWorkflow({
462
515
  last_score: screening.score
463
516
  });
464
517
  runControl.checkpoint({
518
+ results,
465
519
  last_candidate: {
466
520
  id: screeningCandidate.id || null,
467
521
  key: candidateKey,
@@ -470,7 +524,8 @@ export async function runRecruitWorkflow({
470
524
  status: screening.status,
471
525
  passed: screening.passed,
472
526
  score: screening.score
473
- }
527
+ },
528
+ llm_screening: compactScreeningLlmResult(llmResult)
474
529
  }
475
530
  });
476
531
 
@@ -496,6 +551,7 @@ export async function runRecruitWorkflow({
496
551
  processed: results.length,
497
552
  screened: results.length,
498
553
  detail_opened: results.filter((item) => item.detail).length,
554
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
499
555
  passed: results.filter((item) => item.screening.passed).length,
500
556
  results
501
557
  };
@@ -514,7 +570,7 @@ export function createRecruitRunService({
514
570
  criteria = "",
515
571
  searchParams = {},
516
572
  maxCandidates = 5,
517
- detailLimit = 1,
573
+ detailLimit = null,
518
574
  closeDetail = true,
519
575
  delayMs = 0,
520
576
  cardTimeoutMs = 90000,
@@ -532,10 +588,18 @@ export function createRecruitRunService({
532
588
  refreshOnEnd = true,
533
589
  maxRefreshRounds = 2,
534
590
  refreshResetSettleMs = 5000,
591
+ screeningMode = "llm",
592
+ llmConfig = null,
593
+ llmTimeoutMs = 120000,
594
+ llmImageLimit = 8,
595
+ llmImageDetail = "high",
535
596
  name = "recruit-domain-run"
536
597
  } = {}) {
537
598
  if (!client) throw new Error("startRecruitRun requires a guarded CDP client");
538
599
  const normalizedSearchParams = normalizeSearchParams(searchParams);
600
+ const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
601
+ const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
602
+ const normalizedDetailLimit = detailLimit == null ? candidateLimit : Math.max(0, Number(detailLimit) || 0);
539
603
  return manager.startRun({
540
604
  name,
541
605
  context: {
@@ -544,7 +608,7 @@ export function createRecruitRunService({
544
608
  criteria_present: Boolean(criteria),
545
609
  search_params: normalizedSearchParams,
546
610
  max_candidates: maxCandidates,
547
- detail_limit: detailLimit,
611
+ detail_limit: normalizedDetailLimit,
548
612
  close_detail: closeDetail,
549
613
  reset_before_search: resetBeforeSearch,
550
614
  reset_timeout_ms: resetTimeoutMs,
@@ -559,14 +623,20 @@ export function createRecruitRunService({
559
623
  list_fallback_point: listFallbackPoint,
560
624
  refresh_on_end: refreshOnEnd,
561
625
  max_refresh_rounds: maxRefreshRounds,
562
- refresh_reset_settle_ms: refreshResetSettleMs
626
+ refresh_reset_settle_ms: refreshResetSettleMs,
627
+ screening_mode: normalizedScreeningMode,
628
+ llm_configured: Boolean(llmConfig),
629
+ llm_timeout_ms: llmTimeoutMs,
630
+ llm_image_limit: llmImageLimit,
631
+ llm_image_detail: llmImageDetail
563
632
  },
564
633
  progress: {
565
634
  card_count: 0,
566
- target_count: Math.max(1, Number(maxCandidates) || 1),
635
+ target_count: candidateLimit,
567
636
  processed: 0,
568
637
  screened: 0,
569
638
  detail_opened: 0,
639
+ llm_screened: 0,
570
640
  passed: 0
571
641
  },
572
642
  checkpoint: {},
@@ -576,7 +646,7 @@ export function createRecruitRunService({
576
646
  criteria,
577
647
  searchParams: normalizedSearchParams,
578
648
  maxCandidates,
579
- detailLimit,
649
+ detailLimit: normalizedDetailLimit,
580
650
  closeDetail,
581
651
  delayMs,
582
652
  cardTimeoutMs,
@@ -593,7 +663,12 @@ export function createRecruitRunService({
593
663
  listFallbackPoint,
594
664
  refreshOnEnd,
595
665
  maxRefreshRounds,
596
- refreshResetSettleMs
666
+ refreshResetSettleMs,
667
+ screeningMode: normalizedScreeningMode,
668
+ llmConfig,
669
+ llmTimeoutMs,
670
+ llmImageLimit,
671
+ llmImageDetail
597
672
  }, runControl)
598
673
  });
599
674
  }
package/src/index.js CHANGED
@@ -541,11 +541,38 @@ function createRunInputSchema() {
541
541
  detail_limit: {
542
542
  type: "integer",
543
543
  minimum: 0,
544
- description: "打开详情/CV 的人数上限;默认跟随 target_count/max_candidates。生产筛选不应传 0;只有 allow_card_only_screening=true 时才会接受 0 作为调试卡片-only 模式"
544
+ description: "打开详情/CV 的人数上限;默认跟随 target_count/max_candidates。生产筛选不应传 0;detail_limit=0 需要 debug_test_mode=true allow_card_only_screening=true"
545
545
  },
546
546
  allow_card_only_screening: {
547
547
  type: "boolean",
548
- description: "高级调试开关;默认 false。只有显式为 true 时,recommend 才会尊重 detail_limit=0 并只用卡片信息筛选"
548
+ description: "高级调试开关;默认 false。只有同时显式 debug_test_mode=true 时,recommend 才会尊重 detail_limit=0"
549
+ },
550
+ debug_test_mode: {
551
+ type: "boolean",
552
+ description: "高级测试开关;默认 false。只有显式为 true 时才允许 deterministic/local scorer、跳过筛选器、card-only、dry-run 后置动作等调试路径"
553
+ },
554
+ screening_mode: {
555
+ type: "string",
556
+ enum: ["llm", "deterministic"],
557
+ description: "筛选引擎;默认 llm。deterministic 仅限 debug_test_mode=true 的明确测试场景"
558
+ },
559
+ use_llm: {
560
+ type: "boolean",
561
+ description: "兼容字段;默认 true。use_llm=false 等同 deterministic,仅限 debug_test_mode=true"
562
+ },
563
+ llm_timeout_ms: {
564
+ type: "integer",
565
+ minimum: 1000,
566
+ description: "可选,单个候选人的 LLM 调用超时"
567
+ },
568
+ llm_image_limit: {
569
+ type: "integer",
570
+ minimum: 1,
571
+ description: "可选,传给 LLM 的图片简历截图页数上限"
572
+ },
573
+ llm_image_detail: {
574
+ type: "string",
575
+ description: "可选,图片输入 detail,默认 high"
549
576
  },
550
577
  delay_ms: {
551
578
  type: "integer",
@@ -577,11 +604,11 @@ function createRunInputSchema() {
577
604
  },
578
605
  no_filter: {
579
606
  type: "boolean",
580
- description: "开发/live gate 专用:跳过本次筛选器点击,默认 false"
607
+ description: "开发/live gate 专用:跳过本次筛选器点击,默认 false;正式 run 需要 debug_test_mode=true 才允许"
581
608
  },
582
609
  filter_enabled: {
583
610
  type: "boolean",
584
- description: "开发/live gate 专用:false 时跳过本次筛选器点击"
611
+ description: "开发/live gate 专用:false 时跳过本次筛选器点击;正式 run 需要 debug_test_mode=true 才允许"
585
612
  },
586
613
  refresh_on_end: {
587
614
  type: "boolean",
@@ -672,7 +699,16 @@ function createBossChatStartInputSchema({ requireFullInput = false } = {}) {
672
699
  },
673
700
  use_llm: {
674
701
  type: "boolean",
675
- description: "可选,是否使用 screening-config.json 中的 LLM 配置筛选;求简历任务默认使用"
702
+ description: "可选,默认 true。use_llm=false 等同 deterministic/local scorer,仅限 debug_test_mode=true 的明确测试场景"
703
+ },
704
+ screening_mode: {
705
+ type: "string",
706
+ enum: ["llm", "deterministic"],
707
+ description: "筛选引擎;默认 llm。deterministic 仅限 debug_test_mode=true"
708
+ },
709
+ debug_test_mode: {
710
+ type: "boolean",
711
+ description: "高级测试开关;默认 false。只有显式为 true 时才允许 deterministic/local scorer、detail_limit=0、dry-run 求简历等调试路径"
676
712
  },
677
713
  request_cv: {
678
714
  type: "boolean",
@@ -703,6 +739,15 @@ function createBossChatStartInputSchema({ requireFullInput = false } = {}) {
703
739
  minimum: 1000,
704
740
  description: "可选,单个候选人的 LLM 调用超时"
705
741
  },
742
+ llm_image_limit: {
743
+ type: "integer",
744
+ minimum: 1,
745
+ description: "可选,传给 LLM 的图片简历截图页数上限"
746
+ },
747
+ llm_image_detail: {
748
+ type: "string",
749
+ description: "可选,图片输入 detail,默认 high"
750
+ },
706
751
  online_resume_button_timeout_ms: {
707
752
  type: "integer",
708
753
  minimum: 1000,
@@ -84,9 +84,38 @@ function parseNonNegativeInteger(raw, fallback) {
84
84
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
85
85
  }
86
86
 
87
+ function isDebugTestMode(args = {}) {
88
+ return args.debug_test_mode === true || args.allow_debug_test_mode === true;
89
+ }
90
+
91
+ function normalizeScreeningModeArg(args = {}) {
92
+ const raw = normalizeText(args.screening_mode || args.screeningMode || "");
93
+ if (args.use_llm === false) return "deterministic";
94
+ return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
95
+ ? "deterministic"
96
+ : "llm";
97
+ }
98
+
99
+ function collectRecommendDebugTestOptions(args = {}, normalized = {}) {
100
+ const reasons = [];
101
+ if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
102
+ if (args.allow_card_only_screening === true) reasons.push("allow_card_only_screening");
103
+ if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
104
+ if (args.no_filter === true) reasons.push("no_filter");
105
+ if (args.filter_enabled === false) reasons.push("filter_enabled=false");
106
+ if (args.dry_run_post_action === true) reasons.push("dry_run_post_action");
107
+ if (args.execute_post_action === false && normalized.postAction && normalized.postAction !== "none") {
108
+ reasons.push("execute_post_action=false");
109
+ }
110
+ return reasons;
111
+ }
112
+
87
113
  function resolveRecommendDetailLimit(args = {}, normalized = {}) {
88
114
  const fallback = parsePositiveInteger(normalized.targetCount, 5);
89
115
  const requested = parseNonNegativeInteger(args.detail_limit, fallback);
116
+ if (requested === 0 && !isDebugTestMode(args)) {
117
+ return fallback;
118
+ }
90
119
  if (requested === 0 && args.allow_card_only_screening !== true) {
91
120
  return fallback;
92
121
  }
@@ -270,8 +299,15 @@ function ensureRecommendRunArtifacts(snapshot) {
270
299
  if (meta) meta.checkpointPath = artifacts.checkpoint_path;
271
300
 
272
301
  const summary = snapshot?.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
273
- if (summary) {
274
- const rows = Array.isArray(summary.results) ? summary.results : [];
302
+ const checkpointResults = Array.isArray(checkpoint.results) ? checkpoint.results : [];
303
+ const artifactSummary = summary || (checkpointResults.length ? {
304
+ domain: "recommend",
305
+ partial: true,
306
+ partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
307
+ results: checkpointResults
308
+ } : null);
309
+ if (artifactSummary) {
310
+ const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
275
311
  writeRecommendLegacyCsvAtomic(artifacts.output_csv, rows, snapshot, meta);
276
312
  writeJsonAtomic(artifacts.report_json, {
277
313
  run_id: snapshot.runId || snapshot.run_id,
@@ -280,7 +316,7 @@ function ensureRecommendRunArtifacts(snapshot) {
280
316
  progress: snapshot.progress || {},
281
317
  context: snapshot.context || {},
282
318
  checkpoint,
283
- summary,
319
+ summary: artifactSummary,
284
320
  generated_at: new Date().toISOString()
285
321
  });
286
322
  if (meta) {
@@ -297,6 +333,12 @@ function buildLegacyRecommendResult(snapshot) {
297
333
  const artifacts = ensureRecommendRunArtifacts(snapshot);
298
334
  const meta = getRecommendRunMeta(snapshot.runId);
299
335
  const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
336
+ const checkpoint = snapshot.checkpoint && typeof snapshot.checkpoint === "object" ? snapshot.checkpoint : {};
337
+ const resultRows = Array.isArray(summary?.results)
338
+ ? summary.results
339
+ : Array.isArray(checkpoint.results)
340
+ ? checkpoint.results
341
+ : [];
300
342
  const progress = normalizeLegacyProgress(snapshot.progress, summary);
301
343
  const targetCount = Number.isInteger(progress.target_count)
302
344
  ? progress.target_count
@@ -341,7 +383,7 @@ function buildLegacyRecommendResult(snapshot) {
341
383
  screen_params: clonePlain(meta.parsed?.screenParams || {}, {}),
342
384
  target_count_semantics: TARGET_COUNT_SEMANTICS,
343
385
  error: snapshot.error || null,
344
- results: Array.isArray(summary?.results) ? summary.results : []
386
+ results: resultRows
345
387
  };
346
388
  }
347
389
 
@@ -958,11 +1000,12 @@ function normalizeRecommendStartInput(args = {}, parsed, configResolution = null
958
1000
  postAction: parsed.screenParams?.post_action || "none",
959
1001
  maxGreetCount: Number.isInteger(parsed.screenParams?.max_greet_count)
960
1002
  ? parsed.screenParams.max_greet_count
961
- : null
1003
+ : null,
1004
+ screeningMode: normalizeScreeningModeArg(args)
962
1005
  };
963
1006
  }
964
1007
 
965
- function getRunOptions(args, parsed, normalized, session) {
1008
+ function getRunOptions(args, parsed, normalized, session, configResolution = null) {
966
1009
  const slowLive = args.slow_live === true;
967
1010
  const executePostAction = args.dry_run_post_action === true
968
1011
  ? false
@@ -998,6 +1041,13 @@ function getRunOptions(args, parsed, normalized, session) {
998
1041
  actionTimeoutMs: parsePositiveInteger(args.action_timeout_ms, slowLive ? 12000 : 8000),
999
1042
  actionIntervalMs: parsePositiveInteger(args.action_interval_ms, 500),
1000
1043
  actionAfterClickDelayMs: parseNonNegativeInteger(args.action_after_click_delay_ms, slowLive ? 1200 : 900),
1044
+ screeningMode: normalized.screeningMode,
1045
+ llmConfig: normalized.screeningMode === "llm" && configResolution?.ok ? {
1046
+ ...configResolution.config
1047
+ } : null,
1048
+ llmTimeoutMs: parsePositiveInteger(args.llm_timeout_ms, slowLive ? 180000 : 120000),
1049
+ llmImageLimit: parsePositiveInteger(args.llm_image_limit, 8),
1050
+ llmImageDetail: normalizeText(args.llm_image_detail) || "high",
1001
1051
  name: "mcp-recommend-pipeline-run",
1002
1052
  parsed
1003
1053
  };
@@ -1054,6 +1104,30 @@ async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = ""
1054
1104
  if (gate) return gate;
1055
1105
  const configResolution = resolveBossScreeningConfig(workspaceRoot);
1056
1106
  const normalized = normalizeRecommendStartInput(args, parsed, configResolution);
1107
+ const debugTestOptions = collectRecommendDebugTestOptions(args, normalized);
1108
+ if (debugTestOptions.length && !isDebugTestMode(args)) {
1109
+ return {
1110
+ status: "FAILED",
1111
+ error: {
1112
+ code: "DEBUG_TEST_MODE_REQUIRED",
1113
+ message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1114
+ retryable: false
1115
+ },
1116
+ debug_test_options: debugTestOptions
1117
+ };
1118
+ }
1119
+ if (normalized.screeningMode === "llm" && !configResolution.ok) {
1120
+ return {
1121
+ status: "FAILED",
1122
+ error: {
1123
+ code: "SCREEN_CONFIG_ERROR",
1124
+ message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1125
+ retryable: true
1126
+ },
1127
+ config_path: configResolution.config_path || null,
1128
+ candidate_paths: configResolution.candidate_paths || []
1129
+ };
1130
+ }
1057
1131
 
1058
1132
  let session;
1059
1133
  try {
@@ -1085,7 +1159,7 @@ async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = ""
1085
1159
 
1086
1160
  let started;
1087
1161
  try {
1088
- started = recommendRunService.startRecommendRun(getRunOptions(args, parsed, normalized, session));
1162
+ started = recommendRunService.startRecommendRun(getRunOptions(args, parsed, normalized, session, configResolution));
1089
1163
  } catch (error) {
1090
1164
  await session.close?.();
1091
1165
  return {
@@ -73,6 +73,27 @@ function parseNonNegativeInteger(raw, fallback) {
73
73
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
74
74
  }
75
75
 
76
+ function isDebugTestMode(args = {}) {
77
+ return args.debug_test_mode === true || args.allow_debug_test_mode === true;
78
+ }
79
+
80
+ function normalizeScreeningModeArg(args = {}) {
81
+ const raw = normalizeText(args.screening_mode || args.screeningMode || "");
82
+ if (args.use_llm === false) return "deterministic";
83
+ return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
84
+ ? "deterministic"
85
+ : "llm";
86
+ }
87
+
88
+ function collectRecruitDebugTestOptions(args = {}) {
89
+ const reasons = [];
90
+ if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
91
+ if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
92
+ if (args.dry_run_post_action === true) reasons.push("dry_run_post_action");
93
+ if (args.execute_post_action === false) reasons.push("execute_post_action=false");
94
+ return reasons;
95
+ }
96
+
76
97
  function methodSummary(methodLog = []) {
77
98
  const summary = {};
78
99
  for (const entry of methodLog || []) {
@@ -203,8 +224,15 @@ function ensureRecruitRunArtifacts(snapshot) {
203
224
  if (meta) meta.checkpointPath = artifacts.checkpoint_path;
204
225
 
205
226
  const summary = snapshot?.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
206
- if (summary) {
207
- const rows = Array.isArray(summary.results) ? summary.results : [];
227
+ const checkpointResults = Array.isArray(checkpoint.results) ? checkpoint.results : [];
228
+ const artifactSummary = summary || (checkpointResults.length ? {
229
+ domain: "recruit",
230
+ partial: true,
231
+ partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
232
+ results: checkpointResults
233
+ } : null);
234
+ if (artifactSummary) {
235
+ const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
208
236
  writeRecruitLegacyCsvAtomic(artifacts.output_csv, rows, snapshot, meta);
209
237
  writeJsonAtomic(artifacts.report_json, {
210
238
  run_id: snapshot.runId || snapshot.run_id,
@@ -213,7 +241,7 @@ function ensureRecruitRunArtifacts(snapshot) {
213
241
  progress: snapshot.progress || {},
214
242
  context: snapshot.context || {},
215
243
  checkpoint,
216
- summary,
244
+ summary: artifactSummary,
217
245
  generated_at: new Date().toISOString()
218
246
  });
219
247
  if (meta) {
@@ -270,6 +298,12 @@ function buildLegacyRunResult(snapshot) {
270
298
  const artifacts = ensureRecruitRunArtifacts(snapshot);
271
299
  const meta = getRecruitRunMeta(snapshot.runId);
272
300
  const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
301
+ const checkpoint = snapshot.checkpoint && typeof snapshot.checkpoint === "object" ? snapshot.checkpoint : {};
302
+ const resultRows = Array.isArray(summary?.results)
303
+ ? summary.results
304
+ : Array.isArray(checkpoint.results)
305
+ ? checkpoint.results
306
+ : [];
273
307
  const progress = normalizeLegacyProgress(snapshot.progress, summary);
274
308
  const targetCount = Number.isInteger(progress.target_count)
275
309
  ? progress.target_count
@@ -302,7 +336,8 @@ function buildLegacyRunResult(snapshot) {
302
336
  || null,
303
337
  completion_reason: completionReason(snapshot.status),
304
338
  target_count_semantics: TARGET_COUNT_SEMANTICS,
305
- run_id: snapshot.runId
339
+ run_id: snapshot.runId,
340
+ results: resultRows
306
341
  };
307
342
  }
308
343
 
@@ -387,7 +422,34 @@ export function createRecruitPipelineInputSchema() {
387
422
  detail_limit: {
388
423
  type: "integer",
389
424
  minimum: 0,
390
- description: "打开详情的人数上限;默认 1,0 表示只用卡片信息"
425
+ description: "打开详情/CV 的人数上限;默认跟随 max_candidates。detail_limit=0 属于调试路径,需要 debug_test_mode=true"
426
+ },
427
+ debug_test_mode: {
428
+ type: "boolean",
429
+ description: "高级测试开关;默认 false。只有显式为 true 时才允许 deterministic/local scorer、detail_limit=0 等调试路径"
430
+ },
431
+ screening_mode: {
432
+ type: "string",
433
+ enum: ["llm", "deterministic"],
434
+ description: "筛选引擎;默认 llm。deterministic 仅限 debug_test_mode=true"
435
+ },
436
+ use_llm: {
437
+ type: "boolean",
438
+ description: "兼容字段;默认 true。use_llm=false 等同 deterministic,仅限 debug_test_mode=true"
439
+ },
440
+ llm_timeout_ms: {
441
+ type: "integer",
442
+ minimum: 1000,
443
+ description: "可选,单个候选人的 LLM 调用超时"
444
+ },
445
+ llm_image_limit: {
446
+ type: "integer",
447
+ minimum: 1,
448
+ description: "可选,传给 LLM 的图片简历截图页数上限"
449
+ },
450
+ llm_image_detail: {
451
+ type: "string",
452
+ description: "可选,图片输入 detail,默认 high"
391
453
  },
392
454
  delay_ms: {
393
455
  type: "integer",
@@ -751,22 +813,30 @@ async function connectRecruitChromeSession({
751
813
  };
752
814
  }
753
815
 
754
- function getRunOptions(args, parsed, session) {
816
+ function getRunOptions(args, parsed, session, configResolution = null) {
755
817
  const slowLive = args.slow_live === true;
756
818
  const targetCount = parsePositiveInteger(args.max_candidates, parsed.screenParams.target_count || 10);
819
+ const screeningMode = normalizeScreeningModeArg(args);
757
820
  return {
758
821
  client: session.client,
759
822
  targetUrl: RECRUIT_TARGET_URL,
760
823
  criteria: parsed.screenParams.criteria,
761
824
  searchParams: parsed.searchParams,
762
825
  maxCandidates: targetCount,
763
- detailLimit: parseNonNegativeInteger(args.detail_limit, 1),
826
+ detailLimit: parseNonNegativeInteger(args.detail_limit, targetCount),
764
827
  closeDetail: true,
765
828
  delayMs: Math.max(0, parsePositiveInteger(args.delay_ms, 0)),
766
829
  cardTimeoutMs: slowLive ? 180000 : 90000,
767
830
  resetBeforeSearch: args.reset_search !== false,
768
831
  resetTimeoutMs: slowLive ? 300000 : 180000,
769
832
  cityOptionTimeoutMs: slowLive ? 60000 : 30000,
833
+ screeningMode,
834
+ llmConfig: screeningMode === "llm" && configResolution?.ok ? {
835
+ ...configResolution.config
836
+ } : null,
837
+ llmTimeoutMs: parsePositiveInteger(args.llm_timeout_ms, slowLive ? 180000 : 120000),
838
+ llmImageLimit: parsePositiveInteger(args.llm_image_limit, 8),
839
+ llmImageDetail: normalizeText(args.llm_image_detail) || "high",
770
840
  name: "mcp-recruit-pipeline-run"
771
841
  };
772
842
  }
@@ -814,6 +884,31 @@ async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "" }
814
884
  const gate = evaluateRecruitPipelineGate(parsed);
815
885
  if (gate) return gate;
816
886
  const configResolution = resolveBossScreeningConfig(workspaceRoot);
887
+ const screeningMode = normalizeScreeningModeArg(args);
888
+ const debugTestOptions = collectRecruitDebugTestOptions(args);
889
+ if (debugTestOptions.length && !isDebugTestMode(args)) {
890
+ return {
891
+ status: "FAILED",
892
+ error: {
893
+ code: "DEBUG_TEST_MODE_REQUIRED",
894
+ message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
895
+ retryable: false
896
+ },
897
+ debug_test_options: debugTestOptions
898
+ };
899
+ }
900
+ if (screeningMode === "llm" && !configResolution.ok) {
901
+ return {
902
+ status: "FAILED",
903
+ error: {
904
+ code: "SCREEN_CONFIG_ERROR",
905
+ message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
906
+ retryable: true
907
+ },
908
+ config_path: configResolution.config_path || null,
909
+ candidate_paths: configResolution.candidate_paths || []
910
+ };
911
+ }
817
912
  const host = normalizeText(args.host) || DEFAULT_RECRUIT_HOST;
818
913
  const port = parsePositiveInteger(
819
914
  args.port,
@@ -850,7 +945,7 @@ async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "" }
850
945
 
851
946
  let started;
852
947
  try {
853
- started = recruitRunService.startRecruitRun(getRunOptions(args, parsed, session));
948
+ started = recruitRunService.startRecruitRun(getRunOptions(args, parsed, session, configResolution));
854
949
  } catch (error) {
855
950
  await session.close?.();
856
951
  return {