Browse Source
- add sort=match to company candidate list with in-memory scoring - matchScore normalized over known signals, integer clamped to 0-100 - matchReasons as short phrases, returned consistently for all sorts - config-driven signal weights under app.talent-match.weightbackup/master-local-20261012
11 changed files with 495 additions and 51 deletions
@ -0,0 +1,191 @@ |
|||||
|
package com.mydreamjob.recommend; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.type.TypeReference; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.profile.StudentProfile; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Value; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.regex.Matcher; |
||||
|
import java.util.regex.Pattern; |
||||
|
|
||||
|
/** |
||||
|
* 岗位匹配度确定性计算器(企业候选人看板 `sort=match`)。 |
||||
|
* |
||||
|
* <p>由 5 个加权信号组成(技能/学历/经验/城市/背景参考),每信号 0-100, |
||||
|
* 汇总后取整为 `matchScore`(0-100,四舍五入)。纯确定性规则,无 AI。 |
||||
|
* 信号权重来自 {@code app.talent-match.weight.*} 配置,总和约定为 1.00。</p> |
||||
|
*/ |
||||
|
@Slf4j |
||||
|
@Component |
||||
|
public class TalentMatchScorer { |
||||
|
|
||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
private static final Pattern EXPERIENCE_REQ = Pattern.compile("(\\d+)\\s*[-~至]\\s*(\\d+)\\s*年"); |
||||
|
private static final Pattern EXPERIENCE_MIN = Pattern.compile("([1-9]\\d*)\\s*年"); |
||||
|
|
||||
|
// 学历档次(数值越大越高)
|
||||
|
private static final int LVL_PHD = 4; |
||||
|
private static final int LVL_MASTER = 3; |
||||
|
private static final int LVL_BACHELOR = 2; |
||||
|
private static final int LVL_ASSOCIATE = 1; |
||||
|
|
||||
|
@Value("${app.talent-match.weight.skill:0.40}") |
||||
|
private double wSkill; |
||||
|
@Value("${app.talent-match.weight.edu:0.25}") |
||||
|
private double wEdu; |
||||
|
@Value("${app.talent-match.weight.exp:0.20}") |
||||
|
private double wExp; |
||||
|
@Value("${app.talent-match.weight.loc:0.10}") |
||||
|
private double wLoc; |
||||
|
@Value("${app.talent-match.weight.score:0.05}") |
||||
|
private double wScore; |
||||
|
|
||||
|
/** |
||||
|
* 计算指定岗位与候选人档案的匹配度。overallScore 为候选综合评分(可为 null)。 |
||||
|
* |
||||
|
* <p>采用「已知信号加权归一化」:仅对两端数据都具备的信号计分, |
||||
|
* 缺失/无法判断的信号不参与(不因数据缺失而扣分)。 |
||||
|
* 分值 = Σ(已知信号得分 × 权重) / Σ(已知权重),并夹取到 [0,100]。 |
||||
|
* |
||||
|
* @return matchScore(0-100 整数,不会低于 0 或超过 100)与简短理由数组 |
||||
|
*/ |
||||
|
public MatchResult match(Job job, StudentProfile profile, BigDecimal overallScore) { |
||||
|
List<String> reasons = new ArrayList<>(); |
||||
|
double weighted = 0.0; |
||||
|
double knownWeight = 0.0; |
||||
|
|
||||
|
Double skill = skillScore(job, profile, reasons); |
||||
|
if (skill != null) { weighted += skill * wSkill; knownWeight += wSkill; } |
||||
|
Double edu = educationScore(job, profile, reasons); |
||||
|
if (edu != null) { weighted += edu * wEdu; knownWeight += wEdu; } |
||||
|
Double exp = experienceScore(job, profile, reasons); |
||||
|
if (exp != null) { weighted += exp * wExp; knownWeight += wExp; } |
||||
|
Double loc = cityScore(job, profile, reasons); |
||||
|
if (loc != null) { weighted += loc * wLoc; knownWeight += wLoc; } |
||||
|
Double bg = backgroundScore(overallScore, reasons); |
||||
|
if (bg != null) { weighted += bg * wScore; knownWeight += wScore; } |
||||
|
|
||||
|
if (knownWeight == 0.0) { |
||||
|
return new MatchResult(0, List.copyOf(reasons)); |
||||
|
} |
||||
|
int matchScore = (int) Math.round(weighted / knownWeight); |
||||
|
matchScore = Math.max(0, Math.min(100, matchScore)); |
||||
|
return new MatchResult(matchScore, List.copyOf(reasons)); |
||||
|
} |
||||
|
|
||||
|
/** 技能匹配:候选人技能命中岗位要求关键词的占比 → 0-100;数据不足返回 null */ |
||||
|
private Double skillScore(Job job, StudentProfile profile, List<String> reasons) { |
||||
|
if (profile == null || isBlank(profile.getSkillsJson())) return null; |
||||
|
if (job == null || isBlank(job.getRequirements())) return null; |
||||
|
List<String> skills = parseSkills(profile.getSkillsJson()); |
||||
|
if (skills.isEmpty()) return null; |
||||
|
// 忽略大小写与空格,兼容「Spring Boot」与「SpringBoot」同义的写法
|
||||
|
String reqNorm = job.getRequirements().toLowerCase().replace(" ", ""); |
||||
|
long hit = skills.stream() |
||||
|
.filter(s -> { |
||||
|
String sn = s.toLowerCase().replace(" ", ""); |
||||
|
return !sn.isBlank() && reqNorm.contains(sn); |
||||
|
}) |
||||
|
.count(); |
||||
|
double score = hit * 100.0 / skills.size(); |
||||
|
if (score > 0) reasons.add("技能匹配"); |
||||
|
return score; |
||||
|
} |
||||
|
|
||||
|
/** 学历匹配:达到/高于岗位最低学历=100,低于按档位折算;要求未写明学历时返回 null */ |
||||
|
private Double educationScore(Job job, StudentProfile profile, List<String> reasons) { |
||||
|
if (profile == null || isBlank(profile.getEducation())) return null; |
||||
|
int reqLvl = job == null ? 0 : educationLevel(job.getRequirements()); |
||||
|
if (reqLvl == 0) return null; // 岗位未声明学历要求 → 该信号无法判断
|
||||
|
int candLvl = educationLevel(profile.getEducation()); |
||||
|
if (candLvl >= reqLvl) { |
||||
|
reasons.add("学历符合"); |
||||
|
return 100.0; |
||||
|
} |
||||
|
reasons.add("学历略低"); |
||||
|
return candLvl * 100.0 / reqLvl; |
||||
|
} |
||||
|
|
||||
|
/** 经验匹配:毕业年数对照岗位要求经验区间 → 0-100;岗位未声明年限时返回 null */ |
||||
|
private Double experienceScore(Job job, StudentProfile profile, List<String> reasons) { |
||||
|
if (profile == null || profile.getGraduationYear() == null || profile.getGraduationYear() <= 0) return null; |
||||
|
String req = job == null ? "" : nullToEmpty(job.getRequirements()); |
||||
|
Integer required = parseExperience(req); |
||||
|
if (required == null) return null; // 岗位未声明经验要求 → 该信号无法判断
|
||||
|
int years = LocalDate.now().getYear() - profile.getGraduationYear(); |
||||
|
if (years >= required) { |
||||
|
reasons.add("经验匹配"); |
||||
|
return 100.0; |
||||
|
} |
||||
|
reasons.add("经验不足"); |
||||
|
return Math.max(0, years * 100.0 / required); |
||||
|
} |
||||
|
|
||||
|
/** 城市匹配:岗位城市与候选人所在地一致=100,否则=50;任一缺失返回 null */ |
||||
|
private Double cityScore(Job job, StudentProfile profile, List<String> reasons) { |
||||
|
if (job == null || isBlank(job.getCity()) || profile == null || isBlank(profile.getLocation())) return null; |
||||
|
reasons.add(job.getCity().equals(profile.getLocation()) ? "城市匹配" : "城市待定"); |
||||
|
return job.getCity().equals(profile.getLocation()) ? 100.0 : 50.0; |
||||
|
} |
||||
|
|
||||
|
/** 背景参考:候选综合评分映射档位;无评分时返回 null */ |
||||
|
private Double backgroundScore(BigDecimal overallScore, List<String> reasons) { |
||||
|
if (overallScore == null) return null; |
||||
|
double v = overallScore.doubleValue(); |
||||
|
if (v >= 85) { reasons.add("综合评分高"); return 100.0; } |
||||
|
if (v >= 60) { reasons.add("综合评分中"); return 70.0; } |
||||
|
reasons.add("综合评分低"); |
||||
|
return 40.0; |
||||
|
} |
||||
|
|
||||
|
private int educationLevel(String text) { |
||||
|
if (isBlank(text)) return 0; |
||||
|
if (text.contains("博士")) return LVL_PHD; |
||||
|
if (text.contains("硕士") || text.contains("研究生")) return LVL_MASTER; |
||||
|
if (text.contains("本科")) return LVL_BACHELOR; |
||||
|
if (text.contains("大专") || text.contains("专科")) return LVL_ASSOCIATE; |
||||
|
return 0; |
||||
|
} |
||||
|
|
||||
|
/** 解析经验要求:优先取区间下限,其次取"X年"最小值,解析不到返回 null */ |
||||
|
private Integer parseExperience(String text) { |
||||
|
Matcher range = EXPERIENCE_REQ.matcher(text); |
||||
|
if (range.find()) return Integer.valueOf(range.group(1)); |
||||
|
Matcher min = EXPERIENCE_MIN.matcher(text); |
||||
|
int lowest = Integer.MAX_VALUE; |
||||
|
while (min.find()) { |
||||
|
lowest = Math.min(lowest, Integer.parseInt(min.group(1))); |
||||
|
} |
||||
|
return lowest == Integer.MAX_VALUE ? null : lowest; |
||||
|
} |
||||
|
|
||||
|
private List<String> parseSkills(String json) { |
||||
|
if (isBlank(json)) return List.of(); |
||||
|
try { |
||||
|
List<String> list = MAPPER.readValue(json, new TypeReference<List<String>>() {}); |
||||
|
return list == null ? List.of() : list.stream().filter(s -> s != null && !s.isBlank()).toList(); |
||||
|
} catch (Exception e) { |
||||
|
log.warn("failed to parse skillsJson: {}", e.getMessage()); |
||||
|
return List.of(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private boolean isBlank(String s) { |
||||
|
return s == null || s.isBlank(); |
||||
|
} |
||||
|
|
||||
|
private String nullToEmpty(String s) { |
||||
|
return s == null ? "" : s; |
||||
|
} |
||||
|
|
||||
|
/** 匹配结果载体 */ |
||||
|
public record MatchResult(int matchScore, List<String> reasons) {} |
||||
|
} |
||||
@ -0,0 +1,130 @@ |
|||||
|
package com.mydreamjob.recommend; |
||||
|
|
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.profile.StudentProfile; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
class TalentMatchScorerTest { |
||||
|
|
||||
|
private TalentMatchScorer newScorer() { |
||||
|
TalentMatchScorer s = new TalentMatchScorer(); |
||||
|
// 注入与 application.yml 一致的默认权重(skill 0.40 / edu 0.25 / exp 0.20 / loc 0.10 / score 0.05)
|
||||
|
ReflectionTestUtils.setField(s, "wSkill", 0.40); |
||||
|
ReflectionTestUtils.setField(s, "wEdu", 0.25); |
||||
|
ReflectionTestUtils.setField(s, "wExp", 0.20); |
||||
|
ReflectionTestUtils.setField(s, "wLoc", 0.10); |
||||
|
ReflectionTestUtils.setField(s, "wScore", 0.05); |
||||
|
return s; |
||||
|
} |
||||
|
|
||||
|
private Job job(String requirements, String city) { |
||||
|
return Job.builder().requirements(requirements).city(city).build(); |
||||
|
} |
||||
|
|
||||
|
private StudentProfile profile(String education, String location, Short gradYear, String skillsJson) { |
||||
|
return StudentProfile.builder() |
||||
|
.education(education).location(location).graduationYear(gradYear) |
||||
|
.skillsJson(skillsJson).build(); |
||||
|
} |
||||
|
|
||||
|
@Test void skillMatch_reasonIsSkillMatchWithoutDetail() { |
||||
|
Job j = job("熟悉Java、SpringBoot,三年以上经验,本科", "北京"); |
||||
|
StudentProfile p = profile("本科", "北京", (short) 2020, |
||||
|
"[\"Java\",\"SpringBoot\",\"Python\"]"); |
||||
|
TalentMatchScorer s = newScorer(); |
||||
|
TalentMatchScorer.MatchResult r = s.match(j, p, null); |
||||
|
assertTrue(r.reasons().contains("技能匹配")); |
||||
|
// 理由为简短短语,不含技能明细
|
||||
|
assertFalse(r.reasons().stream().anyMatch(x -> x.contains("Java") || x.contains("Spring"))); |
||||
|
} |
||||
|
|
||||
|
@Test void educationMet_shouldBe100AndEduMatch() { |
||||
|
Job j = job("要求本科及以上", "北京"); |
||||
|
StudentProfile p = profile("硕士", "北京", (short) 2020, null); |
||||
|
TalentMatchScorer s = newScorer(); |
||||
|
TalentMatchScorer.MatchResult r = s.match(j, p, null); |
||||
|
assertTrue(r.reasons().contains("学历符合")); |
||||
|
// 已知信号=学历(100×0.25)+城市(100×0.10),累计 0.35 → 100/100
|
||||
|
assertEquals(100, r.matchScore()); |
||||
|
} |
||||
|
|
||||
|
@Test void educationBelow_shouldBeLowerAndEduLow() { |
||||
|
Job j = job("要求本科", "北京"); |
||||
|
StudentProfile p = profile("大专", "北京", (short) 2020, null); |
||||
|
TalentMatchScorer s = newScorer(); |
||||
|
TalentMatchScorer.MatchResult r = s.match(j, p, null); |
||||
|
assertTrue(r.reasons().contains("学历略低")); |
||||
|
// (50×0.25 + 100×0.10)/(0.25+0.10) = 22.5/0.35 ≈ 64
|
||||
|
assertEquals(64, r.matchScore()); |
||||
|
} |
||||
|
|
||||
|
@Test void cityMatch_versusDifferentCity() { |
||||
|
Job j = job("本科", "北京"); |
||||
|
StudentProfile same = profile("本科", "北京", (short) 2020, null); |
||||
|
StudentProfile diff = profile("本科", "上海", (short) 2020, null); |
||||
|
TalentMatchScorer s = newScorer(); |
||||
|
TalentMatchScorer.MatchResult rSame = s.match(j, same, null); |
||||
|
TalentMatchScorer.MatchResult rDiff = s.match(j, diff, null); |
||||
|
assertTrue(rSame.reasons().contains("城市匹配")); |
||||
|
assertTrue(rDiff.reasons().contains("城市待定")); |
||||
|
assertTrue(rSame.matchScore() > rDiff.matchScore()); |
||||
|
// 同城 (100×0.25+100×0.10)/0.35=100;异城 (100×0.25+50×0.10)/0.35≈86
|
||||
|
assertEquals(100, rSame.matchScore()); |
||||
|
assertEquals(86, rDiff.matchScore()); |
||||
|
} |
||||
|
|
||||
|
@Test void experienceMet_versusLack() { |
||||
|
Job j = job("3年以上经验", "北京"); |
||||
|
StudentProfile met = profile("本科", "北京", (short) 2018, null); // 8 年
|
||||
|
StudentProfile lack = profile("本科", "北京", (short) 2025, null); // 1 年
|
||||
|
TalentMatchScorer s = newScorer(); |
||||
|
TalentMatchScorer.MatchResult rMet = s.match(j, met, null); |
||||
|
TalentMatchScorer.MatchResult rLack = s.match(j, lack, null); |
||||
|
assertTrue(rMet.reasons().contains("经验匹配")); |
||||
|
assertTrue(rLack.reasons().contains("经验不足")); |
||||
|
assertTrue(rMet.matchScore() > rLack.matchScore()); |
||||
|
} |
||||
|
|
||||
|
@Test void backgroundScoreHighAndLow() { |
||||
|
Job j = job("本科", "北京"); |
||||
|
StudentProfile p = profile("本科", "北京", (short) 2020, null); |
||||
|
TalentMatchScorer s = newScorer(); |
||||
|
TalentMatchScorer.MatchResult rHigh = s.match(j, p, new BigDecimal("90.00")); |
||||
|
TalentMatchScorer.MatchResult rLow = s.match(j, p, new BigDecimal("40.00")); |
||||
|
assertTrue(rHigh.reasons().contains("综合评分高")); |
||||
|
assertTrue(rLow.reasons().contains("综合评分低")); |
||||
|
assertTrue(rHigh.matchScore() > rLow.matchScore()); |
||||
|
} |
||||
|
|
||||
|
@Test void missingData_shouldNotNpeAndScoreDegraded() { |
||||
|
Job j = job("本科", "北京"); |
||||
|
TalentMatchScorer s = newScorer(); |
||||
|
TalentMatchScorer.MatchResult r = s.match(j, null, null); |
||||
|
assertEquals(0, r.matchScore()); |
||||
|
assertTrue(r.reasons().isEmpty()); |
||||
|
} |
||||
|
|
||||
|
@Test void allSignalsHit_intScoreAndShortReasons() { |
||||
|
Job j = job("要求本科,熟悉Java,3年经验", "北京"); |
||||
|
StudentProfile p = profile("硕士", "北京", (short) 2018, |
||||
|
"[\"Java\",\"SpringBoot\"]"); |
||||
|
TalentMatchScorer s = newScorer(); |
||||
|
TalentMatchScorer.MatchResult r = s.match(j, p, new BigDecimal("90.00")); |
||||
|
// 整数 0-100
|
||||
|
assertTrue(r.matchScore() >= 0 && r.matchScore() <= 100); |
||||
|
assertEquals(r.matchScore(), (int) Math.round(r.matchScore())); |
||||
|
// 每信号至多一句
|
||||
|
assertTrue(r.reasons().size() <= 5); |
||||
|
assertTrue(r.reasons().contains("技能匹配")); |
||||
|
assertTrue(r.reasons().contains("学历符合")); |
||||
|
assertTrue(r.reasons().contains("经验匹配")); |
||||
|
assertTrue(r.reasons().contains("城市匹配")); |
||||
|
assertTrue(r.reasons().contains("综合评分高")); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue