@reconcrap/boss-recommend-mcp 1.3.18 → 1.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.3.18",
3
+ "version": "1.3.19",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -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) {
@@ -1808,11 +1998,19 @@ function extractJsonObject(text) {
1808
1998
  const start = raw.indexOf("{");
1809
1999
  const end = raw.lastIndexOf("}");
1810
2000
  if (start === -1 || end === -1 || end <= start) {
1811
- throw new Error("Vision model response did not contain JSON");
2001
+ throw new Error("Model response did not contain JSON");
1812
2002
  }
1813
2003
  return JSON.parse(raw.slice(start, end + 1));
1814
2004
  }
1815
2005
 
2006
+ function tryExtractJsonObject(text) {
2007
+ try {
2008
+ return extractJsonObject(text);
2009
+ } catch {
2010
+ return {};
2011
+ }
2012
+ }
2013
+
1816
2014
  async function promptPostAction() {
1817
2015
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1818
2016
  throw new Error("POST_ACTION_CONFIRMATION_REQUIRED");
@@ -4475,6 +4673,18 @@ class RecommendScreenCli {
4475
4673
  }
4476
4674
  return "";
4477
4675
  };
4676
+ const rankDegree = (value) => {
4677
+ const text = normalize(value).toLowerCase();
4678
+ if (!text) return 0;
4679
+ if (/博士|phd|doctor/.test(text)) return 7;
4680
+ if (/硕士|master/.test(text)) return 6;
4681
+ if (/本科|学士|bachelor/.test(text)) return 5;
4682
+ if (/大专|专科/.test(text)) return 4;
4683
+ if (/高中/.test(text)) return 3;
4684
+ if (/中专|中技/.test(text)) return 2;
4685
+ if (/初中|小学/.test(text)) return 1;
4686
+ return 0;
4687
+ };
4478
4688
  const recommendInner = Array.from(doc.querySelectorAll(".card-inner[data-geekid]"))
4479
4689
  .find((item) => (item.getAttribute("data-geekid") || "") === String(candidateKey)) || null;
4480
4690
  const latestInner = recommendInner
@@ -4500,13 +4710,84 @@ class RecommendScreenCli {
4500
4710
  const workSpans = latestWork
4501
4711
  ? Array.from(latestWork.querySelectorAll(".join-text-wrap.content span")).map((item) => textOf(item)).filter(Boolean)
4502
4712
  : [];
4713
+ const workTimeSpans = latestWork
4714
+ ? Array.from(latestWork.querySelectorAll(".join-text-wrap.time span")).map((item) => textOf(item)).filter(Boolean)
4715
+ : [];
4716
+ const eduItems = Array.from(card.querySelectorAll(".timeline-wrap.edu-exps .timeline-item"))
4717
+ .map((item) => {
4718
+ const timeSpans = Array.from(item.querySelectorAll(".join-text-wrap.time span")).map((node) => textOf(node)).filter(Boolean);
4719
+ const contentSpans = Array.from(item.querySelectorAll(".join-text-wrap.content span")).map((node) => textOf(node)).filter(Boolean);
4720
+ return {
4721
+ school: contentSpans[0] || "",
4722
+ major: contentSpans[1] || "",
4723
+ degree: contentSpans[2] || "",
4724
+ start: timeSpans[0] || "",
4725
+ end: timeSpans[1] || ""
4726
+ };
4727
+ })
4728
+ .filter((item) => item.school || item.major || item.degree || item.start || item.end)
4729
+ .slice(0, 2);
4730
+ if (eduItems.length === 0 && (eduSpans[0] || eduSpans[1] || eduSpans[2])) {
4731
+ eduItems.push({
4732
+ school: eduSpans[0] || "",
4733
+ major: eduSpans[1] || "",
4734
+ degree: eduSpans[2] || "",
4735
+ start: "",
4736
+ end: ""
4737
+ });
4738
+ }
4739
+ const baseInfoTokens = Array.from(card.querySelectorAll(".join-text-wrap.base-info span, .base-info span"))
4740
+ .map((item) => textOf(item))
4741
+ .filter(Boolean);
4742
+ let age = "";
4743
+ let workYears = "";
4744
+ let highestDegree = "";
4745
+ for (const token of baseInfoTokens) {
4746
+ if (!age && /\d+\s*岁/u.test(token)) {
4747
+ age = token;
4748
+ continue;
4749
+ }
4750
+ if (!workYears && /(\d+\s*年|应届|在校)/u.test(token) && !/\d+\s*岁/u.test(token)) {
4751
+ workYears = token;
4752
+ continue;
4753
+ }
4754
+ if (!highestDegree && /(博士|硕士|本科|大专|专科|高中|中专|中技|初中)/u.test(token)) {
4755
+ highestDegree = token;
4756
+ }
4757
+ }
4758
+ const genderUse = card.querySelector("svg.gender use, .gender use, svg[class*='gender'] use");
4759
+ const genderHref = String(
4760
+ (genderUse && (genderUse.getAttribute("xlink:href") || genderUse.getAttribute("href") || ""))
4761
+ || ""
4762
+ ).toLowerCase();
4763
+ let gender = "";
4764
+ if (/(man|male|boy|icon-man|男)/.test(genderHref)) {
4765
+ gender = "男";
4766
+ } else if (/(woman|female|girl|icon-woman|女)/.test(genderHref)) {
4767
+ gender = "女";
4768
+ }
4769
+ if (!highestDegree) {
4770
+ const degreeFromEdu = eduItems
4771
+ .slice()
4772
+ .sort((a, b) => rankDegree(b.degree) - rankDegree(a.degree))[0];
4773
+ if (degreeFromEdu?.degree) {
4774
+ highestDegree = degreeFromEdu.degree;
4775
+ }
4776
+ }
4503
4777
  return {
4504
4778
  ok: true,
4505
4779
  name: pick(card, [".geek-name-wrap .name", ".name-wrap .name", "span.name", ".name"]),
4506
4780
  school: eduSpans[0] || pick(card, [".edu-wrap .school-name", ".base-info .school-name", ".school-name"]),
4507
4781
  major: eduSpans[1] || pick(card, [".edu-wrap .major", ".major"]),
4508
4782
  company: workSpans[0] || pick(card, [".company-name-wrap .name", ".company-name"]),
4509
- position: workSpans[1] || pick(card, [".position span", ".position"])
4783
+ position: workSpans[1] || pick(card, [".position span", ".position"]),
4784
+ age,
4785
+ gender,
4786
+ highest_degree: highestDegree,
4787
+ work_years: workYears,
4788
+ latest_work_start: workTimeSpans[0] || "",
4789
+ latest_work_end: workTimeSpans[1] || "",
4790
+ education_list: eduItems
4510
4791
  };
4511
4792
  })(${JSON.stringify(candidateKey)})`);
4512
4793
  } catch {
@@ -4520,7 +4801,25 @@ class RecommendScreenCli {
4520
4801
  school: normalizeText(profile?.school || ""),
4521
4802
  major: normalizeText(profile?.major || ""),
4522
4803
  company: normalizeText(profile?.company || ""),
4523
- position: normalizeText(profile?.position || "")
4804
+ position: normalizeText(profile?.position || ""),
4805
+ age: normalizeText(profile?.age || ""),
4806
+ gender: normalizeText(profile?.gender || ""),
4807
+ highestDegree: normalizeText(profile?.highest_degree || ""),
4808
+ workYears: normalizeText(profile?.work_years || ""),
4809
+ latestWorkStart: normalizeText(profile?.latest_work_start || ""),
4810
+ latestWorkEnd: normalizeText(profile?.latest_work_end || ""),
4811
+ educationList: Array.isArray(profile?.education_list)
4812
+ ? profile.education_list
4813
+ .map((item) => ({
4814
+ school: normalizeText(item?.school || ""),
4815
+ major: normalizeText(item?.major || ""),
4816
+ degree: normalizeText(item?.degree || ""),
4817
+ start: normalizeText(item?.start || ""),
4818
+ end: normalizeText(item?.end || "")
4819
+ }))
4820
+ .filter((item) => item.school || item.major || item.degree || item.start || item.end)
4821
+ .slice(0, 2)
4822
+ : []
4524
4823
  };
4525
4824
  }
4526
4825
 
@@ -4653,22 +4952,32 @@ class RecommendScreenCli {
4653
4952
  applyVisionEvidenceGate(result) {
4654
4953
  const parsed = result && typeof result === "object" ? result : {};
4655
4954
  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);
4955
+ const evidenceGateEligible = parsed?.evidenceGateEligible === true
4956
+ || Array.isArray(parsed?.evidence)
4957
+ || Number.isFinite(Number(parsed?.evidenceRawCount))
4958
+ || Number.isFinite(Number(parsed?.evidenceMatchedCount));
4959
+ const parsedEvidence = evidenceGateEligible ? toStringArray(parsed?.evidence) : [];
4960
+ const evidenceRawCount = evidenceGateEligible
4961
+ ? (Number.isFinite(Number(parsed?.evidenceRawCount))
4962
+ ? Number(parsed.evidenceRawCount)
4963
+ : parsedEvidence.length)
4964
+ : null;
4965
+ const evidenceMatchedCount = evidenceGateEligible
4966
+ ? (Number.isFinite(Number(parsed?.evidenceMatchedCount))
4967
+ ? Number(parsed.evidenceMatchedCount)
4968
+ : parsedEvidence.length)
4969
+ : null;
4970
+ const evidenceGateDemoted = parsed?.evidenceGateDemoted === true
4971
+ || (evidenceGateEligible && rawPassed && evidenceMatchedCount <= 0);
4972
+ const cot = normalizeText(parsed?.cot || parsed?.reason || "");
4973
+ const summary = normalizeText(parsed?.summary || cot);
4666
4974
  const finalReason = evidenceGateDemoted
4667
- ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`
4668
- : (reason || (rawPassed ? "满足筛选标准。" : "未满足筛选标准。"));
4975
+ ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${cot ? ` 原始判断依据(CoT): ${cot}` : ""}`
4976
+ : (cot || (rawPassed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。"));
4669
4977
  return {
4670
4978
  passed: evidenceGateDemoted ? false : rawPassed,
4671
4979
  rawPassed,
4980
+ cot: finalReason,
4672
4981
  reason: finalReason,
4673
4982
  summary: summary || finalReason,
4674
4983
  evidence: parsedEvidence,
@@ -4879,11 +5188,10 @@ class RecommendScreenCli {
4879
5188
  "workExpCheckRes 等经历校验项仅作为“需追问”软风险,不得直接据此判定不通过。\n" +
4880
5189
  "活跃度、沟通热度、受欢迎度等运营指标不参与筛选通过判定。\n\n" +
4881
5190
  "要求:\n" +
4882
- "1) reason 必须写出可审计的判定依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
4883
- "2) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
4884
- "3) evidence 至少给出 2 条可在简历中定位的原文短句。\n\n" +
5191
+ "1) 只做结论判断:候选人是否符合筛选标准。\n" +
5192
+ "2) 只返回 passed 布尔值,不要在 JSON 中输出 reason/summary/evidence 等字段。\n\n" +
4885
5193
  "请返回严格 JSON: " +
4886
- "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
5194
+ "{\"passed\": true/false}"
4887
5195
  }
4888
5196
  ];
4889
5197
  for (let index = 0; index < imagePaths.length; index += 1) {
@@ -4943,28 +5251,47 @@ class RecommendScreenCli {
4943
5251
  throw this.buildError("VISION_MODEL_FAILED", `Vision model request failed: ${response.status} ${body.slice(0, 400)}`);
4944
5252
  }
4945
5253
  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;
5254
+ const choice = json?.choices?.[0] || {};
5255
+ const content = flattenChatMessageContent(choice?.message?.content);
5256
+ const parsed = tryExtractJsonObject(content);
5257
+ const parsedPassed = parsePassedDecision(parsed?.passed);
5258
+ const fallbackPassed = parsePassedDecisionFromContent(content);
5259
+ const rawPassed = parsedPassed !== null ? parsedPassed : fallbackPassed;
5260
+ if (rawPassed === null) {
5261
+ throw this.buildError(
5262
+ "VISION_MODEL_FAILED",
5263
+ `Vision model response missing boolean passed decision. content=${truncateText(content, 180)}`
5264
+ );
5265
+ }
5266
+ const cot = normalizeText(extractCotFromChoice(choice, parsed));
5267
+ const reason = cot || (rawPassed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。");
5268
+ const summary = reason;
5269
+ const evidenceGateEligible = Array.isArray(parsed?.evidence)
5270
+ || Number.isFinite(Number(parsed?.evidenceRawCount))
5271
+ || Number.isFinite(Number(parsed?.evidenceMatchedCount));
5272
+ const parsedEvidence = evidenceGateEligible ? toStringArray(parsed?.evidence) : [];
5273
+ const evidenceRawCount = evidenceGateEligible
5274
+ ? (Number.isFinite(Number(parsed?.evidenceRawCount)) ? Number(parsed.evidenceRawCount) : parsedEvidence.length)
5275
+ : null;
5276
+ const evidenceMatchedCount = evidenceGateEligible
5277
+ ? (Number.isFinite(Number(parsed?.evidenceMatchedCount)) ? Number(parsed.evidenceMatchedCount) : parsedEvidence.length)
5278
+ : null;
5279
+ const evidenceGateDemoted = evidenceGateEligible && rawPassed && (evidenceMatchedCount ?? 0) <= 0;
4955
5280
  const finalReason = evidenceGateDemoted
4956
- ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`
4957
- : (reason || (rawPassed ? "满足筛选标准。" : "未满足筛选标准。"));
5281
+ ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始判断依据(CoT): ${reason}` : ""}`
5282
+ : reason;
4958
5283
  const passed = evidenceGateDemoted ? false : rawPassed;
4959
- const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, evidence, passed);
5284
+ const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, parsedEvidence, passed);
4960
5285
  return {
4961
5286
  passed,
4962
5287
  rawPassed,
5288
+ cot: reason,
4963
5289
  reason: enrichedReason,
4964
5290
  summary: summary || enrichedReason,
4965
- evidence,
4966
- evidenceRawCount: evidence.length,
4967
- evidenceMatchedCount: evidence.length,
5291
+ evidence: parsedEvidence,
5292
+ evidenceRawCount,
5293
+ evidenceMatchedCount,
5294
+ evidenceGateEligible,
4968
5295
  evidenceGateDemoted
4969
5296
  };
4970
5297
  }
@@ -5064,17 +5391,17 @@ class RecommendScreenCli {
5064
5391
  "要求:\n" +
5065
5392
  "1) 必须完整阅读上面的全部简历文本。\n" +
5066
5393
  "2) 只能依据简历中真实出现的信息判断,严禁编造不存在的经历/项目/学历/公司。\n" +
5067
- "3) 若证据不足,必须返回 passed=false。\n\n" +
5068
- "4) 当筛选条件涉及应届/毕业年份时,必须以最高学历毕业年份作为主依据;若简历中存在教育时间、毕业时间或可推断年份信息,必须先推断再判断;" +
5394
+ "3) 若文本中包含“人选卡片兜底信息(仅在简历缺失时使用)”段落,只能在主简历缺失对应字段时引用该段,不可覆盖主简历已明确字段。\n" +
5395
+ "4) 若证据不足,必须返回 passed=false。\n\n" +
5396
+ "5) 当筛选条件涉及应届/毕业年份时,必须以最高学历毕业年份作为主依据;若简历中存在教育时间、毕业时间或可推断年份信息,必须先推断再判断;" +
5069
5397
  "只有完全不存在时间线信息时才可写“无法判断”。\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" +
5398
+ "6) 当筛选条件提及“相关经验”时,必须以工作经历或项目经历作为硬性证据;教育/课程/论文/技能/个人优势只能作为补充,不能单独判定满足。\n" +
5399
+ "7) workExpCheckRes 等经历校验项仅作为“需追问”软风险,不得直接据此判定不通过。\n" +
5400
+ "8) 活跃度、沟通热度、受欢迎度等运营指标不参与筛选通过判定。\n" +
5401
+ "9) 只做结论判断:候选人是否符合筛选标准。\n" +
5402
+ "10) 只返回 passed 布尔值,不要在 JSON 中输出 reason/summary/evidence 等字段。\n\n" +
5076
5403
  "请返回严格 JSON: " +
5077
- "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
5404
+ "{\"passed\": true/false}"
5078
5405
  }
5079
5406
  ]
5080
5407
  };
@@ -5100,32 +5427,43 @@ class RecommendScreenCli {
5100
5427
  throw this.buildError("TEXT_MODEL_FAILED", `Text model request failed: ${response.status} ${body.slice(0, 400)}`);
5101
5428
  }
5102
5429
  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);
5430
+ const choice = json?.choices?.[0] || {};
5431
+ const content = flattenChatMessageContent(choice?.message?.content);
5432
+ const parsed = tryExtractJsonObject(content);
5433
+ const cot = normalizeText(extractCotFromChoice(choice, parsed));
5109
5434
  const normalizedResume = normalizeText(safeResumeText);
5110
5435
  const normalizedResumeLower = toLowerSafe(normalizedResume);
5111
- const parsedEvidence = toStringArray(parsed.evidence);
5436
+ const evidenceGateEligible = Array.isArray(parsed?.evidence)
5437
+ || Number.isFinite(Number(parsed?.evidenceRawCount))
5438
+ || Number.isFinite(Number(parsed?.evidenceMatchedCount));
5439
+ const parsedEvidence = evidenceGateEligible ? toStringArray(parsed.evidence) : [];
5112
5440
  const evidence = [];
5113
5441
  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);
5442
+ if (evidenceGateEligible) {
5443
+ for (const item of parsedEvidence) {
5444
+ const matched = matchEvidenceAgainstResume(item, safeResumeText, normalizedResume, normalizedResumeLower);
5445
+ if (matched.matched) {
5446
+ evidence.push(item);
5447
+ } else {
5448
+ unmatchedEvidence.push(item);
5449
+ }
5120
5450
  }
5121
5451
  }
5122
- const rawPassed = parsed.passed === true;
5452
+ const parsedPassed = parsePassedDecision(parsed?.passed);
5453
+ const fallbackPassed = parsePassedDecisionFromContent(content);
5454
+ const rawPassed = parsedPassed !== null ? parsedPassed : fallbackPassed;
5455
+ if (rawPassed === null) {
5456
+ throw this.buildError(
5457
+ "TEXT_MODEL_FAILED",
5458
+ `Text model response missing boolean passed decision. content=${truncateText(content, 180)}`
5459
+ );
5460
+ }
5123
5461
  let passed = rawPassed;
5124
- let finalReason = reason || (passed ? "满足筛选标准。" : "不满足筛选标准。");
5125
- const evidenceGateDemoted = rawPassed && evidence.length <= 0;
5462
+ let finalReason = cot || (passed ? "模型判定符合筛选标准。" : "模型判定不符合筛选标准。");
5463
+ const evidenceGateDemoted = evidenceGateEligible && rawPassed && evidence.length <= 0;
5126
5464
  if (evidenceGateDemoted) {
5127
5465
  passed = false;
5128
- finalReason = `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`;
5466
+ finalReason = `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${finalReason ? ` 原始判断依据(CoT): ${finalReason}` : ""}`;
5129
5467
  if (unmatchedEvidence.length > 0) {
5130
5468
  log(
5131
5469
  `[EVIDENCE_GATE] passed=true 但证据未命中简历原文,已降级为不通过;` +
@@ -5133,15 +5471,17 @@ class RecommendScreenCli {
5133
5471
  );
5134
5472
  }
5135
5473
  }
5474
+ const summary = finalReason;
5136
5475
  const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, evidence, passed);
5137
5476
  return {
5138
5477
  passed,
5139
5478
  rawPassed,
5479
+ cot: finalReason,
5140
5480
  reason: enrichedReason,
5141
5481
  summary: summary || enrichedReason,
5142
5482
  evidence,
5143
- evidenceRawCount: parsedEvidence.length,
5144
- evidenceMatchedCount: evidence.length,
5483
+ evidenceRawCount: evidenceGateEligible ? parsedEvidence.length : null,
5484
+ evidenceMatchedCount: evidenceGateEligible ? evidence.length : null,
5145
5485
  evidenceGateDemoted,
5146
5486
  chunkIndex,
5147
5487
  chunkTotal
@@ -5722,6 +6062,7 @@ class RecommendScreenCli {
5722
6062
  let domCandidateInfo = null;
5723
6063
 
5724
6064
  if (networkCandidateInfo?.resumeText) {
6065
+ networkCandidateInfo = enrichCandidateInfoWithCardProfile(networkCandidateInfo, cardProfile || null);
5725
6066
  screening = await timeCandidateStage(
5726
6067
  "text_model_ms",
5727
6068
  () => this.callTextModel(networkCandidateInfo.resumeText)
@@ -5761,7 +6102,10 @@ class RecommendScreenCli {
5761
6102
  })
5762
6103
  );
5763
6104
  if (lateNetworkCandidateInfo?.resumeText) {
5764
- networkCandidateInfo = lateNetworkCandidateInfo;
6105
+ networkCandidateInfo = enrichCandidateInfoWithCardProfile(
6106
+ lateNetworkCandidateInfo,
6107
+ cardProfile || null
6108
+ );
5765
6109
  screening = await timeCandidateStage(
5766
6110
  "text_model_ms",
5767
6111
  () => this.callTextModel(networkCandidateInfo.resumeText)
@@ -5786,7 +6130,10 @@ class RecommendScreenCli {
5786
6130
  () => this.resolveDomResumeFallback(nextCandidate, cardProfile || null)
5787
6131
  );
5788
6132
  if (domFallback?.networkCandidateInfo?.resumeText) {
5789
- networkCandidateInfo = domFallback.networkCandidateInfo;
6133
+ networkCandidateInfo = enrichCandidateInfoWithCardProfile(
6134
+ domFallback.networkCandidateInfo,
6135
+ cardProfile || null
6136
+ );
5790
6137
  screening = await timeCandidateStage(
5791
6138
  "text_model_ms",
5792
6139
  () => this.callTextModel(networkCandidateInfo.resumeText)
@@ -5806,7 +6153,10 @@ class RecommendScreenCli {
5806
6153
  }
5807
6154
  );
5808
6155
  } else if (domFallback?.domCandidateInfo?.resumeText) {
5809
- domCandidateInfo = domFallback.domCandidateInfo;
6156
+ domCandidateInfo = enrichCandidateInfoWithCardProfile(
6157
+ domFallback.domCandidateInfo,
6158
+ cardProfile || null
6159
+ );
5810
6160
  screening = await timeCandidateStage(
5811
6161
  "text_model_ms",
5812
6162
  () => this.callTextModel(domCandidateInfo.resumeText)
@@ -6169,6 +6519,8 @@ if (require.main === module) {
6169
6519
  isRecoverablePostActionError,
6170
6520
  classifyFinishedWrapState,
6171
6521
  formatResumeApiData,
6522
+ buildCardProfileFallbackText,
6523
+ enrichCandidateInfoWithCardProfile,
6172
6524
  extractEvidenceTokens,
6173
6525
  matchEvidenceAgainstResume
6174
6526
  }
@@ -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
  "南京大学 专业: 数学",
@@ -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();