@reconcrap/boss-recommend-mcp 1.3.18 → 1.3.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -167,7 +167,8 @@ config/screening-config.example.json
167
167
  - `openaiProject`
168
168
  - `debugPort`
169
169
  - `outputDir`
170
- - `llmThinkingLevel`:默认 `off`。可设为 `off/minimal/low/medium/high/auto/current`,用于控制 OpenAI-compatible LLM 的 thinking/reasoning 强度。
170
+ - `llmThinkingLevel`:默认 `low`。可设为 `off/minimal/low/medium/high/auto/current`,用于控制 OpenAI-compatible LLM 的 thinking/reasoning 强度。
171
+ - `humanRestEnabled`:默认 `false`。`false` 时 recommend-screen 随机休息/批次休息与 boss-chat 批次休息均为 `0ms`;`true` 时恢复随机休息节奏。
171
172
 
172
173
  ## 常用命令
173
174
 
@@ -2,7 +2,8 @@
2
2
  "baseUrl": "https://api.openai.com/v1",
3
3
  "apiKey": "replace-with-openai-api-key",
4
4
  "model": "gpt-4.1-mini",
5
- "llmThinkingLevel": "off",
5
+ "llmThinkingLevel": "low",
6
+ "humanRestEnabled": false,
6
7
  "openaiOrganization": "optional-org-id",
7
8
  "openaiProject": "optional-project-id"
8
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.3.18",
3
+ "version": "1.3.20",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/adapters.js CHANGED
@@ -121,6 +121,30 @@ function normalizeText(value) {
121
121
  return String(value || "").replace(/\s+/g, " ").trim();
122
122
  }
123
123
 
124
+ function parseBooleanValue(value) {
125
+ if (typeof value === "boolean") return value;
126
+ const normalized = normalizeText(value).toLowerCase();
127
+ if (!normalized) return null;
128
+ if (["1", "true", "yes", "y", "on", "是"].includes(normalized)) return true;
129
+ if (["0", "false", "no", "n", "off", "否"].includes(normalized)) return false;
130
+ return null;
131
+ }
132
+
133
+ function resolveHumanRestEnabled(config = {}) {
134
+ if (!config || typeof config !== "object" || Array.isArray(config)) return false;
135
+ const candidates = [
136
+ config.humanRestEnabled,
137
+ config.human_rest_enabled,
138
+ config.humanLikeRestEnabled,
139
+ config.human_like_rest_enabled
140
+ ];
141
+ for (const candidate of candidates) {
142
+ const parsed = parseBooleanValue(candidate);
143
+ if (typeof parsed === "boolean") return parsed;
144
+ }
145
+ return false;
146
+ }
147
+
124
148
  function serializeInputSummary(value) {
125
149
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
126
150
  try {
@@ -2937,6 +2961,7 @@ export async function runRecommendScreenCli({
2937
2961
  if (llmThinkingLevel) {
2938
2962
  args.push("--thinking-level", llmThinkingLevel);
2939
2963
  }
2964
+ args.push("--human-rest", String(resolveHumanRestEnabled(loaded.config)));
2940
2965
  if (Number.isInteger(screenParams.target_count) && screenParams.target_count > 0) {
2941
2966
  args.push("--targetCount", String(screenParams.target_count));
2942
2967
  }
package/src/boss-chat.js CHANGED
@@ -40,6 +40,30 @@ function parsePositiveInteger(value, fallback = null) {
40
40
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
41
41
  }
42
42
 
43
+ function parseBooleanValue(value) {
44
+ if (typeof value === "boolean") return value;
45
+ const normalized = normalizeText(value).toLowerCase();
46
+ if (!normalized) return null;
47
+ if (["1", "true", "yes", "y", "on", "是"].includes(normalized)) return true;
48
+ if (["0", "false", "no", "n", "off", "否"].includes(normalized)) return false;
49
+ return null;
50
+ }
51
+
52
+ function resolveHumanRestEnabled(config = {}) {
53
+ if (!config || typeof config !== "object" || Array.isArray(config)) return false;
54
+ const candidates = [
55
+ config.humanRestEnabled,
56
+ config.human_rest_enabled,
57
+ config.humanLikeRestEnabled,
58
+ config.human_like_rest_enabled
59
+ ];
60
+ for (const candidate of candidates) {
61
+ const parsed = parseBooleanValue(candidate);
62
+ if (typeof parsed === "boolean") return parsed;
63
+ }
64
+ return false;
65
+ }
66
+
43
67
  function isUnlimitedTargetCountToken(value) {
44
68
  const token = normalizeText(value).toLowerCase();
45
69
  if (!token) return false;
@@ -290,7 +314,8 @@ function resolveBossChatScreenConfig(workspaceRoot) {
290
314
  apiKey: normalizeText(parsed.apiKey),
291
315
  model: normalizeText(parsed.model),
292
316
  llmThinkingLevel: resolveLlmThinkingLevel(parsed),
293
- debugPort: parsePositiveInteger(parsed.debugPort, 9222)
317
+ debugPort: parsePositiveInteger(parsed.debugPort, 9222),
318
+ humanRestEnabled: resolveHumanRestEnabled(parsed)
294
319
  },
295
320
  config_path: configPath,
296
321
  config_dir: path.dirname(configPath)
@@ -430,6 +455,8 @@ function buildBossChatCliArgs(command, input, resolvedConfig) {
430
455
  }
431
456
  if (typeof normalized.batchRestEnabled === "boolean") {
432
457
  args.push("--batch-rest", String(normalized.batchRestEnabled));
458
+ } else if (typeof resolvedConfig?.humanRestEnabled === "boolean") {
459
+ args.push("--batch-rest", String(resolvedConfig.humanRestEnabled));
433
460
  }
434
461
  return args;
435
462
  }
@@ -755,8 +755,8 @@ async function testBossChatLlmShouldApplyThinkingDefaultsAndOverrides() {
755
755
  },
756
756
  });
757
757
  await volcClient.requestCompletions({ prompt: "prompt", evidenceCorpus: "resume" });
758
- assert.deepEqual(volcCompletionPayload.thinking, { type: "disabled" });
759
- assert.equal(volcCompletionPayload.reasoning_effort, "minimal");
758
+ assert.deepEqual(volcCompletionPayload.thinking, { type: "enabled" });
759
+ assert.equal(volcCompletionPayload.reasoning_effort, "low");
760
760
 
761
761
  let lowCompletionPayload = null;
762
762
  const lowClient = new LlmClient({
@@ -787,7 +787,7 @@ async function testBossChatLlmShouldApplyThinkingDefaultsAndOverrides() {
787
787
  });
788
788
  await openaiClient.requestCompletions({ prompt: "prompt", evidenceCorpus: "resume" });
789
789
  assert.equal(openaiCompletionPayload.thinking, undefined);
790
- assert.equal(openaiCompletionPayload.reasoning_effort, "minimal");
790
+ assert.equal(openaiCompletionPayload.reasoning_effort, "low");
791
791
 
792
792
  let responsesPayload = null;
793
793
  const responsesClient = new LlmClient({
@@ -47,7 +47,7 @@ export class InteractionController {
47
47
  return 0;
48
48
  }
49
49
 
50
- const restMs = 0;
50
+ const restMs = 4000 + Math.floor(Math.random() * 4000);
51
51
  logger.log(`短暂休息 ${restMs}ms,保持处理节奏稳定...`);
52
52
  await this.wait(restMs);
53
53
  this.nextRestAt = processedCount + this.randomRestThreshold();
@@ -104,7 +104,7 @@ function resolveLlmThinkingLevel(config = {}, options = {}) {
104
104
  normalizeLlmThinkingLevel(config.reasoningEffort) ||
105
105
  normalizeLlmThinkingLevel(config.reasoning_effort) ||
106
106
  getEnvLlmThinkingLevel() ||
107
- 'off'
107
+ 'low'
108
108
  );
109
109
  }
110
110
 
@@ -118,7 +118,7 @@ function isVolcengineModel(baseUrl, model) {
118
118
  }
119
119
 
120
120
  function applyChatCompletionThinking(payload, { baseUrl = '', model = '', thinkingLevel = '' } = {}) {
121
- const level = normalizeLlmThinkingLevel(thinkingLevel) || 'off';
121
+ const level = normalizeLlmThinkingLevel(thinkingLevel) || 'low';
122
122
  if (isProviderDefaultThinkingLevel(level)) return payload;
123
123
  const isVolc = isVolcengineModel(baseUrl, model);
124
124
  if (isVolc) {
@@ -142,7 +142,7 @@ function applyChatCompletionThinking(payload, { baseUrl = '', model = '', thinki
142
142
  }
143
143
 
144
144
  function applyResponsesThinking(payload, { thinkingLevel = '' } = {}) {
145
- const level = normalizeLlmThinkingLevel(thinkingLevel) || 'off';
145
+ const level = normalizeLlmThinkingLevel(thinkingLevel) || 'low';
146
146
  if (isProviderDefaultThinkingLevel(level) || level === 'auto') return payload;
147
147
  payload.reasoning = {
148
148
  ...(payload.reasoning || {}),
@@ -16,7 +16,7 @@ const CSV_HEADER = [
16
16
  "最近工作职位",
17
17
  "评估通过详细原因",
18
18
  "处理结果",
19
- "筛选原因",
19
+ "判断依据(CoT)",
20
20
  "动作执行结果",
21
21
  "简历来源",
22
22
  "原始判定通过",
@@ -592,6 +592,101 @@ function mergeCandidateProfiles(...profiles) {
592
592
  };
593
593
  }
594
594
 
595
+ function buildCardProfileFallbackText(cardProfile = {}) {
596
+ const profile = cardProfile && typeof cardProfile === "object" ? cardProfile : {};
597
+ const educationList = Array.isArray(profile.educationList)
598
+ ? profile.educationList
599
+ .map((item) => ({
600
+ school: normalizeText(item?.school || ""),
601
+ major: normalizeText(item?.major || ""),
602
+ degree: normalizeText(item?.degree || ""),
603
+ start: normalizeText(item?.start || ""),
604
+ end: normalizeText(item?.end || "")
605
+ }))
606
+ .filter((item) => item.school || item.major || item.degree || item.start || item.end)
607
+ .slice(0, 2)
608
+ : [];
609
+ const hasCore = Boolean(
610
+ normalizeText(profile.name || "")
611
+ || normalizeText(profile.age || "")
612
+ || normalizeText(profile.gender || "")
613
+ || normalizeText(profile.highestDegree || "")
614
+ || normalizeText(profile.workYears || "")
615
+ || normalizeText(profile.company || "")
616
+ || normalizeText(profile.position || "")
617
+ || normalizeText(profile.latestWorkStart || "")
618
+ || normalizeText(profile.latestWorkEnd || "")
619
+ || educationList.length > 0
620
+ );
621
+ if (!hasCore) return "";
622
+
623
+ const lines = ["=== 人选卡片兜底信息(仅在简历缺失时使用) ==="];
624
+ const pushField = (label, value) => {
625
+ const text = normalizeText(value);
626
+ if (!text) return;
627
+ lines.push(`${label}: ${text}`);
628
+ };
629
+
630
+ pushField("姓名", profile.name);
631
+ pushField("年龄", profile.age);
632
+ pushField("性别", profile.gender);
633
+ pushField("最高学历", profile.highestDegree);
634
+ pushField("工作年限", profile.workYears);
635
+ pushField("最近一份工作公司", profile.company);
636
+ pushField("最近一份职位", profile.position);
637
+ const workPeriod = formatResumeTimeRange(profile.latestWorkStart, profile.latestWorkEnd, "至今");
638
+ if (workPeriod) {
639
+ lines.push(`最近一份工作在职日期: ${workPeriod}`);
640
+ }
641
+ if (educationList.length > 0) {
642
+ lines.push("最近两段学校经历:");
643
+ educationList.forEach((item, index) => {
644
+ const eduPeriod = formatResumeTimeRange(item.start, item.end);
645
+ const detailParts = [
646
+ item.school ? `学校=${item.school}` : "",
647
+ item.major ? `专业=${item.major}` : "",
648
+ item.degree ? `学历=${item.degree}` : "",
649
+ eduPeriod ? `时间=${eduPeriod}` : ""
650
+ ].filter(Boolean);
651
+ if (detailParts.length > 0) {
652
+ lines.push(`${index + 1}. ${detailParts.join(";")}`);
653
+ }
654
+ });
655
+ }
656
+ return lines.join("\n");
657
+ }
658
+
659
+ function enrichCandidateInfoWithCardProfile(candidateInfo = {}, cardProfile = null) {
660
+ const info = candidateInfo && typeof candidateInfo === "object" ? candidateInfo : {};
661
+ const profile = cardProfile && typeof cardProfile === "object" ? cardProfile : null;
662
+ if (!profile) return { ...info };
663
+
664
+ const educationList = Array.isArray(profile.educationList) ? profile.educationList : [];
665
+ const firstEducation = educationList[0] || {};
666
+ const merged = {
667
+ ...info,
668
+ name: preferReadableName(info?.name || "", profile?.name || ""),
669
+ school: normalizeText(info?.school || "") || normalizeText(profile?.school || "") || normalizeText(firstEducation?.school || ""),
670
+ major: normalizeText(info?.major || "") || normalizeText(profile?.major || "") || normalizeText(firstEducation?.major || ""),
671
+ company: normalizeText(info?.company || "") || normalizeText(profile?.company || ""),
672
+ position: normalizeText(info?.position || "") || normalizeText(profile?.position || ""),
673
+ alreadyInterested: info?.alreadyInterested === true
674
+ };
675
+
676
+ const baseResumeText = normalizeText(info?.resumeText || "");
677
+ const cardFallbackText = buildCardProfileFallbackText(profile);
678
+ if (cardFallbackText) {
679
+ merged.resumeText = baseResumeText.includes("=== 人选卡片兜底信息(仅在简历缺失时使用) ===")
680
+ ? baseResumeText
681
+ : baseResumeText
682
+ ? `${baseResumeText}\n\n${cardFallbackText}`
683
+ : cardFallbackText;
684
+ } else {
685
+ merged.resumeText = baseResumeText;
686
+ }
687
+ return merged;
688
+ }
689
+
595
690
  function isDomProfileConsistentWithCard(cardProfile, domProfile) {
596
691
  if (!cardProfile || !domProfile) return true;
597
692
  let compared = 0;
@@ -809,6 +904,101 @@ function parseBoolean(value) {
809
904
  return null;
810
905
  }
811
906
 
907
+ function parsePassedDecision(value) {
908
+ if (typeof value === "boolean") return value;
909
+ if (typeof value === "number") {
910
+ if (value === 1) return true;
911
+ if (value === 0) return false;
912
+ }
913
+ const normalized = normalizeText(value).toLowerCase();
914
+ if (!normalized) return null;
915
+ if (/不符合|不通过|未通过|未命中|不匹配|不满足/.test(normalized)) return false;
916
+ if (/符合|通过|命中|匹配|满足/.test(normalized)) return true;
917
+ return null;
918
+ }
919
+
920
+ function parsePassedDecisionFromContent(content) {
921
+ const raw = String(content || "");
922
+ const explicit = raw.match(/"passed"\s*:\s*(true|false)/i);
923
+ if (explicit) {
924
+ return explicit[1].toLowerCase() === "true";
925
+ }
926
+ return parsePassedDecision(raw);
927
+ }
928
+
929
+ function flattenChatMessageContent(content) {
930
+ if (Array.isArray(content)) {
931
+ return content
932
+ .map((item) => {
933
+ if (typeof item === "string") return item;
934
+ if (item && typeof item === "object") {
935
+ return item.text || item.content || item.reasoning_content || "";
936
+ }
937
+ return "";
938
+ })
939
+ .filter(Boolean)
940
+ .join("\n");
941
+ }
942
+ return String(content || "");
943
+ }
944
+
945
+ function collectNestedText(value, out = [], depth = 0) {
946
+ if (depth > 6 || value === null || value === undefined) return out;
947
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
948
+ const normalized = normalizeText(String(value));
949
+ if (normalized) out.push(normalized);
950
+ return out;
951
+ }
952
+ if (Array.isArray(value)) {
953
+ for (const item of value) {
954
+ collectNestedText(item, out, depth + 1);
955
+ }
956
+ return out;
957
+ }
958
+ if (typeof value === "object") {
959
+ const priorityKeys = ["text", "reasoning_content", "summary_text", "summary", "content", "cot", "reason"];
960
+ const seen = new Set();
961
+ for (const key of priorityKeys) {
962
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
963
+ seen.add(key);
964
+ collectNestedText(value[key], out, depth + 1);
965
+ }
966
+ }
967
+ for (const [key, nested] of Object.entries(value)) {
968
+ if (seen.has(key)) continue;
969
+ collectNestedText(nested, out, depth + 1);
970
+ }
971
+ }
972
+ return out;
973
+ }
974
+
975
+ function extractCotFromChoice(choice, parsed = {}) {
976
+ const fragments = [];
977
+ const candidates = [
978
+ choice?.message?.reasoning_content,
979
+ choice?.message?.reasoning,
980
+ choice?.reasoning_content,
981
+ choice?.reasoning,
982
+ parsed?.cot,
983
+ parsed?.reasoning_content,
984
+ parsed?.reasoning,
985
+ parsed?.summary_text,
986
+ parsed?.summary,
987
+ parsed?.reason
988
+ ];
989
+ for (const candidate of candidates) {
990
+ collectNestedText(candidate, fragments);
991
+ }
992
+ const deduped = [];
993
+ const seen = new Set();
994
+ for (const item of fragments) {
995
+ if (seen.has(item)) continue;
996
+ seen.add(item);
997
+ deduped.push(item);
998
+ }
999
+ return deduped.join("\n");
1000
+ }
1001
+
812
1002
  function normalizeCliOptionToken(rawToken) {
813
1003
  const token = String(rawToken || "").trim();
814
1004
  if (!token) {
@@ -1098,7 +1288,7 @@ function getEnvLlmThinkingLevel() {
1098
1288
  }
1099
1289
 
1100
1290
  function resolveLlmThinkingLevel(value) {
1101
- return normalizeLlmThinkingLevel(value) || getEnvLlmThinkingLevel() || "off";
1291
+ return normalizeLlmThinkingLevel(value) || getEnvLlmThinkingLevel() || "low";
1102
1292
  }
1103
1293
 
1104
1294
  function isVolcengineModel(baseUrl, model) {
@@ -1149,6 +1339,7 @@ function parseArgs(argv) {
1149
1339
  checkpointPath: null,
1150
1340
  pauseControlPath: null,
1151
1341
  resume: false,
1342
+ humanRestEnabled: false,
1152
1343
  postAction: null,
1153
1344
  postActionConfirmed: null,
1154
1345
  help: false,
@@ -1163,6 +1354,7 @@ function parseArgs(argv) {
1163
1354
  pageScope: false,
1164
1355
  calibrationPath: false,
1165
1356
  port: false,
1357
+ humanRest: false,
1166
1358
  postAction: false,
1167
1359
  postActionConfirmed: false
1168
1360
  }
@@ -1231,6 +1423,11 @@ function parseArgs(argv) {
1231
1423
  } else if (token === "--pause-control-path" && (inlineValue || next)) {
1232
1424
  parsed.pauseControlPath = path.resolve(inlineValue || next);
1233
1425
  if (!inlineValue) index += 1;
1426
+ } else if ((token === "--human-rest" || token === "--humanRest" || token === "--human_rest") && (inlineValue || next)) {
1427
+ const parsedBoolean = parseBoolean(inlineValue || next);
1428
+ parsed.humanRestEnabled = parsedBoolean === true;
1429
+ parsed.__provided.humanRest = parsedBoolean !== null;
1430
+ if (!inlineValue) index += 1;
1234
1431
  } else if (token === "--resume") {
1235
1432
  parsed.resume = true;
1236
1433
  } else if ((token === "--post-action" || token === "--postAction") && (inlineValue || next)) {
@@ -1808,11 +2005,19 @@ function extractJsonObject(text) {
1808
2005
  const start = raw.indexOf("{");
1809
2006
  const end = raw.lastIndexOf("}");
1810
2007
  if (start === -1 || end === -1 || end <= start) {
1811
- throw new Error("Vision model response did not contain JSON");
2008
+ throw new Error("Model response did not contain JSON");
1812
2009
  }
1813
2010
  return JSON.parse(raw.slice(start, end + 1));
1814
2011
  }
1815
2012
 
2013
+ function tryExtractJsonObject(text) {
2014
+ try {
2015
+ return extractJsonObject(text);
2016
+ } catch {
2017
+ return {};
2018
+ }
2019
+ }
2020
+
1816
2021
  async function promptPostAction() {
1817
2022
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1818
2023
  throw new Error("POST_ACTION_CONFIRMATION_REQUIRED");
@@ -4475,6 +4680,18 @@ class RecommendScreenCli {
4475
4680
  }
4476
4681
  return "";
4477
4682
  };
4683
+ const rankDegree = (value) => {
4684
+ const text = normalize(value).toLowerCase();
4685
+ if (!text) return 0;
4686
+ if (/博士|phd|doctor/.test(text)) return 7;
4687
+ if (/硕士|master/.test(text)) return 6;
4688
+ if (/本科|学士|bachelor/.test(text)) return 5;
4689
+ if (/大专|专科/.test(text)) return 4;
4690
+ if (/高中/.test(text)) return 3;
4691
+ if (/中专|中技/.test(text)) return 2;
4692
+ if (/初中|小学/.test(text)) return 1;
4693
+ return 0;
4694
+ };
4478
4695
  const recommendInner = Array.from(doc.querySelectorAll(".card-inner[data-geekid]"))
4479
4696
  .find((item) => (item.getAttribute("data-geekid") || "") === String(candidateKey)) || null;
4480
4697
  const latestInner = recommendInner
@@ -4500,13 +4717,84 @@ class RecommendScreenCli {
4500
4717
  const workSpans = latestWork
4501
4718
  ? Array.from(latestWork.querySelectorAll(".join-text-wrap.content span")).map((item) => textOf(item)).filter(Boolean)
4502
4719
  : [];
4720
+ const workTimeSpans = latestWork
4721
+ ? Array.from(latestWork.querySelectorAll(".join-text-wrap.time span")).map((item) => textOf(item)).filter(Boolean)
4722
+ : [];
4723
+ const eduItems = Array.from(card.querySelectorAll(".timeline-wrap.edu-exps .timeline-item"))
4724
+ .map((item) => {
4725
+ const timeSpans = Array.from(item.querySelectorAll(".join-text-wrap.time span")).map((node) => textOf(node)).filter(Boolean);
4726
+ const contentSpans = Array.from(item.querySelectorAll(".join-text-wrap.content span")).map((node) => textOf(node)).filter(Boolean);
4727
+ return {
4728
+ school: contentSpans[0] || "",
4729
+ major: contentSpans[1] || "",
4730
+ degree: contentSpans[2] || "",
4731
+ start: timeSpans[0] || "",
4732
+ end: timeSpans[1] || ""
4733
+ };
4734
+ })
4735
+ .filter((item) => item.school || item.major || item.degree || item.start || item.end)
4736
+ .slice(0, 2);
4737
+ if (eduItems.length === 0 && (eduSpans[0] || eduSpans[1] || eduSpans[2])) {
4738
+ eduItems.push({
4739
+ school: eduSpans[0] || "",
4740
+ major: eduSpans[1] || "",
4741
+ degree: eduSpans[2] || "",
4742
+ start: "",
4743
+ end: ""
4744
+ });
4745
+ }
4746
+ const baseInfoTokens = Array.from(card.querySelectorAll(".join-text-wrap.base-info span, .base-info span"))
4747
+ .map((item) => textOf(item))
4748
+ .filter(Boolean);
4749
+ let age = "";
4750
+ let workYears = "";
4751
+ let highestDegree = "";
4752
+ for (const token of baseInfoTokens) {
4753
+ if (!age && /\d+\s*岁/u.test(token)) {
4754
+ age = token;
4755
+ continue;
4756
+ }
4757
+ if (!workYears && /(\d+\s*年|应届|在校)/u.test(token) && !/\d+\s*岁/u.test(token)) {
4758
+ workYears = token;
4759
+ continue;
4760
+ }
4761
+ if (!highestDegree && /(博士|硕士|本科|大专|专科|高中|中专|中技|初中)/u.test(token)) {
4762
+ highestDegree = token;
4763
+ }
4764
+ }
4765
+ const genderUse = card.querySelector("svg.gender use, .gender use, svg[class*='gender'] use");
4766
+ const genderHref = String(
4767
+ (genderUse && (genderUse.getAttribute("xlink:href") || genderUse.getAttribute("href") || ""))
4768
+ || ""
4769
+ ).toLowerCase();
4770
+ let gender = "";
4771
+ if (/(man|male|boy|icon-man|男)/.test(genderHref)) {
4772
+ gender = "男";
4773
+ } else if (/(woman|female|girl|icon-woman|女)/.test(genderHref)) {
4774
+ gender = "女";
4775
+ }
4776
+ if (!highestDegree) {
4777
+ const degreeFromEdu = eduItems
4778
+ .slice()
4779
+ .sort((a, b) => rankDegree(b.degree) - rankDegree(a.degree))[0];
4780
+ if (degreeFromEdu?.degree) {
4781
+ highestDegree = degreeFromEdu.degree;
4782
+ }
4783
+ }
4503
4784
  return {
4504
4785
  ok: true,
4505
4786
  name: pick(card, [".geek-name-wrap .name", ".name-wrap .name", "span.name", ".name"]),
4506
4787
  school: eduSpans[0] || pick(card, [".edu-wrap .school-name", ".base-info .school-name", ".school-name"]),
4507
4788
  major: eduSpans[1] || pick(card, [".edu-wrap .major", ".major"]),
4508
4789
  company: workSpans[0] || pick(card, [".company-name-wrap .name", ".company-name"]),
4509
- position: workSpans[1] || pick(card, [".position span", ".position"])
4790
+ position: workSpans[1] || pick(card, [".position span", ".position"]),
4791
+ age,
4792
+ gender,
4793
+ highest_degree: highestDegree,
4794
+ work_years: workYears,
4795
+ latest_work_start: workTimeSpans[0] || "",
4796
+ latest_work_end: workTimeSpans[1] || "",
4797
+ education_list: eduItems
4510
4798
  };
4511
4799
  })(${JSON.stringify(candidateKey)})`);
4512
4800
  } catch {
@@ -4520,7 +4808,25 @@ class RecommendScreenCli {
4520
4808
  school: normalizeText(profile?.school || ""),
4521
4809
  major: normalizeText(profile?.major || ""),
4522
4810
  company: normalizeText(profile?.company || ""),
4523
- position: normalizeText(profile?.position || "")
4811
+ position: normalizeText(profile?.position || ""),
4812
+ age: normalizeText(profile?.age || ""),
4813
+ gender: normalizeText(profile?.gender || ""),
4814
+ highestDegree: normalizeText(profile?.highest_degree || ""),
4815
+ workYears: normalizeText(profile?.work_years || ""),
4816
+ latestWorkStart: normalizeText(profile?.latest_work_start || ""),
4817
+ latestWorkEnd: normalizeText(profile?.latest_work_end || ""),
4818
+ educationList: Array.isArray(profile?.education_list)
4819
+ ? profile.education_list
4820
+ .map((item) => ({
4821
+ school: normalizeText(item?.school || ""),
4822
+ major: normalizeText(item?.major || ""),
4823
+ degree: normalizeText(item?.degree || ""),
4824
+ start: normalizeText(item?.start || ""),
4825
+ end: normalizeText(item?.end || "")
4826
+ }))
4827
+ .filter((item) => item.school || item.major || item.degree || item.start || item.end)
4828
+ .slice(0, 2)
4829
+ : []
4524
4830
  };
4525
4831
  }
4526
4832
 
@@ -4653,22 +4959,32 @@ class RecommendScreenCli {
4653
4959
  applyVisionEvidenceGate(result) {
4654
4960
  const parsed = result && typeof result === "object" ? result : {};
4655
4961
  const rawPassed = parsed?.rawPassed === true || parsed?.passed === true;
4656
- const parsedEvidence = toStringArray(parsed?.evidence);
4657
- const evidenceRawCount = Number.isFinite(Number(parsed?.evidenceRawCount))
4658
- ? Number(parsed.evidenceRawCount)
4659
- : parsedEvidence.length;
4660
- const evidenceMatchedCount = Number.isFinite(Number(parsed?.evidenceMatchedCount))
4661
- ? Number(parsed.evidenceMatchedCount)
4662
- : parsedEvidence.length;
4663
- const evidenceGateDemoted = parsed?.evidenceGateDemoted === true || (rawPassed && evidenceMatchedCount <= 0);
4664
- const reason = normalizeText(parsed?.reason || "");
4665
- const summary = normalizeText(parsed?.summary || reason);
4962
+ const evidenceGateEligible = parsed?.evidenceGateEligible === true
4963
+ || Array.isArray(parsed?.evidence)
4964
+ || Number.isFinite(Number(parsed?.evidenceRawCount))
4965
+ || Number.isFinite(Number(parsed?.evidenceMatchedCount));
4966
+ const parsedEvidence = evidenceGateEligible ? toStringArray(parsed?.evidence) : [];
4967
+ const evidenceRawCount = evidenceGateEligible
4968
+ ? (Number.isFinite(Number(parsed?.evidenceRawCount))
4969
+ ? Number(parsed.evidenceRawCount)
4970
+ : parsedEvidence.length)
4971
+ : null;
4972
+ const evidenceMatchedCount = evidenceGateEligible
4973
+ ? (Number.isFinite(Number(parsed?.evidenceMatchedCount))
4974
+ ? Number(parsed.evidenceMatchedCount)
4975
+ : parsedEvidence.length)
4976
+ : null;
4977
+ const evidenceGateDemoted = parsed?.evidenceGateDemoted === true
4978
+ || (evidenceGateEligible && rawPassed && evidenceMatchedCount <= 0);
4979
+ const cot = normalizeText(parsed?.cot || parsed?.reason || "");
4980
+ const summary = normalizeText(parsed?.summary || cot);
4666
4981
  const finalReason = evidenceGateDemoted
4667
- ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`
4668
- : (reason || (rawPassed ? "满足筛选标准。" : "未满足筛选标准。"));
4982
+ ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${cot ? ` 原始判断依据(CoT): ${cot}` : ""}`
4983
+ : (cot || (rawPassed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。"));
4669
4984
  return {
4670
4985
  passed: evidenceGateDemoted ? false : rawPassed,
4671
4986
  rawPassed,
4987
+ cot: finalReason,
4672
4988
  reason: finalReason,
4673
4989
  summary: summary || finalReason,
4674
4990
  evidence: parsedEvidence,
@@ -4879,11 +5195,10 @@ class RecommendScreenCli {
4879
5195
  "workExpCheckRes 等经历校验项仅作为“需追问”软风险,不得直接据此判定不通过。\n" +
4880
5196
  "活跃度、沟通热度、受欢迎度等运营指标不参与筛选通过判定。\n\n" +
4881
5197
  "要求:\n" +
4882
- "1) reason 必须写出可审计的判定依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
4883
- "2) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
4884
- "3) evidence 至少给出 2 条可在简历中定位的原文短句。\n\n" +
5198
+ "1) 只做结论判断:候选人是否符合筛选标准。\n" +
5199
+ "2) 只返回 passed 布尔值,不要在 JSON 中输出 reason/summary/evidence 等字段。\n\n" +
4885
5200
  "请返回严格 JSON: " +
4886
- "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
5201
+ "{\"passed\": true/false}"
4887
5202
  }
4888
5203
  ];
4889
5204
  for (let index = 0; index < imagePaths.length; index += 1) {
@@ -4943,28 +5258,47 @@ class RecommendScreenCli {
4943
5258
  throw this.buildError("VISION_MODEL_FAILED", `Vision model request failed: ${response.status} ${body.slice(0, 400)}`);
4944
5259
  }
4945
5260
  const json = await response.json();
4946
- const content = Array.isArray(json?.choices?.[0]?.message?.content)
4947
- ? json.choices[0].message.content.map((item) => item?.text || "").join("\n")
4948
- : json?.choices?.[0]?.message?.content || "";
4949
- const parsed = extractJsonObject(content);
4950
- const rawPassed = parsed.passed === true;
4951
- const reason = normalizeText(parsed.reason);
4952
- const summary = normalizeText(parsed.summary || reason);
4953
- const evidence = toStringArray(parsed.evidence);
4954
- const evidenceGateDemoted = rawPassed && evidence.length <= 0;
5261
+ const choice = json?.choices?.[0] || {};
5262
+ const content = flattenChatMessageContent(choice?.message?.content);
5263
+ const parsed = tryExtractJsonObject(content);
5264
+ const parsedPassed = parsePassedDecision(parsed?.passed);
5265
+ const fallbackPassed = parsePassedDecisionFromContent(content);
5266
+ const rawPassed = parsedPassed !== null ? parsedPassed : fallbackPassed;
5267
+ if (rawPassed === null) {
5268
+ throw this.buildError(
5269
+ "VISION_MODEL_FAILED",
5270
+ `Vision model response missing boolean passed decision. content=${truncateText(content, 180)}`
5271
+ );
5272
+ }
5273
+ const cot = normalizeText(extractCotFromChoice(choice, parsed));
5274
+ const reason = cot || (rawPassed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。");
5275
+ const summary = reason;
5276
+ const evidenceGateEligible = Array.isArray(parsed?.evidence)
5277
+ || Number.isFinite(Number(parsed?.evidenceRawCount))
5278
+ || Number.isFinite(Number(parsed?.evidenceMatchedCount));
5279
+ const parsedEvidence = evidenceGateEligible ? toStringArray(parsed?.evidence) : [];
5280
+ const evidenceRawCount = evidenceGateEligible
5281
+ ? (Number.isFinite(Number(parsed?.evidenceRawCount)) ? Number(parsed.evidenceRawCount) : parsedEvidence.length)
5282
+ : null;
5283
+ const evidenceMatchedCount = evidenceGateEligible
5284
+ ? (Number.isFinite(Number(parsed?.evidenceMatchedCount)) ? Number(parsed.evidenceMatchedCount) : parsedEvidence.length)
5285
+ : null;
5286
+ const evidenceGateDemoted = evidenceGateEligible && rawPassed && (evidenceMatchedCount ?? 0) <= 0;
4955
5287
  const finalReason = evidenceGateDemoted
4956
- ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`
4957
- : (reason || (rawPassed ? "满足筛选标准。" : "未满足筛选标准。"));
5288
+ ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始判断依据(CoT): ${reason}` : ""}`
5289
+ : reason;
4958
5290
  const passed = evidenceGateDemoted ? false : rawPassed;
4959
- const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, evidence, passed);
5291
+ const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, parsedEvidence, passed);
4960
5292
  return {
4961
5293
  passed,
4962
5294
  rawPassed,
5295
+ cot: reason,
4963
5296
  reason: enrichedReason,
4964
5297
  summary: summary || enrichedReason,
4965
- evidence,
4966
- evidenceRawCount: evidence.length,
4967
- evidenceMatchedCount: evidence.length,
5298
+ evidence: parsedEvidence,
5299
+ evidenceRawCount,
5300
+ evidenceMatchedCount,
5301
+ evidenceGateEligible,
4968
5302
  evidenceGateDemoted
4969
5303
  };
4970
5304
  }
@@ -5064,17 +5398,17 @@ class RecommendScreenCli {
5064
5398
  "要求:\n" +
5065
5399
  "1) 必须完整阅读上面的全部简历文本。\n" +
5066
5400
  "2) 只能依据简历中真实出现的信息判断,严禁编造不存在的经历/项目/学历/公司。\n" +
5067
- "3) 若证据不足,必须返回 passed=false。\n\n" +
5068
- "4) 当筛选条件涉及应届/毕业年份时,必须以最高学历毕业年份作为主依据;若简历中存在教育时间、毕业时间或可推断年份信息,必须先推断再判断;" +
5401
+ "3) 若文本中包含“人选卡片兜底信息(仅在简历缺失时使用)”段落,只能在主简历缺失对应字段时引用该段,不可覆盖主简历已明确字段。\n" +
5402
+ "4) 若证据不足,必须返回 passed=false。\n\n" +
5403
+ "5) 当筛选条件涉及应届/毕业年份时,必须以最高学历毕业年份作为主依据;若简历中存在教育时间、毕业时间或可推断年份信息,必须先推断再判断;" +
5069
5404
  "只有完全不存在时间线信息时才可写“无法判断”。\n" +
5070
- "5) 当筛选条件提及“相关经验”时,必须以工作经历或项目经历作为硬性证据;教育/课程/论文/技能/个人优势只能作为补充,不能单独判定满足。\n" +
5071
- "6) workExpCheckRes 等经历校验项仅作为“需追问”软风险,不得直接据此判定不通过。\n" +
5072
- "7) 活跃度、沟通热度、受欢迎度等运营指标不参与筛选通过判定。\n" +
5073
- "8) reason 必须写出可审计依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
5074
- "9) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
5075
- "10) evidence 至少给出 2 条可在简历原文定位的证据短句。\n\n" +
5405
+ "6) 当筛选条件提及“相关经验”时,必须以工作经历或项目经历作为硬性证据;教育/课程/论文/技能/个人优势只能作为补充,不能单独判定满足。\n" +
5406
+ "7) workExpCheckRes 等经历校验项仅作为“需追问”软风险,不得直接据此判定不通过。\n" +
5407
+ "8) 活跃度、沟通热度、受欢迎度等运营指标不参与筛选通过判定。\n" +
5408
+ "9) 只做结论判断:候选人是否符合筛选标准。\n" +
5409
+ "10) 只返回 passed 布尔值,不要在 JSON 中输出 reason/summary/evidence 等字段。\n\n" +
5076
5410
  "请返回严格 JSON: " +
5077
- "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
5411
+ "{\"passed\": true/false}"
5078
5412
  }
5079
5413
  ]
5080
5414
  };
@@ -5100,32 +5434,43 @@ class RecommendScreenCli {
5100
5434
  throw this.buildError("TEXT_MODEL_FAILED", `Text model request failed: ${response.status} ${body.slice(0, 400)}`);
5101
5435
  }
5102
5436
  const json = await response.json();
5103
- const content = Array.isArray(json?.choices?.[0]?.message?.content)
5104
- ? json.choices[0].message.content.map((item) => item?.text || "").join("\n")
5105
- : json?.choices?.[0]?.message?.content || "";
5106
- const parsed = extractJsonObject(content);
5107
- const reason = normalizeText(parsed.reason);
5108
- const summary = normalizeText(parsed.summary || reason);
5437
+ const choice = json?.choices?.[0] || {};
5438
+ const content = flattenChatMessageContent(choice?.message?.content);
5439
+ const parsed = tryExtractJsonObject(content);
5440
+ const cot = normalizeText(extractCotFromChoice(choice, parsed));
5109
5441
  const normalizedResume = normalizeText(safeResumeText);
5110
5442
  const normalizedResumeLower = toLowerSafe(normalizedResume);
5111
- const parsedEvidence = toStringArray(parsed.evidence);
5443
+ const evidenceGateEligible = Array.isArray(parsed?.evidence)
5444
+ || Number.isFinite(Number(parsed?.evidenceRawCount))
5445
+ || Number.isFinite(Number(parsed?.evidenceMatchedCount));
5446
+ const parsedEvidence = evidenceGateEligible ? toStringArray(parsed.evidence) : [];
5112
5447
  const evidence = [];
5113
5448
  const unmatchedEvidence = [];
5114
- for (const item of parsedEvidence) {
5115
- const matched = matchEvidenceAgainstResume(item, safeResumeText, normalizedResume, normalizedResumeLower);
5116
- if (matched.matched) {
5117
- evidence.push(item);
5118
- } else {
5119
- unmatchedEvidence.push(item);
5449
+ if (evidenceGateEligible) {
5450
+ for (const item of parsedEvidence) {
5451
+ const matched = matchEvidenceAgainstResume(item, safeResumeText, normalizedResume, normalizedResumeLower);
5452
+ if (matched.matched) {
5453
+ evidence.push(item);
5454
+ } else {
5455
+ unmatchedEvidence.push(item);
5456
+ }
5120
5457
  }
5121
5458
  }
5122
- const rawPassed = parsed.passed === true;
5459
+ const parsedPassed = parsePassedDecision(parsed?.passed);
5460
+ const fallbackPassed = parsePassedDecisionFromContent(content);
5461
+ const rawPassed = parsedPassed !== null ? parsedPassed : fallbackPassed;
5462
+ if (rawPassed === null) {
5463
+ throw this.buildError(
5464
+ "TEXT_MODEL_FAILED",
5465
+ `Text model response missing boolean passed decision. content=${truncateText(content, 180)}`
5466
+ );
5467
+ }
5123
5468
  let passed = rawPassed;
5124
- let finalReason = reason || (passed ? "满足筛选标准。" : "不满足筛选标准。");
5125
- const evidenceGateDemoted = rawPassed && evidence.length <= 0;
5469
+ let finalReason = cot || (passed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。");
5470
+ const evidenceGateDemoted = evidenceGateEligible && rawPassed && evidence.length <= 0;
5126
5471
  if (evidenceGateDemoted) {
5127
5472
  passed = false;
5128
- finalReason = `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`;
5473
+ finalReason = `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${finalReason ? ` 原始判断依据(CoT): ${finalReason}` : ""}`;
5129
5474
  if (unmatchedEvidence.length > 0) {
5130
5475
  log(
5131
5476
  `[EVIDENCE_GATE] passed=true 但证据未命中简历原文,已降级为不通过;` +
@@ -5133,15 +5478,17 @@ class RecommendScreenCli {
5133
5478
  );
5134
5479
  }
5135
5480
  }
5481
+ const summary = finalReason;
5136
5482
  const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, evidence, passed);
5137
5483
  return {
5138
5484
  passed,
5139
5485
  rawPassed,
5486
+ cot: finalReason,
5140
5487
  reason: enrichedReason,
5141
5488
  summary: summary || enrichedReason,
5142
5489
  evidence,
5143
- evidenceRawCount: parsedEvidence.length,
5144
- evidenceMatchedCount: evidence.length,
5490
+ evidenceRawCount: evidenceGateEligible ? parsedEvidence.length : null,
5491
+ evidenceMatchedCount: evidenceGateEligible ? evidence.length : null,
5145
5492
  evidenceGateDemoted,
5146
5493
  chunkIndex,
5147
5494
  chunkTotal
@@ -5403,12 +5750,12 @@ class RecommendScreenCli {
5403
5750
  async takeBreakIfNeeded() {
5404
5751
  this.restCounter += 1;
5405
5752
  if (Math.random() < 0.08) {
5406
- const pauseMs = 0;
5753
+ const pauseMs = this.args.humanRestEnabled ? 3000 + Math.floor(Math.random() * 4000) : 0;
5407
5754
  log(`[随机休息] 暂停 ${Math.round(pauseMs / 1000)} 秒`);
5408
5755
  await sleep(pauseMs);
5409
5756
  }
5410
5757
  if (this.restCounter >= this.restThreshold) {
5411
- const pauseMs = 0;
5758
+ const pauseMs = this.args.humanRestEnabled ? 15000 + Math.floor(Math.random() * 15000) : 0;
5412
5759
  log(`[批次休息] 已连续处理 ${this.restCounter} 人,暂停 ${Math.round(pauseMs / 1000)} 秒`);
5413
5760
  await sleep(pauseMs);
5414
5761
  this.restCounter = 0;
@@ -5722,6 +6069,7 @@ class RecommendScreenCli {
5722
6069
  let domCandidateInfo = null;
5723
6070
 
5724
6071
  if (networkCandidateInfo?.resumeText) {
6072
+ networkCandidateInfo = enrichCandidateInfoWithCardProfile(networkCandidateInfo, cardProfile || null);
5725
6073
  screening = await timeCandidateStage(
5726
6074
  "text_model_ms",
5727
6075
  () => this.callTextModel(networkCandidateInfo.resumeText)
@@ -5761,7 +6109,10 @@ class RecommendScreenCli {
5761
6109
  })
5762
6110
  );
5763
6111
  if (lateNetworkCandidateInfo?.resumeText) {
5764
- networkCandidateInfo = lateNetworkCandidateInfo;
6112
+ networkCandidateInfo = enrichCandidateInfoWithCardProfile(
6113
+ lateNetworkCandidateInfo,
6114
+ cardProfile || null
6115
+ );
5765
6116
  screening = await timeCandidateStage(
5766
6117
  "text_model_ms",
5767
6118
  () => this.callTextModel(networkCandidateInfo.resumeText)
@@ -5786,7 +6137,10 @@ class RecommendScreenCli {
5786
6137
  () => this.resolveDomResumeFallback(nextCandidate, cardProfile || null)
5787
6138
  );
5788
6139
  if (domFallback?.networkCandidateInfo?.resumeText) {
5789
- networkCandidateInfo = domFallback.networkCandidateInfo;
6140
+ networkCandidateInfo = enrichCandidateInfoWithCardProfile(
6141
+ domFallback.networkCandidateInfo,
6142
+ cardProfile || null
6143
+ );
5790
6144
  screening = await timeCandidateStage(
5791
6145
  "text_model_ms",
5792
6146
  () => this.callTextModel(networkCandidateInfo.resumeText)
@@ -5806,7 +6160,10 @@ class RecommendScreenCli {
5806
6160
  }
5807
6161
  );
5808
6162
  } else if (domFallback?.domCandidateInfo?.resumeText) {
5809
- domCandidateInfo = domFallback.domCandidateInfo;
6163
+ domCandidateInfo = enrichCandidateInfoWithCardProfile(
6164
+ domFallback.domCandidateInfo,
6165
+ cardProfile || null
6166
+ );
5810
6167
  screening = await timeCandidateStage(
5811
6168
  "text_model_ms",
5812
6169
  () => this.callTextModel(domCandidateInfo.resumeText)
@@ -6119,7 +6476,7 @@ async function main() {
6119
6476
  console.log(JSON.stringify({
6120
6477
  status: "COMPLETED",
6121
6478
  result: {
6122
- usage: "node boss-recommend-screen-cli.cjs --criteria \"有 MCP 开发经验\" --post-action <favorite|greet|none> --max-greet-count 10 --post-action-confirmed true --baseurl <url> --apikey <key> --model <model> --thinking-level off|low|medium|high|current --page-scope recommend|latest|featured --calibration <favorite-calibration.json> --port 9222 --output <csv-path> [--input-summary-json <json>] --checkpoint-path <checkpoint.json> --pause-control-path <pause-control.json> [--resume]"
6479
+ usage: "node boss-recommend-screen-cli.cjs --criteria \"有 MCP 开发经验\" --post-action <favorite|greet|none> --max-greet-count 10 --post-action-confirmed true --baseurl <url> --apikey <key> --model <model> --thinking-level off|low|medium|high|current --page-scope recommend|latest|featured --calibration <favorite-calibration.json> --port 9222 --human-rest <true|false> --output <csv-path> [--input-summary-json <json>] --checkpoint-path <checkpoint.json> --pause-control-path <pause-control.json> [--resume]"
6123
6480
  }
6124
6481
  }));
6125
6482
  return;
@@ -6169,6 +6526,8 @@ if (require.main === module) {
6169
6526
  isRecoverablePostActionError,
6170
6527
  classifyFinishedWrapState,
6171
6528
  formatResumeApiData,
6529
+ buildCardProfileFallbackText,
6530
+ enrichCandidateInfoWithCardProfile,
6172
6531
  extractEvidenceTokens,
6173
6532
  matchEvidenceAgainstResume
6174
6533
  }
@@ -1013,6 +1013,46 @@ function testFormatResumeApiDataShouldIncludeStructuredJudgementHints() {
1013
1013
  assert.equal(formatted.includes("判定忽略项: 活跃度/沟通热度/受欢迎度等运营指标不参与通过判定。"), true);
1014
1014
  }
1015
1015
 
1016
+ function testEnrichCandidateInfoWithCardProfileShouldAppendCardFallbackWhenDomInfoMissing() {
1017
+ const candidateInfo = {
1018
+ name: "",
1019
+ school: "",
1020
+ major: "",
1021
+ company: "",
1022
+ position: "",
1023
+ resumeText: "=== 基本信息 ===\n姓名: 赵梓轩\n"
1024
+ };
1025
+ const cardProfile = {
1026
+ name: "赵梓轩",
1027
+ age: "29岁",
1028
+ gender: "男",
1029
+ highestDegree: "硕士",
1030
+ workYears: "2年",
1031
+ company: "中科院",
1032
+ position: "科研助理",
1033
+ latestWorkStart: "2024.10",
1034
+ latestWorkEnd: "至今",
1035
+ educationList: [
1036
+ { school: "科克大学", major: "理学", degree: "硕士", start: "2020", end: "2023" },
1037
+ { school: "东北大学", major: "数学与应用数学", degree: "本科", start: "2014", end: "2018" }
1038
+ ]
1039
+ };
1040
+
1041
+ const enriched = __testables.enrichCandidateInfoWithCardProfile(candidateInfo, cardProfile);
1042
+ assert.equal(enriched.name, "赵梓轩");
1043
+ assert.equal(enriched.company, "中科院");
1044
+ assert.equal(enriched.position, "科研助理");
1045
+ assert.equal(enriched.resumeText.includes("=== 人选卡片兜底信息(仅在简历缺失时使用) ==="), true);
1046
+ assert.equal(enriched.resumeText.includes("年龄: 29岁"), true);
1047
+ assert.equal(enriched.resumeText.includes("性别: 男"), true);
1048
+ assert.equal(enriched.resumeText.includes("最近一份工作在职日期: 2024.10 ~ 至今"), true);
1049
+ assert.equal(enriched.resumeText.includes("1. 学校=科克大学;专业=理学;学历=硕士;时间=2020 ~ 2023"), true);
1050
+ assert.equal(enriched.resumeText.includes("2. 学校=东北大学;专业=数学与应用数学;学历=本科;时间=2014 ~ 2018"), true);
1051
+
1052
+ const enrichedAgain = __testables.enrichCandidateInfoWithCardProfile(enriched, cardProfile);
1053
+ assert.equal((enrichedAgain.resumeText.match(/人选卡片兜底信息(仅在简历缺失时使用)/g) || []).length, 1);
1054
+ }
1055
+
1016
1056
  function testEvidenceTokenMatcherShouldSupportParaphrasedEvidence() {
1017
1057
  const resume = [
1018
1058
  "南京大学 专业: 数学",
@@ -1485,8 +1525,8 @@ async function testCallTextModelShouldFallbackToChunkModeOnContextLimit() {
1485
1525
  }
1486
1526
  }
1487
1527
 
1488
- async function testTextModelShouldDefaultThinkingOffForVolcengine() {
1489
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-thinking-off-"));
1528
+ async function testTextModelShouldDefaultThinkingLowForVolcengine() {
1529
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-thinking-low-default-"));
1490
1530
  const cli = new RecommendScreenCli(createArgs(tempDir));
1491
1531
  cli.args.baseUrl = "https://ark.cn-beijing.volces.com/api/v3";
1492
1532
  cli.args.model = "doubao-seed-2-0-mini-260215";
@@ -1512,8 +1552,8 @@ async function testTextModelShouldDefaultThinkingOffForVolcengine() {
1512
1552
  };
1513
1553
  try {
1514
1554
  await cli.callTextModel("resume");
1515
- assert.deepEqual(capturedPayload?.thinking, { type: "disabled" });
1516
- assert.equal(capturedPayload?.reasoning_effort, "minimal");
1555
+ assert.deepEqual(capturedPayload?.thinking, { type: "enabled" });
1556
+ assert.equal(capturedPayload?.reasoning_effort, "low");
1517
1557
  } finally {
1518
1558
  global.fetch = originalFetch;
1519
1559
  }
@@ -1757,6 +1797,7 @@ async function main() {
1757
1797
  testFinishedWrapClassifierShouldNotTreatLoadMoreAsBottom();
1758
1798
  testFormatResumeApiDataShouldPreserveEducationTagsAndProjectDescription();
1759
1799
  testFormatResumeApiDataShouldIncludeStructuredJudgementHints();
1800
+ testEnrichCandidateInfoWithCardProfileShouldAppendCardFallbackWhenDomInfoMissing();
1760
1801
  testEvidenceTokenMatcherShouldSupportParaphrasedEvidence();
1761
1802
  testCheckpointPayloadShouldIncludeCandidateAudits();
1762
1803
  testCheckpointShouldPersistAndRestoreInputSummary();
@@ -1771,7 +1812,7 @@ async function main() {
1771
1812
  testParseArgsShouldSupportInputSummaryJson();
1772
1813
  await testCallTextModelShouldNotTruncateLongResume();
1773
1814
  await testCallTextModelShouldFallbackToChunkModeOnContextLimit();
1774
- await testTextModelShouldDefaultThinkingOffForVolcengine();
1815
+ await testTextModelShouldDefaultThinkingLowForVolcengine();
1775
1816
  await testTextModelShouldSupportLowThinkingForVolcengine();
1776
1817
  await testPrepareVisionImageSegmentsShouldSplitLongImage();
1777
1818
  await testVisionEvidenceGateShouldDemoteImageFallbackWithoutEvidence();