@reconcrap/boss-recommend-mcp 1.3.17 → 1.3.18

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.17",
3
+ "version": "1.3.18",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -652,6 +652,101 @@ function formatEducationSchoolTags(edu) {
652
652
  return tags.join("、");
653
653
  }
654
654
 
655
+ function inferDegreeRank(degreeText) {
656
+ const normalized = normalizeText(degreeText).toLowerCase();
657
+ if (!normalized) return 0;
658
+ if (/博士|phd|doctor/.test(normalized)) return 7;
659
+ if (/硕士|master/.test(normalized)) return 6;
660
+ if (/本科|学士|bachelor/.test(normalized)) return 5;
661
+ if (/大专|专科|junior/.test(normalized)) return 4;
662
+ if (/高中/.test(normalized)) return 3;
663
+ if (/中专|中技/.test(normalized)) return 2;
664
+ if (/初中|小学|及以下/.test(normalized)) return 1;
665
+ return 0;
666
+ }
667
+
668
+ function normalizeResumeDateToken(value) {
669
+ const raw = normalizeText(value);
670
+ if (!raw) return "";
671
+ const digits = raw.replace(/[^\d]/g, "");
672
+ if (/^\d{8}$/.test(digits)) {
673
+ return `${digits.slice(0, 4)}.${digits.slice(4, 6)}`;
674
+ }
675
+ if (/^\d{6}$/.test(digits)) {
676
+ return `${digits.slice(0, 4)}.${digits.slice(4, 6)}`;
677
+ }
678
+ if (/^\d{4}$/.test(digits)) {
679
+ return digits;
680
+ }
681
+ return raw;
682
+ }
683
+
684
+ function formatResumeTimeRange(startRaw, endRaw, fallbackEnd = "") {
685
+ const start = normalizeResumeDateToken(startRaw);
686
+ const end = normalizeResumeDateToken(endRaw) || normalizeText(fallbackEnd);
687
+ if (start && end) return `${start} ~ ${end}`;
688
+ if (start) return `${start} ~`;
689
+ if (end) return `~ ${end}`;
690
+ return "";
691
+ }
692
+
693
+ function formatResumeTimeRangeFromFields(source, startFields = [], endFields = [], fallbackEnd = "") {
694
+ const startRaw = startFields
695
+ .map((field) => source?.[field])
696
+ .find((value) => normalizeText(value));
697
+ const endRaw = endFields
698
+ .map((field) => source?.[field])
699
+ .find((value) => normalizeText(value));
700
+ return formatResumeTimeRange(startRaw, endRaw, fallbackEnd);
701
+ }
702
+
703
+ function formatNamedListText(items = []) {
704
+ if (!Array.isArray(items) || items.length <= 0) return "";
705
+ return items
706
+ .map((item) => normalizeText(item?.name || item?.subjectName || item?.title || item))
707
+ .filter(Boolean)
708
+ .join("、");
709
+ }
710
+
711
+ function extractYearFromResumeDate(value) {
712
+ const token = normalizeResumeDateToken(value);
713
+ if (!token) return "";
714
+ const match = token.match(/(19|20)\d{2}/);
715
+ return match ? match[0] : "";
716
+ }
717
+
718
+ function deriveHighestEducation(eduExpList = []) {
719
+ const list = Array.isArray(eduExpList) ? eduExpList : [];
720
+ let selected = null;
721
+ for (const edu of list) {
722
+ const degree = formatEducationDegree(edu);
723
+ const rank = inferDegreeRank(degree);
724
+ const endYear = extractYearFromResumeDate(
725
+ edu?.endYearMonStr || edu?.endYearStr || edu?.endDateDesc || edu?.endDate || ""
726
+ );
727
+ const candidate = {
728
+ school: normalizeText(edu?.school || edu?.schoolName || ""),
729
+ degree,
730
+ rank,
731
+ endYear
732
+ };
733
+ if (!selected) {
734
+ selected = candidate;
735
+ continue;
736
+ }
737
+ const selectedYear = Number(selected.endYear || 0);
738
+ const candidateYear = Number(candidate.endYear || 0);
739
+ if (candidate.rank > selected.rank) {
740
+ selected = candidate;
741
+ continue;
742
+ }
743
+ if (candidate.rank === selected.rank && candidateYear > selectedYear) {
744
+ selected = candidate;
745
+ }
746
+ }
747
+ return selected || { school: "", degree: "", rank: 0, endYear: "" };
748
+ }
749
+
655
750
  function splitTextByChunks(text, chunkSize, overlap, maxChunks) {
656
751
  const source = String(text || "");
657
752
  if (!source) return [];
@@ -1536,12 +1631,21 @@ function formatResumeApiData(data) {
1536
1631
  const parts = [];
1537
1632
  const geekDetail = data?.geekDetail || data?.geekDetailInfo || data || {};
1538
1633
  const baseInfo = geekDetail.geekBaseInfo || {};
1539
- const expectList = geekDetail.geekExpectList || [];
1634
+ const expectList = Array.isArray(geekDetail.geekExpectList) && geekDetail.geekExpectList.length > 0
1635
+ ? geekDetail.geekExpectList
1636
+ : Array.isArray(geekDetail.geekExpPosList) && geekDetail.geekExpPosList.length > 0
1637
+ ? geekDetail.geekExpPosList
1638
+ : geekDetail.showExpectPosition
1639
+ ? [geekDetail.showExpectPosition]
1640
+ : [];
1540
1641
  const workExpList = geekDetail.geekWorkExpList || [];
1541
1642
  const projExpList = geekDetail.geekProjExpList || [];
1542
1643
  const eduExpList = geekDetail.geekEduExpList || geekDetail.geekEducationList || [];
1543
1644
  const advantage = geekDetail.geekAdvantage || baseInfo.userDesc || baseInfo.userDescription || "";
1544
1645
  const skillList = geekDetail.geekSkillList || geekDetail.skillList || [];
1646
+ const certificationList = geekDetail.geekCertificationList || [];
1647
+ const workExpCheckRes = Array.isArray(geekDetail.workExpCheckRes) ? geekDetail.workExpCheckRes : [];
1648
+ const highestEdu = deriveHighestEducation(eduExpList);
1545
1649
 
1546
1650
  parts.push("=== 基本信息 ===");
1547
1651
  if (baseInfo.name) parts.push(`姓名: ${baseInfo.name}`);
@@ -1549,6 +1653,9 @@ function formatResumeApiData(data) {
1549
1653
  if (baseInfo.gender !== undefined) parts.push(`性别: ${baseInfo.gender === 1 ? "男" : "女"}`);
1550
1654
  if (baseInfo.degreeCategory) parts.push(`学历: ${baseInfo.degreeCategory}`);
1551
1655
  if (baseInfo.workYearDesc) parts.push(`工作经验: ${baseInfo.workYearDesc}`);
1656
+ if (typeof baseInfo.freshGraduate === "number") parts.push(`应届状态: ${baseInfo.freshGraduate === 1 ? "应届生" : "非应届生"}`);
1657
+ const workDate = normalizeResumeDateToken(baseInfo.workDate8);
1658
+ if (workDate) parts.push(`参加工作时间: ${workDate}`);
1552
1659
  if (baseInfo.activeTimeDesc) parts.push(`活跃状态: ${baseInfo.activeTimeDesc}`);
1553
1660
  if (baseInfo.applyStatusContent) parts.push(`求职状态: ${baseInfo.applyStatusContent}`);
1554
1661
 
@@ -1573,13 +1680,27 @@ function formatResumeApiData(data) {
1573
1680
  const company = exp.company || "";
1574
1681
  const position = stripHtml(exp.positionName || "");
1575
1682
  parts.push(`${index + 1}. ${company} - ${position}`);
1576
- if (exp.startYearMonStr) {
1577
- parts.push(` 时间: ${exp.startYearMonStr} ~ ${exp.endYearMonStr || "至今"}`);
1683
+ const workTime = formatResumeTimeRangeFromFields(
1684
+ exp,
1685
+ ["startYearMonStr", "startYearStr", "startDateDesc", "startDate"],
1686
+ ["endYearMonStr", "endYearStr", "endDateDesc", "endDate"],
1687
+ "至今"
1688
+ );
1689
+ if (workTime) {
1690
+ parts.push(` 时间: ${workTime}`);
1578
1691
  }
1579
1692
  const workContent = exp.responsibility || exp.workContent || "";
1580
1693
  if (workContent) {
1581
1694
  parts.push(` 职责: ${stripHtml(workContent)}`);
1582
1695
  }
1696
+ const workPerformance = exp.workPerformance || exp.performance || "";
1697
+ if (workPerformance) {
1698
+ parts.push(` 成果: ${stripHtml(workPerformance)}`);
1699
+ }
1700
+ const workEmphasis = exp.workEmphasis || "";
1701
+ if (workEmphasis) {
1702
+ parts.push(` 补充: ${stripHtml(workEmphasis)}`);
1703
+ }
1583
1704
  });
1584
1705
  }
1585
1706
 
@@ -1588,8 +1709,14 @@ function formatResumeApiData(data) {
1588
1709
  projExpList.forEach((proj, index) => {
1589
1710
  parts.push(`${index + 1}. ${proj.name || proj.projectName || "未知项目"}`);
1590
1711
  if (proj.roleName) parts.push(` 角色: ${proj.roleName}`);
1591
- if (proj.startYearMonStr) {
1592
- parts.push(` 时间: ${proj.startYearMonStr} ~ ${proj.endYearMonStr || "至今"}`);
1712
+ const projTime = formatResumeTimeRangeFromFields(
1713
+ proj,
1714
+ ["startYearMonStr", "startYearStr", "startDateDesc", "startDate"],
1715
+ ["endYearMonStr", "endYearStr", "endDateDesc", "endDate"],
1716
+ "至今"
1717
+ );
1718
+ if (projTime) {
1719
+ parts.push(` 时间: ${projTime}`);
1593
1720
  }
1594
1721
  const projectDescription = proj.description || proj.projectDescription || "";
1595
1722
  if (projectDescription) parts.push(` 描述: ${stripHtml(projectDescription)}`);
@@ -1605,15 +1732,35 @@ function formatResumeApiData(data) {
1605
1732
  if (edu.major || edu.majorName) parts.push(` 专业: ${edu.major || edu.majorName}`);
1606
1733
  const eduDegree = formatEducationDegree(edu);
1607
1734
  if (eduDegree) parts.push(` 学历: ${eduDegree}`);
1608
- const eduStart = edu.startYearMonStr || edu.startYearStr;
1609
- if (eduStart) {
1610
- const eduEnd = edu.endYearMonStr || edu.endYearStr || "";
1611
- parts.push(` 时间: ${eduStart} ~ ${eduEnd}`);
1735
+ const eduTime = formatResumeTimeRangeFromFields(
1736
+ edu,
1737
+ ["startYearMonStr", "startYearStr", "startDateDesc", "startDate"],
1738
+ ["endYearMonStr", "endYearStr", "endDateDesc", "endDate"]
1739
+ );
1740
+ if (eduTime) {
1741
+ parts.push(` 时间: ${eduTime}`);
1612
1742
  }
1613
1743
  const schoolTags = formatEducationSchoolTags(edu);
1614
1744
  if (schoolTags) {
1615
1745
  parts.push(` 学校标签: ${schoolTags}`);
1616
1746
  }
1747
+ const eduDescription = stripHtml(edu.eduDescription || edu.description || "");
1748
+ if (eduDescription) {
1749
+ parts.push(` 描述: ${eduDescription}`);
1750
+ }
1751
+ const courseDesc = stripHtml(edu.courseDesc || "");
1752
+ if (courseDesc) {
1753
+ parts.push(` 课程/研究: ${courseDesc}`);
1754
+ }
1755
+ const keySubjects = formatNamedListText(edu.keySubjectList || []);
1756
+ if (keySubjects) {
1757
+ parts.push(` 核心课程: ${keySubjects}`);
1758
+ }
1759
+ const thesisTitle = normalizeText(edu.thesisTitle || "");
1760
+ const thesisDesc = stripHtml(edu.thesisDesc || "");
1761
+ if (thesisTitle || thesisDesc) {
1762
+ parts.push(` 论文: ${thesisTitle}${thesisDesc ? ` - ${thesisDesc}` : ""}`);
1763
+ }
1617
1764
  });
1618
1765
  }
1619
1766
 
@@ -1626,6 +1773,33 @@ function formatResumeApiData(data) {
1626
1773
  });
1627
1774
  }
1628
1775
 
1776
+ if (certificationList.length > 0) {
1777
+ parts.push("\n=== 资格证书 ===");
1778
+ certificationList.forEach((cert) => {
1779
+ const certName = normalizeText(cert?.certName || cert?.name || "");
1780
+ if (certName) parts.push(`- ${certName}`);
1781
+ });
1782
+ }
1783
+
1784
+ parts.push("\n=== 结构化判定线索 ===");
1785
+ if (highestEdu.degree) parts.push(`最高学历: ${highestEdu.degree}`);
1786
+ if (highestEdu.school) parts.push(`最高学历学校: ${highestEdu.school}`);
1787
+ if (highestEdu.endYear) parts.push(`最高学历毕业年份: ${highestEdu.endYear}`);
1788
+ parts.push(`是否有工作经历: ${workExpList.length > 0 ? "是" : "否"}`);
1789
+ parts.push(`是否有项目经历: ${projExpList.length > 0 ? "是" : "否"}`);
1790
+ parts.push("相关经验硬判口径: 仅工作经历/项目经历可作为“相关经验”硬性证据;教育/课程/技能仅作补充。");
1791
+ if (workExpCheckRes.length > 0) {
1792
+ const riskText = workExpCheckRes
1793
+ .map((item) => normalizeText(item?.desc || item?.firstTip || item?.chatDesc || ""))
1794
+ .filter(Boolean)
1795
+ .slice(0, 3)
1796
+ .join(";");
1797
+ if (riskText) {
1798
+ parts.push(`软风险提示(需追问,不直接淘汰): ${riskText}`);
1799
+ }
1800
+ }
1801
+ parts.push("判定忽略项: 活跃度/沟通热度/受欢迎度等运营指标不参与通过判定。");
1802
+
1629
1803
  return parts.join("\n");
1630
1804
  }
1631
1805
 
@@ -4698,7 +4872,12 @@ class RecommendScreenCli {
4698
4872
  `筛选标准:\n${this.args.criteria}\n\n` +
4699
4873
  "你将收到候选人完整简历的一个或多个顺序分段图片。必须完整阅读全部分段后再判断," +
4700
4874
  "不能只根据前几段下结论;后续分段中的教育、项目、经历或否定信息必须纳入最终判断。" +
4701
- "严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n\n" +
4875
+ "严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n" +
4876
+ "当筛选条件涉及应届/毕业年份时,必须以最高学历毕业年份作为主依据;若简历中出现教育起止时间、毕业时间或可推断年份信息,必须先推断再判断," +
4877
+ "只有完全不存在可推断时间信息时才可以写“无法判断”。\n" +
4878
+ "当筛选条件提及“相关经验”时,必须以工作经历或项目经历作为硬性证据;教育/课程/论文/技能/个人优势只能作为补充,不能单独判定满足。\n" +
4879
+ "workExpCheckRes 等经历校验项仅作为“需追问”软风险,不得直接据此判定不通过。\n" +
4880
+ "活跃度、沟通热度、受欢迎度等运营指标不参与筛选通过判定。\n\n" +
4702
4881
  "要求:\n" +
4703
4882
  "1) reason 必须写出可审计的判定依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
4704
4883
  "2) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
@@ -4886,9 +5065,14 @@ class RecommendScreenCli {
4886
5065
  "1) 必须完整阅读上面的全部简历文本。\n" +
4887
5066
  "2) 只能依据简历中真实出现的信息判断,严禁编造不存在的经历/项目/学历/公司。\n" +
4888
5067
  "3) 若证据不足,必须返回 passed=false。\n\n" +
4889
- "4) reason 必须写出可审计依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
4890
- "5) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
4891
- "6) evidence 至少给出 2 条可在简历原文定位的证据短句。\n\n" +
5068
+ "4) 当筛选条件涉及应届/毕业年份时,必须以最高学历毕业年份作为主依据;若简历中存在教育时间、毕业时间或可推断年份信息,必须先推断再判断;" +
5069
+ "只有完全不存在时间线信息时才可写“无法判断”。\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" +
4892
5076
  "请返回严格 JSON: " +
4893
5077
  "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
4894
5078
  }
@@ -959,6 +959,60 @@ function testFormatResumeApiDataShouldPreserveEducationTagsAndProjectDescription
959
959
  assert.equal(formatted.includes("描述: 采用stable diffusion进行编辑实验"), true);
960
960
  }
961
961
 
962
+ function testFormatResumeApiDataShouldIncludeStructuredJudgementHints() {
963
+ const source = {
964
+ geekDetailInfo: {
965
+ geekBaseInfo: {
966
+ name: "测试候选人",
967
+ degreeCategory: "硕士"
968
+ },
969
+ geekWorkExpList: [
970
+ {
971
+ company: "中科院",
972
+ positionName: "科研助理",
973
+ startDate: "20241001",
974
+ endDate: "",
975
+ responsibility: "科研以及项目"
976
+ }
977
+ ],
978
+ geekProjExpList: [],
979
+ geekEduExpList: [
980
+ {
981
+ school: "科克大学",
982
+ major: "理学",
983
+ degreeName: "硕士",
984
+ startDateDesc: "2020",
985
+ endDateDesc: "2023"
986
+ },
987
+ {
988
+ school: "东北大学",
989
+ major: "数学与应用数学",
990
+ degreeName: "本科",
991
+ startDate: "20140101",
992
+ endDate: "20180101"
993
+ }
994
+ ],
995
+ workExpCheckRes: [
996
+ {
997
+ desc: "毕业同年未填写工作经历"
998
+ }
999
+ ],
1000
+ jobCompetitive: {
1001
+ tips: [{ content: "受欢迎程度高" }]
1002
+ }
1003
+ }
1004
+ };
1005
+ const formatted = __testables.formatResumeApiData(source);
1006
+ assert.equal(formatted.includes("=== 结构化判定线索 ==="), true);
1007
+ assert.equal(formatted.includes("最高学历: 硕士"), true);
1008
+ assert.equal(formatted.includes("最高学历毕业年份: 2023"), true);
1009
+ assert.equal(formatted.includes("是否有工作经历: 是"), true);
1010
+ assert.equal(formatted.includes("是否有项目经历: 否"), true);
1011
+ assert.equal(formatted.includes("相关经验硬判口径"), true);
1012
+ assert.equal(formatted.includes("软风险提示(需追问,不直接淘汰): 毕业同年未填写工作经历"), true);
1013
+ assert.equal(formatted.includes("判定忽略项: 活跃度/沟通热度/受欢迎度等运营指标不参与通过判定。"), true);
1014
+ }
1015
+
962
1016
  function testEvidenceTokenMatcherShouldSupportParaphrasedEvidence() {
963
1017
  const resume = [
964
1018
  "南京大学 专业: 数学",
@@ -1702,6 +1756,7 @@ async function main() {
1702
1756
  testFavoriteActionParserShouldOnlyTrustKnownRequestShapes();
1703
1757
  testFinishedWrapClassifierShouldNotTreatLoadMoreAsBottom();
1704
1758
  testFormatResumeApiDataShouldPreserveEducationTagsAndProjectDescription();
1759
+ testFormatResumeApiDataShouldIncludeStructuredJudgementHints();
1705
1760
  testEvidenceTokenMatcherShouldSupportParaphrasedEvidence();
1706
1761
  testCheckpointPayloadShouldIncludeCandidateAudits();
1707
1762
  testCheckpointShouldPersistAndRestoreInputSummary();