修改用户个人资料识别逻辑
This commit is contained in:
@@ -45,7 +45,7 @@ public class UserProfileService {
|
|||||||
private UserProfileCompetitionMapper competitionMapper;
|
private UserProfileCompetitionMapper competitionMapper;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private org.jiayunet.service.UserSkillTagMatchService userSkillTagMatchService;
|
private org.jiayunet.service.UserProfileAnalyzeService userProfileAnalyzeService;
|
||||||
|
|
||||||
// ==================== 主表 ====================
|
// ==================== 主表 ====================
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ public class UserProfileService {
|
|||||||
profile.setUpdateTime(now);
|
profile.setUpdateTime(now);
|
||||||
userProfileMapper.insert(profile);
|
userProfileMapper.insert(profile);
|
||||||
}
|
}
|
||||||
userSkillTagMatchService.matchUserSkillTags(userId);
|
userProfileAnalyzeService.analyzeUserProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 教育经历 ====================
|
// ==================== 教育经历 ====================
|
||||||
@@ -114,7 +114,7 @@ public class UserProfileService {
|
|||||||
item.setUpdateTime(now);
|
item.setUpdateTime(now);
|
||||||
});
|
});
|
||||||
educationMapper.batchInsert(list);
|
educationMapper.batchInsert(list);
|
||||||
userSkillTagMatchService.matchUserSkillTags(userId);
|
userProfileAnalyzeService.analyzeUserProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 工作经历 ====================
|
// ==================== 工作经历 ====================
|
||||||
@@ -151,7 +151,7 @@ public class UserProfileService {
|
|||||||
item.setUpdateTime(now);
|
item.setUpdateTime(now);
|
||||||
});
|
});
|
||||||
workMapper.batchInsert(list);
|
workMapper.batchInsert(list);
|
||||||
userSkillTagMatchService.matchUserSkillTags(userId);
|
userProfileAnalyzeService.analyzeUserProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 实习经历 ====================
|
// ==================== 实习经历 ====================
|
||||||
@@ -188,7 +188,7 @@ public class UserProfileService {
|
|||||||
item.setUpdateTime(now);
|
item.setUpdateTime(now);
|
||||||
});
|
});
|
||||||
internshipMapper.batchInsert(list);
|
internshipMapper.batchInsert(list);
|
||||||
userSkillTagMatchService.matchUserSkillTags(userId);
|
userProfileAnalyzeService.analyzeUserProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 项目经历 ====================
|
// ==================== 项目经历 ====================
|
||||||
@@ -225,7 +225,7 @@ public class UserProfileService {
|
|||||||
item.setUpdateTime(now);
|
item.setUpdateTime(now);
|
||||||
});
|
});
|
||||||
projectMapper.batchInsert(list);
|
projectMapper.batchInsert(list);
|
||||||
userSkillTagMatchService.matchUserSkillTags(userId);
|
userProfileAnalyzeService.analyzeUserProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 竞赛经历 ====================
|
// ==================== 竞赛经历 ====================
|
||||||
@@ -262,7 +262,7 @@ public class UserProfileService {
|
|||||||
item.setUpdateTime(now);
|
item.setUpdateTime(now);
|
||||||
});
|
});
|
||||||
competitionMapper.batchInsert(list);
|
competitionMapper.batchInsert(list);
|
||||||
userSkillTagMatchService.matchUserSkillTags(userId);
|
userProfileAnalyzeService.analyzeUserProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 内部方法 ====================
|
// ==================== 内部方法 ====================
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package org.jiayunet.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jiayunet.ai.AiChatAbility;
|
||||||
|
import org.jiayunet.mapper.*;
|
||||||
|
import org.jiayunet.pojo.po.*;
|
||||||
|
import org.jiayunet.pojo.vo.UserHonorsVo;
|
||||||
|
import org.jiayunet.tool.HttpTool;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户简历分析服务
|
||||||
|
* <p>主要功能:用户保存简历后异步分析,提取学校等级、经历质量、专业、技能等维度数据</p>
|
||||||
|
* <p>依赖:AiChatAbility(AI调用)、DictCacheService(专业分类缓存)</p>
|
||||||
|
* <p>使用表:bg_user_profile(主表,读取/更新)、bg_user_profile_*(5张子表,读取)、
|
||||||
|
* bg_skill_tag(技能入库)、bg_user_profile_skill_tag_relation(关联表)</p>
|
||||||
|
*
|
||||||
|
* @author zk
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class UserProfileAnalyzeService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AiChatAbility aiChatAbility;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DictCacheService dictCacheService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserProfileMapper userProfileMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserProfileEducationMapper educationMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserProfileWorkMapper workMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserProfileInternshipMapper internshipMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserProfileProjectMapper projectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserProfileCompetitionMapper competitionMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SkillTagMapper skillTagMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserProfileSkillTagRelationMapper relationMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步分析用户简历
|
||||||
|
* <p>1. 查询用户完整简历 2. 第一次AI综合分析 3. 第二次AI专业归一化 4. 第三次AI技能提取</p>
|
||||||
|
*/
|
||||||
|
@Async("userProfileAsyncExecutor")
|
||||||
|
public void analyzeUserProfile(Long userId) {
|
||||||
|
try {
|
||||||
|
log.info("开始分析用户简历, userId={}", userId);
|
||||||
|
|
||||||
|
// 1. 查询用户完整简历
|
||||||
|
UserProfile profile = userProfileMapper.selectOne(new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserId, userId));
|
||||||
|
if (profile == null) {
|
||||||
|
log.info("用户主表为空,清空技能标签, userId={}", userId);
|
||||||
|
clearSkillRelations(userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<UserProfileEducation> educationList = educationMapper.selectList(new LambdaQueryWrapper<UserProfileEducation>().eq(UserProfileEducation::getUserId, userId));
|
||||||
|
List<UserProfileWork> workList = workMapper.selectList(new LambdaQueryWrapper<UserProfileWork>().eq(UserProfileWork::getUserId, userId));
|
||||||
|
List<UserProfileInternship> internshipList = internshipMapper.selectList(new LambdaQueryWrapper<UserProfileInternship>().eq(UserProfileInternship::getUserId, userId));
|
||||||
|
List<UserProfileProject> projectList = projectMapper.selectList(new LambdaQueryWrapper<UserProfileProject>().eq(UserProfileProject::getUserId, userId));
|
||||||
|
List<UserProfileCompetition> competitionList = competitionMapper.selectList(new LambdaQueryWrapper<UserProfileCompetition>().eq(UserProfileCompetition::getUserId, userId));
|
||||||
|
|
||||||
|
// 2. 数据有效性检查
|
||||||
|
if (educationList.isEmpty() && workList.isEmpty() && internshipList.isEmpty() && projectList.isEmpty() && competitionList.isEmpty()) {
|
||||||
|
log.info("用户所有子表为空,清空技能标签, userId={}", userId);
|
||||||
|
clearSkillRelations(userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String profileJson = buildProfileJson(profile, educationList, workList, internshipList, projectList, competitionList);
|
||||||
|
|
||||||
|
// 3. 第一次AI:简历综合分析(失败不影响后续)
|
||||||
|
try {
|
||||||
|
analyzeProfileDimensions(profile.getId(), profileJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("简历综合分析失败, userId={}", userId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 第二次AI:专业归一化(失败不影响后续)
|
||||||
|
try {
|
||||||
|
analyzeMajor(profile.getId(), profileJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("专业归一化失败, userId={}", userId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 第三次AI:技能提取
|
||||||
|
try {
|
||||||
|
extractSkillTags(userId, profileJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("技能提取失败, userId={}", userId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("用户简历分析完成, userId={}", userId);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("用户简历分析异常, userId={}", userId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第一次AI:简历综合分析
|
||||||
|
* <p>一次性识别学校等级、公司背书、经历时长、职责深度、量化产出、荣誉</p>
|
||||||
|
*/
|
||||||
|
private void analyzeProfileDimensions(Long profileId, String profileJson) {
|
||||||
|
String systemPrompt = """
|
||||||
|
你是一个简历分析助手。根据用户简历,分析以下维度并返回JSON。
|
||||||
|
返回JSON格式:
|
||||||
|
{
|
||||||
|
"schoolRank": 1-4的数字(1=C9/985/QS前50 2=211/双一流/QS前200 3=普通一本/QS前500 4=其他,无教育经历则null),
|
||||||
|
"companyPrestige": 1-4的数字(1=名企(世界500强/互联网大厂/知名国企/独角兽) 2=普通实习 3=仅校内活动 4=无经历,取最高档),
|
||||||
|
"experienceDuration": 1-3的数字(1=实习≥3月 2=1-3月 3=≤1月,取最长的一段,无实习则null),
|
||||||
|
"roleDepth": 1-3的数字(1=主导/创新(主导、核心负责、从0到1、架构设计) 2=执行/应用(负责、开发、实现、维护) 3=辅助/学习(协助、参与、了解),取最高档),
|
||||||
|
"outputQuality": 1-3的数字(1=有量化结果(含数字比率如提升20%) 2=有具体产出(明确产出物但无数字) 3=纯描述,取最高档),
|
||||||
|
"honors": {"national":["具体奖项名"],"provincial":["具体奖项名"],"school":["具体奖项/证书名"],"paper":["具体论文名"]}
|
||||||
|
}
|
||||||
|
规则:
|
||||||
|
1. 每个维度独立判断,某维度信息不足则设为null
|
||||||
|
2. 多段经历取最高档(最优表现)
|
||||||
|
3. honors中只填简历中明确提到的,没有则空数组
|
||||||
|
4. 只返回JSON,不要其他内容
|
||||||
|
""";
|
||||||
|
|
||||||
|
String userMessage = "【用户简历】\n" + profileJson;
|
||||||
|
String aiResponse = aiChatAbility.chat(systemPrompt, userMessage);
|
||||||
|
String json = cleanAiResponse(aiResponse);
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonNode root = HttpTool.objectMapper.readTree(json);
|
||||||
|
|
||||||
|
UserProfile update = new UserProfile();
|
||||||
|
update.setId(profileId);
|
||||||
|
update.setSchoolRank(root.path("schoolRank").isNull() ? null : root.path("schoolRank").asInt());
|
||||||
|
update.setCompanyPrestige(root.path("companyPrestige").isNull() ? null : root.path("companyPrestige").asInt());
|
||||||
|
update.setExperienceDuration(root.path("experienceDuration").isNull() ? null : root.path("experienceDuration").asInt());
|
||||||
|
update.setRoleDepth(root.path("roleDepth").isNull() ? null : root.path("roleDepth").asInt());
|
||||||
|
update.setOutputQuality(root.path("outputQuality").isNull() ? null : root.path("outputQuality").asInt());
|
||||||
|
|
||||||
|
// 解析 honors
|
||||||
|
JsonNode honorsNode = root.path("honors");
|
||||||
|
if (!honorsNode.isMissingNode() && !honorsNode.isNull()) {
|
||||||
|
UserHonorsVo honors = HttpTool.objectMapper.treeToValue(honorsNode, UserHonorsVo.class);
|
||||||
|
update.setHonors(honors);
|
||||||
|
}
|
||||||
|
|
||||||
|
update.setUpdateTime(Instant.now());
|
||||||
|
userProfileMapper.updateById(update);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("简历综合分析AI返回解析失败: {}", json, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第二次AI:专业归一化
|
||||||
|
* <p>传入教育经历和三级专业分类列表,AI返回用户专业ID数组 → 更新 bg_user_profile.major_ids</p>
|
||||||
|
*/
|
||||||
|
private void analyzeMajor(Long profileId, String profileJson) {
|
||||||
|
String systemPrompt = """
|
||||||
|
你是一个专业归一化助手。根据用户的教育经历,从专业分类列表中匹配用户的专业。
|
||||||
|
返回JSON格式:
|
||||||
|
{
|
||||||
|
"majorIds": [专业ID数组,从专业列表中选择最匹配的,可多个(如本科+硕士不同专业),无法判断则空数组]
|
||||||
|
}
|
||||||
|
规则:
|
||||||
|
1. 只能从给定专业列表中选择ID
|
||||||
|
2. 每段教育经历匹配一个最相关的专业
|
||||||
|
3. 无教育经历或专业信息不足时返回空数组
|
||||||
|
4. 只返回JSON,不要其他内容
|
||||||
|
""";
|
||||||
|
|
||||||
|
String userMessage = "【用户简历】\n" + profileJson + "\n\n【专业分类列表】\n" + dictCacheService.getMajorCategoryText();
|
||||||
|
|
||||||
|
String aiResponse = aiChatAbility.chat(systemPrompt, userMessage);
|
||||||
|
String json = cleanAiResponse(aiResponse);
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonNode root = HttpTool.objectMapper.readTree(json);
|
||||||
|
List<Long> majorIds = new ArrayList<>();
|
||||||
|
JsonNode idsNode = root.path("majorIds");
|
||||||
|
if (idsNode.isArray()) {
|
||||||
|
for (JsonNode node : idsNode) {
|
||||||
|
long id = node.asLong(0);
|
||||||
|
if (id > 0 && !majorIds.contains(id)) {
|
||||||
|
majorIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UserProfile update = new UserProfile();
|
||||||
|
update.setId(profileId);
|
||||||
|
update.setMajorIds(majorIds.isEmpty() ? null : majorIds);
|
||||||
|
update.setUpdateTime(Instant.now());
|
||||||
|
userProfileMapper.updateById(update);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("专业归一化AI返回解析失败: {}", json, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第三次AI:技能提取
|
||||||
|
* <p>自由提取用户技能,prompt与岗位侧保持一致,INSERT IGNORE入bg_skill_tag → 写关联表</p>
|
||||||
|
*/
|
||||||
|
private void extractSkillTags(Long userId, String profileJson) {
|
||||||
|
String systemPrompt = """
|
||||||
|
你是一个技能提取助手。根据用户简历,提取该用户具备的核心专业能力和工具技能。
|
||||||
|
返回JSON数组格式,如:["java", "spring boot", "mysql", "redis"]
|
||||||
|
规则:
|
||||||
|
1. 统一使用小写字母
|
||||||
|
2. 尽量简短,使用业界通用缩写(如 js 而非 javascript,k8s 而非 kubernetes)
|
||||||
|
3. 提取范围包括:技术栈、专业领域知识、行业工具、专业资质能力等
|
||||||
|
4. 不提取纯软技能(如沟通能力、团队协作、学习能力、积极主动)
|
||||||
|
5. 如果简历中完全没有专业能力体现,返回空数组 []
|
||||||
|
6. 最多15个,按熟练度排序
|
||||||
|
7. 只返回JSON数组,不要其他内容
|
||||||
|
示例1(技术岗):
|
||||||
|
输入:3年Java开发经验,熟悉Spring Boot、MySQL、Redis
|
||||||
|
输出:["java", "spring boot", "mysql", "redis"]
|
||||||
|
示例2(财务岗):
|
||||||
|
输入:负责费用管理与审核,月度经营利润分析,会计学专业
|
||||||
|
输出:["财务管理", "会计核算", "经营分析", "费用审核"]
|
||||||
|
示例3(科研岗):
|
||||||
|
输入:开展生物合成相关研究,发酵工程方向博士
|
||||||
|
输出:["生物合成", "发酵工程"]
|
||||||
|
示例4(无专业能力):
|
||||||
|
输入:具备较强的沟通能力和创新意识,积极主动
|
||||||
|
输出:[]
|
||||||
|
""";
|
||||||
|
|
||||||
|
String userMessage = "【用户简历】\n" + profileJson;
|
||||||
|
String aiResponse = aiChatAbility.chat(systemPrompt, userMessage);
|
||||||
|
String json = cleanAiResponse(aiResponse);
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonNode arrayNode = HttpTool.objectMapper.readTree(json);
|
||||||
|
if (!arrayNode.isArray() || arrayNode.isEmpty()) {
|
||||||
|
clearSkillRelations(userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Long> skillTagIds = new ArrayList<>();
|
||||||
|
for (JsonNode node : arrayNode) {
|
||||||
|
String skillName = node.asText("").trim().toLowerCase();
|
||||||
|
if (skillName.isBlank() || skillName.length() > 50) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Long tagId = findOrCreateSkillTag(skillName);
|
||||||
|
if (tagId != null && !skillTagIds.contains(tagId)) {
|
||||||
|
skillTagIds.add(tagId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceSkillRelations(userId, skillTagIds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("技能提取AI返回解析失败: {}", json, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找或创建技能标签(依靠数据库唯一索引保证并发安全)
|
||||||
|
*/
|
||||||
|
private Long findOrCreateSkillTag(String name) {
|
||||||
|
skillTagMapper.insertIgnore(IdWorker.getId(), name, Instant.now());
|
||||||
|
SkillTag tag = skillTagMapper.selectOne(new LambdaQueryWrapper<SkillTag>().eq(SkillTag::getName, name).last("LIMIT 1"));
|
||||||
|
return tag != null ? tag.getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建用户简历JSON */
|
||||||
|
private String buildProfileJson(UserProfile profile, List<UserProfileEducation> educationList,
|
||||||
|
List<UserProfileWork> workList, List<UserProfileInternship> internshipList,
|
||||||
|
List<UserProfileProject> projectList, List<UserProfileCompetition> competitionList) {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("name", profile.getName());
|
||||||
|
data.put("skills", profile.getSkills());
|
||||||
|
data.put("certificates", profile.getCertificates());
|
||||||
|
data.put("education", educationList);
|
||||||
|
data.put("work", workList);
|
||||||
|
data.put("internship", internshipList);
|
||||||
|
data.put("project", projectList);
|
||||||
|
data.put("competition", competitionList);
|
||||||
|
try {
|
||||||
|
return HttpTool.objectMapper.writeValueAsString(data);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("用户资料序列化失败", e);
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空用户技能标签关联 */
|
||||||
|
private void clearSkillRelations(Long userId) {
|
||||||
|
relationMapper.delete(new LambdaQueryWrapper<UserProfileSkillTagRelation>().eq(UserProfileSkillTagRelation::getUserId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全量替换用户技能标签关联 */
|
||||||
|
private void replaceSkillRelations(Long userId, List<Long> skillTagIds) {
|
||||||
|
relationMapper.delete(new LambdaQueryWrapper<UserProfileSkillTagRelation>().eq(UserProfileSkillTagRelation::getUserId, userId));
|
||||||
|
if (skillTagIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Instant now = Instant.now();
|
||||||
|
List<UserProfileSkillTagRelation> relations = skillTagIds.stream().map(tagId -> {
|
||||||
|
UserProfileSkillTagRelation relation = new UserProfileSkillTagRelation();
|
||||||
|
relation.setUserId(userId);
|
||||||
|
relation.setSkillTagId(tagId);
|
||||||
|
relation.setCreateTime(now);
|
||||||
|
return relation;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
relationMapper.batchInsert(relations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理AI返回的markdown代码块和控制字符 */
|
||||||
|
private String cleanAiResponse(String response) {
|
||||||
|
String json = response.trim();
|
||||||
|
if (json.startsWith("```")) {
|
||||||
|
json = json.replaceAll("^```\\w*\\n?", "").replaceAll("\\n?```$", "").trim();
|
||||||
|
}
|
||||||
|
json = json.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]", "");
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
package org.jiayunet.service;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.jiayunet.ai.AiChatAbility;
|
|
||||||
import org.jiayunet.mapper.*;
|
|
||||||
import org.jiayunet.pojo.po.*;
|
|
||||||
import org.jiayunet.tool.HttpTool;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户技能标签匹配服务
|
|
||||||
* <p>主要功能:根据用户个人资料,AI识别匹配的技能标签</p>
|
|
||||||
* <p>依赖:AiChatAbility(AI调用)</p>
|
|
||||||
* <p>使用表:bg_user_profile(主表)、bg_user_profile_*(5张子表)、
|
|
||||||
* bg_job_category(岗位分类)、bg_skill_tag(技能标签)、
|
|
||||||
* bg_user_profile_skill_tag_relation(关联表)</p>
|
|
||||||
*
|
|
||||||
* @author zk
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
public class UserSkillTagMatchService {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private AiChatAbility aiChatAbility;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserProfileMapper userProfileMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserProfileEducationMapper educationMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserProfileWorkMapper workMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserProfileInternshipMapper internshipMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserProfileProjectMapper projectMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserProfileCompetitionMapper competitionMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private JobCategoryMapper jobCategoryMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private SkillTagMapper skillTagMapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserProfileSkillTagRelationMapper relationMapper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 异步匹配用户技能标签
|
|
||||||
* <p>1. 查询用户完整个人资料 2. 第一次AI调用识别二级分类 3. 查询三级分类
|
|
||||||
* 4. 查询候选技能标签 5. 第二次AI调用匹配技能标签 6. 全量替换关联表</p>
|
|
||||||
*/
|
|
||||||
@Async("userProfileAsyncExecutor")
|
|
||||||
public void matchUserSkillTags(Long userId) {
|
|
||||||
try {
|
|
||||||
log.info("开始匹配用户技能标签, userId={}", userId);
|
|
||||||
|
|
||||||
// 1. 查询用户完整个人资料
|
|
||||||
UserProfile profile = userProfileMapper.selectOne(new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserId, userId));
|
|
||||||
if (profile == null) {
|
|
||||||
log.info("用户主表为空,清空技能标签, userId={}", userId);
|
|
||||||
clearRelations(userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<UserProfileEducation> educationList = educationMapper.selectList(new LambdaQueryWrapper<UserProfileEducation>().eq(UserProfileEducation::getUserId, userId));
|
|
||||||
List<UserProfileWork> workList = workMapper.selectList(new LambdaQueryWrapper<UserProfileWork>().eq(UserProfileWork::getUserId, userId));
|
|
||||||
List<UserProfileInternship> internshipList = internshipMapper.selectList(new LambdaQueryWrapper<UserProfileInternship>().eq(UserProfileInternship::getUserId, userId));
|
|
||||||
List<UserProfileProject> projectList = projectMapper.selectList(new LambdaQueryWrapper<UserProfileProject>().eq(UserProfileProject::getUserId, userId));
|
|
||||||
List<UserProfileCompetition> competitionList = competitionMapper.selectList(new LambdaQueryWrapper<UserProfileCompetition>().eq(UserProfileCompetition::getUserId, userId));
|
|
||||||
|
|
||||||
// 2. 数据有效性检查
|
|
||||||
if (educationList.isEmpty() && workList.isEmpty() && internshipList.isEmpty() && projectList.isEmpty() && competitionList.isEmpty()) {
|
|
||||||
log.info("用户所有子表为空,清空技能标签, userId={}", userId);
|
|
||||||
clearRelations(userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 第一次 AI 调用(识别二级分类)
|
|
||||||
List<Long> categoryIds = identifyCategories(profile, educationList, workList, internshipList, projectList, competitionList);
|
|
||||||
if (categoryIds.isEmpty()) {
|
|
||||||
log.info("AI未识别出岗位分类,清空技能标签, userId={}", userId);
|
|
||||||
clearRelations(userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 查询三级分类(技能标签挂在三级分类下)
|
|
||||||
List<JobCategory> level3Categories = jobCategoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().eq(JobCategory::getLevel, 3).in(JobCategory::getParentId, categoryIds));
|
|
||||||
if (level3Categories.isEmpty()) {
|
|
||||||
log.info("二级分类下无三级分类,清空技能标签, userId={}", userId);
|
|
||||||
clearRelations(userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 查询候选技能标签(使用三级分类ID)
|
|
||||||
List<Long> level3CategoryIds = level3Categories.stream().map(JobCategory::getId).collect(Collectors.toList());
|
|
||||||
List<SkillTag> candidateTags = skillTagMapper.selectList(new LambdaQueryWrapper<SkillTag>().in(SkillTag::getCategoryId, level3CategoryIds));
|
|
||||||
if (candidateTags.isEmpty()) {
|
|
||||||
log.info("候选技能标签为空,清空技能标签, userId={}", userId);
|
|
||||||
clearRelations(userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 第二次 AI 调用(匹配技能标签)
|
|
||||||
List<Long> matchedTagIds = matchSkillTags(profile, educationList, workList, internshipList, projectList, competitionList, candidateTags);
|
|
||||||
|
|
||||||
// 7. 全量替换关联表
|
|
||||||
replaceRelations(userId, matchedTagIds);
|
|
||||||
|
|
||||||
log.info("用户技能标签匹配完成, userId={}, 匹配{}个标签", userId, matchedTagIds.size());
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("用户技能标签匹配异常, userId={}", userId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 第一次 AI 调用:识别用户所属的二级岗位分类
|
|
||||||
*/
|
|
||||||
private List<Long> identifyCategories(UserProfile profile, List<UserProfileEducation> educationList,
|
|
||||||
List<UserProfileWork> workList, List<UserProfileInternship> internshipList,
|
|
||||||
List<UserProfileProject> projectList, List<UserProfileCompetition> competitionList) {
|
|
||||||
// 构建用户资料 JSON
|
|
||||||
Map<String, Object> profileData = new HashMap<>();
|
|
||||||
profileData.put("name", profile.getName());
|
|
||||||
profileData.put("workYears", profile.getWorkYears());
|
|
||||||
profileData.put("education", educationList);
|
|
||||||
profileData.put("work", workList);
|
|
||||||
profileData.put("internship", internshipList);
|
|
||||||
profileData.put("project", projectList);
|
|
||||||
profileData.put("competition", competitionList);
|
|
||||||
|
|
||||||
String profileJson;
|
|
||||||
try {
|
|
||||||
profileJson = HttpTool.objectMapper.writeValueAsString(profileData);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("用户资料序列化失败", e);
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询所有二级分类
|
|
||||||
List<JobCategory> allCategories = jobCategoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().eq(JobCategory::getLevel, 2));
|
|
||||||
|
|
||||||
// 构建分类列表文本(带一级分类名称)
|
|
||||||
Map<Long, String> parentNameMap = new HashMap<>();
|
|
||||||
List<JobCategory> parentCategories = jobCategoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().eq(JobCategory::getLevel, 1));
|
|
||||||
for (JobCategory parent : parentCategories) {
|
|
||||||
parentNameMap.put(parent.getId(), parent.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder categoryText = new StringBuilder();
|
|
||||||
for (JobCategory category : allCategories) {
|
|
||||||
String parentName = parentNameMap.getOrDefault(category.getParentId(), "未知");
|
|
||||||
categoryText.append(String.format("ID: %d, 名称: %s > %s\n", category.getId(), parentName, category.getName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 Prompt
|
|
||||||
String systemPrompt = """
|
|
||||||
任务:根据用户个人资料,选择最匹配的二级岗位分类。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 选择 1-10 个最匹配的二级分类(优先核心岗位,按匹配度排序)
|
|
||||||
2. 信息不足无法判断时返回空数组 []
|
|
||||||
3. 只返回纯数字数组 JSON,不要任何其他文字
|
|
||||||
|
|
||||||
返回格式示例:
|
|
||||||
- 有分类:[23, 45, 67]
|
|
||||||
- 无法判断:[]
|
|
||||||
""";
|
|
||||||
|
|
||||||
String userMessage = "【用户个人资料】\n" + profileJson + "\n\n【二级岗位分类列表(格式:一级分类 > 二级分类)】\n" + categoryText;
|
|
||||||
|
|
||||||
try {
|
|
||||||
String aiResponse = aiChatAbility.chat(systemPrompt, userMessage);
|
|
||||||
return parseIdArray(aiResponse, allCategories.stream().map(JobCategory::getId).collect(Collectors.toSet()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("AI识别岗位分类失败", e);
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 第二次 AI 调用:从候选标签中匹配用户技能标签
|
|
||||||
*/
|
|
||||||
private List<Long> matchSkillTags(UserProfile profile, List<UserProfileEducation> educationList,
|
|
||||||
List<UserProfileWork> workList, List<UserProfileInternship> internshipList,
|
|
||||||
List<UserProfileProject> projectList, List<UserProfileCompetition> competitionList,
|
|
||||||
List<SkillTag> candidateTags) {
|
|
||||||
// 构建用户资料 JSON
|
|
||||||
Map<String, Object> profileData = new HashMap<>();
|
|
||||||
profileData.put("name", profile.getName());
|
|
||||||
profileData.put("workYears", profile.getWorkYears());
|
|
||||||
profileData.put("education", educationList);
|
|
||||||
profileData.put("work", workList);
|
|
||||||
profileData.put("internship", internshipList);
|
|
||||||
profileData.put("project", projectList);
|
|
||||||
profileData.put("competition", competitionList);
|
|
||||||
|
|
||||||
String profileJson;
|
|
||||||
try {
|
|
||||||
profileJson = HttpTool.objectMapper.writeValueAsString(profileData);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("用户资料序列化失败", e);
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建候选标签文本(按分类分组)
|
|
||||||
Map<Long, String> categoryNameMap = new HashMap<>();
|
|
||||||
Set<Long> categoryIds = candidateTags.stream().map(SkillTag::getCategoryId).collect(Collectors.toSet());
|
|
||||||
List<JobCategory> categories = jobCategoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().in(JobCategory::getId, categoryIds));
|
|
||||||
for (JobCategory category : categories) {
|
|
||||||
categoryNameMap.put(category.getId(), category.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder tagText = new StringBuilder();
|
|
||||||
Map<Long, List<SkillTag>> groupedTags = candidateTags.stream().collect(Collectors.groupingBy(SkillTag::getCategoryId));
|
|
||||||
for (Map.Entry<Long, List<SkillTag>> entry : groupedTags.entrySet()) {
|
|
||||||
String categoryName = categoryNameMap.getOrDefault(entry.getKey(), "未知");
|
|
||||||
tagText.append("【").append(categoryName).append("】\n");
|
|
||||||
for (SkillTag tag : entry.getValue()) {
|
|
||||||
tagText.append(String.format("ID: %d, 名称: %s\n", tag.getId(), tag.getName()));
|
|
||||||
}
|
|
||||||
tagText.append("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 Prompt
|
|
||||||
String systemPrompt = """
|
|
||||||
任务:根据用户个人资料,从候选技能标签中选出匹配的标签。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 只选择用户明确具备或可推断的技能
|
|
||||||
2. 无法匹配返回空数组 []
|
|
||||||
3. 只返回纯数字数组 JSON,不要任何其他文字
|
|
||||||
|
|
||||||
返回格式示例:
|
|
||||||
- 有匹配:[12, 34, 56]
|
|
||||||
- 无匹配:[]
|
|
||||||
""";
|
|
||||||
|
|
||||||
String userMessage = "【用户个人资料】\n" + profileJson + "\n\n【候选技能标签(按分类分组)】\n" + tagText;
|
|
||||||
|
|
||||||
try {
|
|
||||||
String aiResponse = aiChatAbility.chat(systemPrompt, userMessage);
|
|
||||||
return parseIdArray(aiResponse, candidateTags.stream().map(SkillTag::getId).collect(Collectors.toSet()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("AI匹配技能标签失败", e);
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 AI 返回的 ID 数组
|
|
||||||
*/
|
|
||||||
private List<Long> parseIdArray(String aiResponse, Set<Long> validIds) {
|
|
||||||
String json = aiResponse.trim();
|
|
||||||
if (json.startsWith("```")) {
|
|
||||||
json = json.replaceAll("^```\\w*\\n?", "").replaceAll("\\n?```$", "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
JsonNode arrayNode = HttpTool.objectMapper.readTree(json);
|
|
||||||
List<Long> result = new ArrayList<>();
|
|
||||||
if (arrayNode.isArray()) {
|
|
||||||
for (JsonNode node : arrayNode) {
|
|
||||||
long id = node.asLong(0);
|
|
||||||
if (id > 0 && validIds.contains(id) && !result.contains(id)) {
|
|
||||||
result.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("AI返回解析失败: {}", json, e);
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空用户技能标签关联
|
|
||||||
*/
|
|
||||||
public void clearRelations(Long userId) {
|
|
||||||
relationMapper.delete(new LambdaQueryWrapper<UserProfileSkillTagRelation>().eq(UserProfileSkillTagRelation::getUserId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全量替换用户技能标签关联
|
|
||||||
*/
|
|
||||||
|
|
||||||
public void replaceRelations(Long userId, List<Long> skillTagIds) {
|
|
||||||
// 先删除
|
|
||||||
relationMapper.delete(new LambdaQueryWrapper<UserProfileSkillTagRelation>().eq(UserProfileSkillTagRelation::getUserId, userId));
|
|
||||||
|
|
||||||
// 结果为空则只删除
|
|
||||||
if (skillTagIds.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量插入
|
|
||||||
Instant now = Instant.now();
|
|
||||||
List<UserProfileSkillTagRelation> relations = skillTagIds.stream().map(tagId -> {
|
|
||||||
UserProfileSkillTagRelation relation = new UserProfileSkillTagRelation();
|
|
||||||
relation.setUserId(userId);
|
|
||||||
relation.setSkillTagId(tagId);
|
|
||||||
relation.setCreateTime(now);
|
|
||||||
return relation;
|
|
||||||
}).collect(Collectors.toList());
|
|
||||||
|
|
||||||
relationMapper.batchInsert(relations);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user