添加用户个人技能标签提取
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package org.jiayunet.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.jiayunet.pojo.po.UserProfileSkillTagRelation;
|
||||
|
||||
/**
|
||||
* 用户技能标签关联Mapper
|
||||
*
|
||||
* @author zk
|
||||
*/
|
||||
@Mapper
|
||||
public interface UserProfileSkillTagRelationMapper extends CommonMapper<UserProfileSkillTagRelation> {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.jiayunet.pojo.po;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 用户技能标签关联表(bg_user_profile_skill_tag_relation)
|
||||
*
|
||||
* @author zk
|
||||
*/
|
||||
@Data
|
||||
@TableName(value = "bg_user_profile_skill_tag_relation")
|
||||
public class UserProfileSkillTagRelation {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 技能标签ID */
|
||||
private Long skillTagId;
|
||||
|
||||
/** 创建时间 */
|
||||
private Instant createTime;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
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