diff --git a/src/main/java/com/qingqiu/interview/dto/PageBaseParams.java b/src/main/java/com/qingqiu/interview/common/dto/PageBaseParams.java similarity index 90% rename from src/main/java/com/qingqiu/interview/dto/PageBaseParams.java rename to src/main/java/com/qingqiu/interview/common/dto/PageBaseParams.java index 563bccb..1d37c43 100755 --- a/src/main/java/com/qingqiu/interview/dto/PageBaseParams.java +++ b/src/main/java/com/qingqiu/interview/common/dto/PageBaseParams.java @@ -1,4 +1,4 @@ -package com.qingqiu.interview.dto; +package com.qingqiu.interview.common.dto; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/qingqiu/interview/controller/InterviewController.java b/src/main/java/com/qingqiu/interview/controller/InterviewController.java old mode 100755 new mode 100644 index 5fa1e41..e19f1f2 --- a/src/main/java/com/qingqiu/interview/controller/InterviewController.java +++ b/src/main/java/com/qingqiu/interview/controller/InterviewController.java @@ -1,65 +1,105 @@ -package com.qingqiu.interview.controller; - -import com.alibaba.fastjson2.JSONObject; -import com.qingqiu.interview.dto.*; -import com.qingqiu.interview.entity.InterviewSession; -import com.qingqiu.interview.service.InterviewService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; -import java.util.UUID; - -/** - * 面试流程相关接口 - */ -@Slf4j -@RestController -@RequestMapping("/interview") -@RequiredArgsConstructor -public class InterviewController { - - private final InterviewService interviewService; - - /** - * 开始新的面试会话 - */ - @PostMapping("/start") - public ApiResponse startInterview( - @RequestParam("resume") MultipartFile resume, - @Validated @ModelAttribute InterviewStartRequest request) throws IOException { -// InterviewResponse response = interviewService.startInterview(resume, request); - log.info("接收到的数据: {}", JSONObject.toJSONString(request)); - InterviewResponse interviewResponse = new InterviewResponse(); - interviewResponse.setSessionId(UUID.randomUUID().toString().replace("-", "")); - return ApiResponse.success(interviewResponse); - } - - /** - * 继续面试会话(用户回答) - */ - @PostMapping("/chat") - public ApiResponse continueInterview(@Validated @RequestBody ChatRequest request) { - InterviewResponse response = interviewService.continueInterview(request); - return ApiResponse.success(response); - } - - /** - * 获取所有面试会话列表 - */ - @PostMapping("/get-history-list") - public ApiResponse> getInterviewHistoryList() { - return ApiResponse.success(interviewService.getInterviewSessions()); - } - - /** - * 获取单次面试的详细复盘报告 - */ - @PostMapping("/get-report-detail") - public ApiResponse getInterviewReportDetail(@RequestBody SessionRequest request) { - return ApiResponse.success(interviewService.getInterviewReport(request.getSessionId())); - } -} \ No newline at end of file +package com.qingqiu.interview.controller; + +import com.alibaba.fastjson2.JSONObject; +import com.qingqiu.interview.common.res.R; +import com.qingqiu.interview.dto.InterviewStartRequest; +import com.qingqiu.interview.dto.SubmitAnswerDTO; +import com.qingqiu.interview.entity.InterviewQuestionProgress; +import com.qingqiu.interview.entity.InterviewSession; +import com.qingqiu.interview.service.InterviewService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/19 16:13 + */ +@Slf4j +@RestController +@RequestMapping("/interview") +@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy}) +public class InterviewController { + + private final InterviewService interviewService; + + + /** + * 开始面试 + * + * @return 包含会话ID的会话信息 + */ + @PostMapping("/start") + public R start(@RequestParam("resume") MultipartFile resume, + @Validated @ModelAttribute InterviewStartRequest request) { + log.info("接受的数据: {}", JSONObject.toJSONString(request)); + return R.success(); +// try { +// InterviewSession session = interviewService.startInterview(resume, request); +// return R.success(session); +// } catch (Exception e) { +// // log.error("开始面试失败", e); +// return R.error("开始面试失败:" + e.getMessage()); +// } + } + + /** + * 获取下一个问题 + * + * @param sessionId 会话ID + * @return 下一个问题 + */ + @GetMapping("/{sessionId}/next-question") + public R getNextQuestion(@PathVariable String sessionId) { + try { + InterviewQuestionProgress nextQuestion = interviewService.getNextQuestion(sessionId); + if (nextQuestion == null) { + return R.success(null, "所有问题已回答完毕!"); + } + return R.success(nextQuestion); + } catch (Exception e) { + // log.error("获取下一题失败", e); + return R.error("获取下一题失败:" + e.getMessage()); + } + } + + /** + * 提交答案 + * + * @param submitDto 包含进度ID和答案 + * @return 对当前问题的评估 + */ + @PostMapping("/submit-answer") + public R submitAnswer(@RequestBody SubmitAnswerDTO submitDto) { + try { + InterviewQuestionProgress result = interviewService.submitAnswer(submitDto); + return R.success(result); + } catch (Exception e) { + // log.error("提交答案失败", e); + return R.error("提交答案失败:" + e.getMessage()); + } + } + + /** + * 结束面试并获取最终报告 + * + * @param sessionId 会话ID + * @return 包含最终报告的会话信息 + */ + @PostMapping("/{sessionId}/end") + public R endInterview(@PathVariable String sessionId) { + try { + InterviewSession finalSession = interviewService.endInterview(sessionId); + return R.success(finalSession); + } catch (Exception e) { + // log.error("结束面试失败", e); + return R.error("结束面试失败:" + e.getMessage()); + } + } +} diff --git a/src/main/java/com/qingqiu/interview/dto/ChatDTO.java b/src/main/java/com/qingqiu/interview/dto/ChatDTO.java index c12e04f..6a05e57 100644 --- a/src/main/java/com/qingqiu/interview/dto/ChatDTO.java +++ b/src/main/java/com/qingqiu/interview/dto/ChatDTO.java @@ -1,5 +1,6 @@ package com.qingqiu.interview.dto; +import com.qingqiu.interview.common.enums.LLMProvider; import lombok.Data; import lombok.experimental.Accessors; @@ -10,13 +11,14 @@ import lombok.experimental.Accessors; * @date 2025/9/18 12:54 */ @Data + @Accessors(chain = true) public class ChatDTO { /** 会话id */ private String sessionId; /** 调用模型 */ - private String aiModel; + private String aiModel = LLMProvider.DEEPSEEK.getCode(); /** 输入内容 */ private String content; /** 0 普通会话 1 面试会话 */ diff --git a/src/main/java/com/qingqiu/interview/dto/InterviewStartRequest.java b/src/main/java/com/qingqiu/interview/dto/InterviewStartRequest.java index f3a9e00..4d25552 100755 --- a/src/main/java/com/qingqiu/interview/dto/InterviewStartRequest.java +++ b/src/main/java/com/qingqiu/interview/dto/InterviewStartRequest.java @@ -1,5 +1,6 @@ package com.qingqiu.interview.dto; +import com.qingqiu.interview.common.enums.LLMProvider; import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO; import jakarta.validation.constraints.NotBlank; import lombok.Data; @@ -18,6 +19,11 @@ public class InterviewStartRequest { @NotBlank(message = "面试类型不能为空") private String model; - + /** 选择的AI模型 */ + private String aiModel = LLMProvider.QWEN.getCode(); + + /** 生成的面试题目数量 */ + private Integer totalQuestions = 10; + // 简历文件通过MultipartFile单独传递 } diff --git a/src/main/java/com/qingqiu/interview/dto/QuestionCategoryPageParams.java b/src/main/java/com/qingqiu/interview/dto/QuestionCategoryPageParams.java index 9cc76d1..df49867 100755 --- a/src/main/java/com/qingqiu/interview/dto/QuestionCategoryPageParams.java +++ b/src/main/java/com/qingqiu/interview/dto/QuestionCategoryPageParams.java @@ -1,5 +1,6 @@ package com.qingqiu.interview.dto; +import com.qingqiu.interview.common.dto.PageBaseParams; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; @@ -14,7 +15,7 @@ import lombok.experimental.Accessors; @EqualsAndHashCode(callSuper = true) @Data @Accessors(chain = true) -public class QuestionCategoryPageParams extends PageBaseParams{ +public class QuestionCategoryPageParams extends PageBaseParams { /** * 分类名称(模糊查询) */ diff --git a/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java b/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java index f5d8528..002b126 100755 --- a/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java +++ b/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java @@ -1,5 +1,6 @@ package com.qingqiu.interview.dto; +import com.qingqiu.interview.common.dto.PageBaseParams; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; diff --git a/src/main/java/com/qingqiu/interview/dto/QuestionProgressPageParams.java b/src/main/java/com/qingqiu/interview/dto/QuestionProgressPageParams.java index df94e8d..9f4a140 100755 --- a/src/main/java/com/qingqiu/interview/dto/QuestionProgressPageParams.java +++ b/src/main/java/com/qingqiu/interview/dto/QuestionProgressPageParams.java @@ -1,5 +1,6 @@ package com.qingqiu.interview.dto; +import com.qingqiu.interview.common.dto.PageBaseParams; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; @@ -7,6 +8,6 @@ import lombok.experimental.Accessors; @EqualsAndHashCode(callSuper = true) @Data @Accessors(chain = true) -public class QuestionProgressPageParams extends PageBaseParams{ +public class QuestionProgressPageParams extends PageBaseParams { private String questionName; } diff --git a/src/main/java/com/qingqiu/interview/dto/StartInterviewDTO.java b/src/main/java/com/qingqiu/interview/dto/StartInterviewDTO.java new file mode 100644 index 0000000..28047f6 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/StartInterviewDTO.java @@ -0,0 +1,43 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serial; +import java.io.Serializable; + +/** + *

+ * 开始面试请求的数据传输对象 + *

+ * + * @author qingqiu + * @date 2025/9/19 16:03 + */ +@Data +@Accessors(chain = true) +public class StartInterviewDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 候选人姓名 + */ + private String candidateName; + + /** + * 简历完整内容(或简历文件URL) + */ + private String resumeContent; + + /** + * 指定使用的AI模型 + */ + private String aiModel; + + /** + * 计划提问总数 + */ + private Integer totalQuestions; +} diff --git a/src/main/java/com/qingqiu/interview/dto/SubmitAnswerDTO.java b/src/main/java/com/qingqiu/interview/dto/SubmitAnswerDTO.java new file mode 100644 index 0000000..5454cee --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/SubmitAnswerDTO.java @@ -0,0 +1,31 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serial; +import java.io.Serializable; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/19 16:04 + */ +@Data +@Accessors(chain = true) +public class SubmitAnswerDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 当前问题的进度ID (interview_question_progress.id) + */ + private Long progressId; + + /** + * 用户的回答内容 + */ + private String answer; +} diff --git a/src/main/java/com/qingqiu/interview/service/InterviewAiService.java b/src/main/java/com/qingqiu/interview/service/InterviewAiService.java new file mode 100644 index 0000000..56f528b --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/InterviewAiService.java @@ -0,0 +1,58 @@ +package com.qingqiu.interview.service; + +import com.alibaba.fastjson2.JSONObject; +import com.qingqiu.interview.entity.InterviewQuestionProgress; +import com.qingqiu.interview.entity.InterviewSession; +import com.qingqiu.interview.entity.Question; + +import java.util.List; + +/** + *

+ * 面试接入AI的接口 + *

+ * + * @author qingqiu + * @date 2025/9/19 16:48 + */ +public interface InterviewAiService { + + /** + * 从简历内容中提取技能列表 + * + * @param resumeContent 简历文本 + * @return 包含技能列表的JSON对象 + */ + JSONObject extractSkillsFromResume(String resumeContent); + + /** + * 根据技能动态生成面试题目 + * + * @param skills 技能列表 + * @param resumeContent 简历内容 + * @param count 需要生成的题目数量 + * @return 包含问题列表的JSON对象 + */ + JSONObject generateQuestionsOfAi(String sessionId, List skills, String resumeContent, int count); + + JSONObject generateQuestionOfLocal(String sessionId, List questions, List skills, String resumeContent, int count); + + /** + * 评估用户的回答 + * + * @param question 问题内容 + * @param userAnswer 用户的回答 + * @param context 可选的上下文(之前的问答历史) + * @return 包含评估结果的JSON对象 + */ + JSONObject evaluateAnswer(String sessionId, String question, String userAnswer, List context); + + /** + * 生成最终的面试评估报告 + * + * @param session 面试会话信息 + * @param progressList 整个面试的问答记录 + * @return 包含最终报告的JSON对象 + */ + JSONObject generateFinalReport(InterviewSession session, List progressList); +} diff --git a/src/main/java/com/qingqiu/interview/service/InterviewService.java b/src/main/java/com/qingqiu/interview/service/InterviewService.java old mode 100755 new mode 100644 index 2dcfb1b..7d5223e --- a/src/main/java/com/qingqiu/interview/service/InterviewService.java +++ b/src/main/java/com/qingqiu/interview/service/InterviewService.java @@ -1,579 +1,52 @@ -package com.qingqiu.interview.service; - -import cn.hutool.core.collection.CollectionUtil; -import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.qingqiu.interview.dto.*; -import com.qingqiu.interview.entity.*; -import com.qingqiu.interview.mapper.*; -import com.qingqiu.interview.service.llm.LlmService; -import com.qingqiu.interview.service.parser.DocumentParser; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; -import java.math.BigDecimal; -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; - -import static com.qingqiu.interview.common.constants.QwenModelConstant.QWEN_MAX; - -@Slf4j -@Service -@RequiredArgsConstructor -public class InterviewService { - - private final LlmService llmService; // Changed to a single service - private final List documentParserList; - private final QuestionMapper questionMapper; - private final InterviewSessionMapper sessionMapper; - private final InterviewMessageMapper messageMapper; - private final InterviewEvaluationMapper evaluationMapper; - private final InterviewQuestionProgressMapper questionProgressMapper; - - private final ObjectMapper objectMapper; - - - private Map documentParsers; - - private static final int MAX_QUESTIONS_PER_INTERVIEW = 10; - - @PostConstruct - public void init() { - this.documentParsers = documentParserList.stream() - .collect(Collectors.toMap(DocumentParser::getSupportedType, Function.identity())); - } - - /** - * 开始新的面试会话 - */ - @Transactional(rollbackFor = Exception.class) - public InterviewResponse startInterview(MultipartFile resume, InterviewStartRequest request) throws IOException { - log.info("开始新面试会话,候选人: {}, AI模型: qwen-max", request.getCandidateName()); - - // 1. 解析简历 - String resumeContent = parseResume(resume); - // 判断是否AI出题 - if (request.getModel().equals("local")) { - if (CollectionUtil.isEmpty(request.getSelectedNodes())) { - - } - } - - - // 2. 创建会话 并发送AI请求 让其从题库中智能抽题 - String sessionId = UUID.randomUUID().toString(); - List selectedQuestions = selectQuestionsByAi(resumeContent, sessionId); - if (selectedQuestions.isEmpty()) { - throw new IllegalStateException("AI未能成功选取题目,请检查AI服务或题库。"); - } - - // 生成面试问题进度数据 - if (CollectionUtil.isNotEmpty(selectedQuestions)) { - for (Question question : selectedQuestions) { - InterviewQuestionProgress progress = - new InterviewQuestionProgress() - .setSessionId(sessionId) - .setQuestionId(question.getId()) - .setQuestionContent(question.getContent()) - .setStatus(InterviewQuestionProgress.Status.DEFAULT.name()) - .setTotalQuestions(selectedQuestions.size()) - .setScore(BigDecimal.ZERO) - .setAiModel(QWEN_MAX) - .setCandidateName(request.getCandidateName()); - questionProgressMapper.insert(progress); - } - - } - - // 3. 保存AI选择的题目ID列表 - List selectedQuestionIds = selectedQuestions.stream().map(Question::getId).collect(Collectors.toList()); - String selectedQuestionIdsJson = objectMapper.writeValueAsString(selectedQuestionIds); - - InterviewSession session = createSession(sessionId, request, resumeContent, selectedQuestionIdsJson); - session.setTotalQuestions(selectedQuestions.size()); // 更新会话中的总问题数 - sessionMapper.updateById(session); // 更新数据库 - - // 4. 生成第一个问题 - Question firstQuestion = selectedQuestions.get(0); - String firstQuestionContent = generateFirstQuestion(session, firstQuestion, sessionId); - // 激活问题 - questionProgressMapper.update( - new LambdaUpdateWrapper() - .set(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.ACTIVE.name()) - .eq(InterviewQuestionProgress::getQuestionId, firstQuestion.getId()) - .eq(InterviewQuestionProgress::getSessionId, sessionId) - ); - - // 5. 保存消息记录 - saveMessage(sessionId, InterviewMessage.MessageType.QUESTION.name(), - InterviewMessage.Sender.AI.name(), firstQuestionContent, firstQuestion.getId(), 1); - - // 6. 返回响应 - return new InterviewResponse() - .setSessionId(sessionId) - .setMessage(firstQuestionContent) - .setMessageType(InterviewMessage.MessageType.QUESTION.name()) - .setSender(InterviewMessage.Sender.AI.name()) - .setCurrentQuestionIndex(1) - .setCurrentQuestionId(firstQuestion.getId()) - .setTotalQuestions(selectedQuestions.size()) - .setStatus(InterviewSession.Status.ACTIVE.name()); - } - - /** - * 处理用户回答并生成下一个问题 - */ - @Transactional(rollbackFor = Exception.class) - public InterviewResponse continueInterview(ChatRequest request) { - log.info("继续面试会话: {}", request.getSessionId()); - - InterviewSession session = sessionMapper.selectBySessionId(request.getSessionId()); - if (session == null) { - throw new IllegalArgumentException("会话不存在: " + request.getSessionId()); - } - - if (!InterviewSession.Status.ACTIVE.name().equals(session.getStatus())) { - throw new IllegalStateException("会话已结束"); - } - - - // 1. 保存用户回答 - int nextOrder = messageMapper.selectMaxOrderBySessionId(request.getSessionId()) + 1; - saveMessage(request.getSessionId(), InterviewMessage.MessageType.ANSWER.name(), - InterviewMessage.Sender.USER.name(), request.getUserAnswer(), null, nextOrder); - // 检查是否结束面试 - InterviewQuestionProgress progress = questionProgressMapper.selectOne( - new LambdaQueryWrapper() - .eq(InterviewQuestionProgress::getSessionId, request.getSessionId()) - .orderByDesc(InterviewQuestionProgress::getCreatedTime) - .last("limit 1") - ); - if (Objects.nonNull(progress) && Objects.equals(progress.getQuestionId(), request.getCurrentQuestionId())) { - - } - // 2. 评估回答 - Long currentQuestionId = evaluateAnswer(session, request.getUserAnswer()); - // 比对返回的id是否与当前id一致 - if (currentQuestionId.equals(0L)) { - return finishInterview(session); - } - InterviewQuestionProgress nextQuestionProgress = questionProgressMapper.selectOne( - new LambdaQueryWrapper() - .eq(InterviewQuestionProgress::getSessionId, request.getSessionId()) - .eq(InterviewQuestionProgress::getQuestionId, currentQuestionId) - .orderByDesc(InterviewQuestionProgress::getCreatedTime) - .last("limit 1") - ); - // 将ai返回的内容拼装返回给页面 - // 查询数据 - InterviewQuestionProgress currentQuestionData = questionProgressMapper.selectOne( - new LambdaQueryWrapper() - .eq(InterviewQuestionProgress::getSessionId, request.getSessionId()) - .eq(InterviewQuestionProgress::getQuestionId, request.getCurrentQuestionId()) - .orderByDesc(InterviewQuestionProgress::getCreatedTime) - .last("limit 1") - ); - StringBuilder sb = new StringBuilder(); - if (Objects.nonNull(currentQuestionData)) { - if (StringUtils.isNotBlank(currentQuestionData.getFeedback())) { - sb.append(currentQuestionData.getFeedback()).append("\n"); - } - if (StringUtils.isNotBlank(currentQuestionData.getSuggestions())) { - sb.append(currentQuestionData.getSuggestions()).append("\n"); - } - if (StringUtils.isNotBlank(currentQuestionData.getAiAnswer())) { - sb.append(currentQuestionData.getAiAnswer()).append("\n"); - } - } - - if (!currentQuestionId.equals(request.getCurrentQuestionId())) { - // 5. 生成并保存AI的提问消息 - String nextQuestionContent = String.format("好的,下一个问题是:%s", nextQuestionProgress.getQuestionContent()); - sb.append(nextQuestionContent); - int messageOrder = messageMapper.selectMaxOrderBySessionId(session.getSessionId()) + 1; - saveMessage(session.getSessionId(), InterviewMessage.MessageType.QUESTION.name(), - InterviewMessage.Sender.AI.name(), nextQuestionContent, currentQuestionId, messageOrder); - } - // 6. 返回响应 - return new InterviewResponse() - .setSessionId(session.getSessionId()) - .setMessage(sb.toString()) - .setMessageType(InterviewMessage.MessageType.QUESTION.name()) - .setSender(InterviewMessage.Sender.AI.name()) - .setCurrentQuestionIndex(session.getCurrentQuestionIndex()) - .setTotalQuestions(session.getTotalQuestions()) - .setCurrentQuestionId(currentQuestionId) - .setStatus(InterviewSession.Status.ACTIVE.name()); - - } - - - - - private String parseResume(MultipartFile resume) throws IOException { - String fileExtension = getFileExtension(resume.getOriginalFilename()); - DocumentParser parser = documentParsers.get(fileExtension); - if (parser == null) { - throw new IllegalArgumentException("不支持的简历文件类型: " + fileExtension); - } - return parser.parse(resume.getInputStream()); - } - - private List selectQuestionsByAi(String resumeContent, String sessionId) throws JsonProcessingException { - // 1. 获取全部题库 - List allQuestions = questionMapper.selectList(null); - String questionBankJson = objectMapper.writeValueAsString(allQuestions); - - // 2. 构建发送给AI的提示 - String prompt = String.format(""" - 你是一位专业的面试官。请根据以下候选人的简历内容,从提供的题库中,精心挑选出 %d 道最相关的题目进行面试。 - - 要求: - 1. 题目必须严格从【题库JSON】中选择。 - 2. 挑选的题目应根据候选人的简历内容来抽取。 - 3. 返回一个只包含所选题目ID的JSON数组,格式为:{"question_ids": [1, 5, 23, ...]}。 - 4. 不要返回任何多余的代码,包括markdown形式的代码,我只需要JSON对象,请严格按照api接口形式返回 - 5. 不要返回任何额外的解释或文字,只返回JSON对象。 - 6. 严格按照前后端分离的接口形式返回JSON数据给我,不要返回"```json```" - 7. 请保证返回数据的完整性,不要返回不完整的数据,否则我的JSON解析会报错!!! - - 【候选人简历】: - %s - - 【题库JSON】: - %s - """, MAX_QUESTIONS_PER_INTERVIEW, resumeContent, questionBankJson); - - // 3. 调用AI服务 - String aiResponse = llmService.chat(prompt); - log.info("AI抽题响应: {}", aiResponse); - - // 4. 解析AI返回的题目ID - List selectedIds = new ArrayList<>(); - try { - JsonNode rootNode = objectMapper.readTree(aiResponse); - JsonNode idsNode = rootNode.get("question_ids"); - if (idsNode != null && idsNode.isArray()) { - for (JsonNode idNode : idsNode) { - selectedIds.add(idNode.asLong()); - } - } - } catch (JsonProcessingException e) { - log.error("解析AI返回的题目ID列表失败", e); - return Collections.emptyList(); // 解析失败则返回空列表 - } - - if (selectedIds.isEmpty()) { - return Collections.emptyList(); - } - - // 5. 根据ID从数据库中获取完整的题目信息,并保持AI选择的顺序 - List finalQuestions = questionMapper.selectBatchIds(selectedIds); - finalQuestions.sort(Comparator.comparing(q -> selectedIds.indexOf(q.getId()))); // 保持AI返回的顺序 - - return finalQuestions; - } - - private InterviewSession createSession(String sessionId, InterviewStartRequest request, - String resumeContent, String selectedQuestionIdsJson) { - InterviewSession session = new InterviewSession() - .setSessionId(sessionId) - .setCandidateName(request.getCandidateName()) - .setResumeContent(resumeContent) - .setSelectedQuestionIds(selectedQuestionIdsJson) - .setAiModel("qwen-max") // Hardcoded to qwen-max - .setStatus(InterviewSession.Status.ACTIVE.name()) - .setCurrentQuestionIndex(0); - - sessionMapper.insert(session); - return session; - } - - private String generateFirstQuestion(InterviewSession session, Question question, String sessionId) { - String prompt = String.format(""" - 你是一位专业的技术面试官。现在要开始面试,候选人是 %s。 - - 第一个问题是:%s - - 请以友好但专业的语气提出这个问题,可以适当添加一些引导性的话语。 - """, session.getCandidateName(), question.getContent()); - - return this.llmService.chat(prompt, sessionId); - } - - private void saveMessage(String sessionId, String messageType, String sender, - String content, Long questionId, int order) { - InterviewMessage message = new InterviewMessage() - .setSessionId(sessionId) - .setMessageType(messageType) - .setSender(sender) - .setContent(content) - .setQuestionId(questionId) - .setMessageOrder(order); - - messageMapper.insert(message); - } - - /** - * 评估答案 - * - * @param session 会话数据 - * @param userAnswer 用户回答 - * @return 当前问题id - */ - private Long evaluateAnswer(InterviewSession session, String userAnswer) { - // 根据会话id查询当前会话所有问题 - List interviewQuestionProgresses = questionProgressMapper.selectList( - new LambdaQueryWrapper() - .eq(InterviewQuestionProgress::getSessionId, session.getSessionId()) - .orderByAsc(InterviewQuestionProgress::getCreatedTime) - ); - if (CollectionUtil.isEmpty(interviewQuestionProgresses)) { - throw new RuntimeException("当前会话没有任何可询问的问题!"); - } - - // 1. 获取当前正在回答的问题 - InterviewQuestionProgress currentQuestionProgress = null; - for (InterviewQuestionProgress interviewQuestionProgress : interviewQuestionProgresses) { - if (interviewQuestionProgress.getStatus().equals(InterviewQuestionProgress.Status.ACTIVE.name())) { - currentQuestionProgress = interviewQuestionProgress; - break; - } - } - if (Objects.isNull(currentQuestionProgress)) { - throw new RuntimeException("当前没有正在回答的问题"); - } - Long currentQuestionId = currentQuestionProgress.getQuestionId(); - - - List questionIds = interviewQuestionProgresses.stream() - .map(data -> { - return data.getQuestionId().toString(); - }) - .collect(Collectors.toList()); - String join = String.join(",", questionIds); - // 2. 构建评估提示 - String prompt = String.format(""" - 你是一位资深的技术面试官。请根据以下问题和候选人的回答,进行一次专业的评估。 - - 要求: - 1. 对回答的质量进行打分,分数范围为1-5分。 - 2. 给出简洁、专业的评语。 - 3. 提出具体的改进建议以及你认为应该回答的答案。 - 4. 以严格的JSON格式返回,不要包含任何额外的解释文字。格式如下: - { - "score": 4.5, - "feedback": "回答基本正确,但可以更深入...", - "suggestions": "可以补充关于XXX方面的知识点...", - "answer": "关于当前问题,您应该这样回答xxx", - "currentQuestionId": xxx - } - 5. 不要返回任何多余字符,请严格按照api接口格式的JSON数据进行返回,不要包含"```json```" - 6. 如果你认为面试人对当前问题回答不完美,可以继续对当前问题进行补充提问,但不要修改currentQuestionId - 7. 如果你认为面试人对当前问题回答已经比较好了,或者面试人回答不上来了,请你根据questionIds数据顺序选择下一个问题,并修改currentQuestionId进行返回 - 8. 如果所有问题都已回答完成,请将currentQuestionId设置为0 - { - "questionIds": %s, - "currentQuestionId": %s - } - 【面试问题】: - %s - - 【候选人回答】: - %s - """, join, currentQuestionProgress.getQuestionId(), currentQuestionProgress.getQuestionContent(), userAnswer); - - // 3. 调用AI进行评估 - String aiResponse = llmService.chat(prompt, session.getSessionId()); - log.info("AI评估响应: {}", aiResponse); - - // 4. 解析AI响应并存储评估结果 - try { - JsonNode rootNode = objectMapper.readTree(aiResponse); - InterviewEvaluation evaluation = new InterviewEvaluation() - .setSessionId(session.getSessionId()) - .setQuestionId(currentQuestionId) - .setUserAnswer(userAnswer) - .setScore(new java.math.BigDecimal(rootNode.get("score").asText())) - .setAiFeedback(rootNode.get("feedback").asText()) - .setEvaluationCriteria(rootNode.get("suggestions").asText()); // 暂时复用这个字段存建议 - JsonNode currentQuestionId1 = rootNode.get("currentQuestionId"); - JsonNode aiAnswerNode = rootNode.get("answer"); - if (Objects.nonNull(currentQuestionId1)) { - String text = currentQuestionId1.asText(); - if (StringUtils.isNoneBlank(text)) { - currentQuestionProgress - .setScore(new BigDecimal(rootNode.get("score").asText())) - .setSuggestions(rootNode.get("suggestions").asText()) - .setFeedback(rootNode.get("feedback").asText()) - .setAiAnswer(Objects.nonNull(aiAnswerNode) ? aiAnswerNode.asText() : null) - .setUserAnswer(userAnswer) - ; - if (!StrUtil.equals(text, currentQuestionProgress.getQuestionId().toString())) { - currentQuestionProgress.setStatus(InterviewQuestionProgress.Status.COMPLETED.name()); - questionProgressMapper.updateById(currentQuestionProgress); - questionProgressMapper.update( - new LambdaUpdateWrapper() - .set(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.ACTIVE.name()) - .eq(InterviewQuestionProgress::getSessionId, session.getSessionId()) - .eq(InterviewQuestionProgress::getQuestionId, Long.valueOf(text)) - ); - } else if (text.equals("0")) { - currentQuestionProgress.setStatus(InterviewQuestionProgress.Status.COMPLETED.name()); - questionProgressMapper.updateById(currentQuestionProgress); - } - currentQuestionId = Long.valueOf(text); - } - } - evaluationMapper.insert(evaluation); - log.info("成功存储对问题ID {} 的评估结果", currentQuestionId); - return currentQuestionId; - } catch (Exception e) { - log.error("解析或存储AI评估结果失败", e); - throw new RuntimeException("解析或存储AI评估结果失败"); - } - } - - private InterviewResponse finishInterview(InterviewSession session) { - // 1. 获取本次面试的所有评估数据 - List evaluations = evaluationMapper.selectBySessionId(session.getSessionId()); - - // 2. 构建生成最终报告的提示 - String prompt = buildFinalReportPrompt(session, evaluations); - - // 3. 调用AI生成报告 - String finalReportJson = llmService.chat(prompt, session.getSessionId()); - log.info("AI生成的最终面试报告: {}", finalReportJson); - - // 4. 更新会话状态和最终报告 - session.setStatus(InterviewSession.Status.COMPLETED.name()); - session.setFinalReport(finalReportJson); - sessionMapper.updateById(session); - - // 5. 返回结束信息 - return new InterviewResponse() - .setSessionId(session.getSessionId()) - .setMessage("面试已结束,感谢您的参与!AI正在生成您的面试报告,请稍后在面试历史中查看。") - .setMessageType(InterviewMessage.MessageType.SYSTEM.name()) - .setSender(InterviewMessage.Sender.SYSTEM.name()) - .setCurrentQuestionId(null) - .setStatus(InterviewSession.Status.COMPLETED.name()); - } - - private String buildFinalReportPrompt(InterviewSession session, List evaluations) { - StringBuilder historyBuilder = new StringBuilder(); - for (InterviewEvaluation eval : evaluations) { - Question q = questionMapper.selectById(eval.getQuestionId()); - historyBuilder.append(String.format("\n【问题】: %s\n【回答】: %s\n【AI单题反馈】: %s\n【AI单题建议】: %s\n【AI单题评分】: %s/5.0\n", - q.getContent(), eval.getUserAnswer(), eval.getAiFeedback(), eval.getEvaluationCriteria(), eval.getScore())); - } - - return String.format(""" - 你是一位资深的HR和技术总监。请根据以下候选人的简历、完整的面试问答历史和AI对每一题的初步评估,给出一份全面、专业、有深度的最终面试报告。 - - 要求: - 1. **综合评价**: 对候选人的整体表现给出一个总结性的评语,点出其核心亮点和主要不足。 - 2. **技术能力评估**: 分点阐述候选人在不同技术领域(如Java基础, Spring, 数据库等)的掌握程度。 - 3. **改进建议**: 给出3-5条具体的、可操作的学习和改进建议。 - 4. **综合得分**: 给出一个1-100分的最终综合得分。 - 5. **录用建议**: 给出明确的录用建议(如:强烈推荐、推荐、待考虑、不推荐)。 - 6. 以严格的JSON格式返回,不要包含任何额外的解释文字。格式如下: - { - "overallScore": 85, - "overallFeedback": "候选人Java基础扎实,但在高并发场景下的经验有所欠缺...", - "technicalAssessment": { - "Java基础": "掌握良好,对集合框架理解深入。", - "Spring框架": "熟悉基本使用,但对底层原理理解不足。", - "数据库": "能够编写常规SQL,但在索引优化方面知识欠缺。" - }, - "suggestions": [ - "深入学习Spring AOP和事务管理的实现原理。", - "系统学习MySQL索引优化和查询性能分析。", - "通过实际项目积累高并发处理经验。" - ], - "hiringRecommendation": "推荐" - } - - 【候选人简历摘要】: - %s - - 【面试问答与评估历史】: - %s - """, session.getResumeContent(), historyBuilder.toString()); - } - - - /** - * 获取所有面试会话列表 - */ - public List getInterviewSessions() { - log.info("Fetching all interview sessions"); - return sessionMapper.selectList(null); // 实际中可能需要分页 - } - - /** - * 获取详细的面试复盘报告 - */ - public InterviewReportResponse getInterviewReport(String sessionId) { - log.info("Fetching interview report for session id: {}", sessionId); - - InterviewSession session = sessionMapper.selectBySessionId(sessionId); - if (session == null) { - throw new IllegalArgumentException("找不到ID为 " + sessionId + " 的面试会话。"); - } - - List evaluations = evaluationMapper.selectBySessionId(sessionId); - - List questionDetails = evaluations.stream().map(eval -> { - Question question = questionMapper.selectById(eval.getQuestionId()); - InterviewReportResponse.QuestionDetail detail = new InterviewReportResponse.QuestionDetail(); - detail.setQuestionId(eval.getQuestionId()); - detail.setQuestionContent(question != null ? question.getContent() : "题目已不存在"); - detail.setUserAnswer(eval.getUserAnswer()); - detail.setAiFeedback(eval.getAiFeedback()); - detail.setSuggestions(eval.getEvaluationCriteria()); - detail.setScore(eval.getScore()); - return detail; - }).collect(Collectors.toList()); - - InterviewReportResponse report = new InterviewReportResponse(); - report.setSessionDetails(session); - report.setQuestionDetails(questionDetails); - List interviewMessages = messageMapper.selectList( - new LambdaQueryWrapper() - .eq(InterviewMessage::getSessionId, sessionId) - ); - // 获取当前面试的 问题 - InterviewQuestionProgress progress = questionProgressMapper.selectOne( - new LambdaQueryWrapper() - .eq(InterviewQuestionProgress::getSessionId, sessionId) - .eq(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.ACTIVE.name()) - .last("LIMIT 1") - ); - if (Objects.nonNull(progress)) { - report.setCurrentQuestionId(progress.getQuestionId()); - } - report.setMessages(interviewMessages); - - return report; - } - - private String getFileExtension(String fileName) { - if (fileName == null || fileName.lastIndexOf('.') == -1) { - return ""; - } - return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); - } -} - +package com.qingqiu.interview.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.qingqiu.interview.dto.InterviewStartRequest; +import com.qingqiu.interview.dto.SubmitAnswerDTO; +import com.qingqiu.interview.entity.InterviewQuestionProgress; +import com.qingqiu.interview.entity.InterviewSession; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/19 16:05 + */ + +public interface InterviewService extends IService { + /** + * 开始一场新的面试 + * + * @param file 简历文件 + * @param dto 开始面试的请求参数 + * @return 创建的面试会话 + */ + InterviewSession startInterview(MultipartFile file, InterviewStartRequest dto) throws IOException; + + /** + * 获取下一个问题 + * + * @param sessionId 会话ID + * @return 下一个问题 或 null(如果没有更多问题) + */ + InterviewQuestionProgress getNextQuestion(String sessionId); + + /** + * 提交答案并获取AI评估 + * + * @param submitAnswerDTO 提交答案的请求参数 + * @return 对当前问题的评估和反馈 + */ + InterviewQuestionProgress submitAnswer(SubmitAnswerDTO submitAnswerDTO); + + /** + * 结束面试并生成最终报告 + * + * @param sessionId 会话ID + * @return 包含最终报告的面试会话信息 + */ + InterviewSession endInterview(String sessionId); +} diff --git a/src/main/java/com/qingqiu/interview/service/QuestionService.java b/src/main/java/com/qingqiu/interview/service/QuestionService.java index 670a6d4..763ed36 100755 --- a/src/main/java/com/qingqiu/interview/service/QuestionService.java +++ b/src/main/java/com/qingqiu/interview/service/QuestionService.java @@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.qingqiu.interview.dto.QuestionOptionsDTO; import com.qingqiu.interview.dto.QuestionPageParams; import com.qingqiu.interview.entity.Question; -import com.qingqiu.interview.entity.QuestionCategory; import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO; import org.springframework.web.multipart.MultipartFile; @@ -27,4 +26,14 @@ public interface QuestionService extends IService { void useAiCheckQuestionData(); List getTreeListCategory(QuestionOptionsDTO dto); + + /** + * 根据技能和难度从本地题库随机选择题目 + * + * @param skills 技能列表 + * @param difficulty 难度 + * @param count 题目数量 + * @return 题目列表 + */ + List selectLocalQuestions(List skills, String difficulty, int count); } diff --git a/src/main/java/com/qingqiu/interview/service/impl/InterviewAiServiceImpl.java b/src/main/java/com/qingqiu/interview/service/impl/InterviewAiServiceImpl.java new file mode 100644 index 0000000..0a9dc6b --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/impl/InterviewAiServiceImpl.java @@ -0,0 +1,157 @@ +package com.qingqiu.interview.service.impl; + +import com.alibaba.dashscope.common.Role; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.qingqiu.interview.common.constants.CommonConstant; +import com.qingqiu.interview.dto.ChatDTO; +import com.qingqiu.interview.entity.InterviewQuestionProgress; +import com.qingqiu.interview.entity.InterviewSession; +import com.qingqiu.interview.entity.Question; +import com.qingqiu.interview.service.ChatService; +import com.qingqiu.interview.service.InterviewAiService; +import com.qingqiu.interview.vo.ChatVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/19 16:49 + */ +@Slf4j +@Service +@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy}) +public class InterviewAiServiceImpl implements InterviewAiService { + + private final ChatService chatService; + + @Override + public JSONObject extractSkillsFromResume(String resumeContent) { + String prompt = "你是一位资深的IT技术招聘专家。" + + "请仔细阅读以下简历内容,并提取出其中所有的关键技术技能。" + + "请严格按照以下JSON格式返回,不要添加任何额外的解释或说明:\n" + + "{\"skills\": [\"技能1\", \"技能2\", \"...\"]}\n\n" + + "简历内容如下:\n" + resumeContent; + + ChatDTO chatDTO = new ChatDTO() + .setContent(prompt) + .setRole(Role.SYSTEM.name()) + .setDataType(CommonConstant.ONE); + ChatVO chatVO = chatService.createChat(chatDTO); + + return JSONObject.parse(chatVO.getContent()); + } + + @Override + public JSONObject generateQuestionsOfAi(String sessionId, List skills, String resumeContent, int count) { + String skillsStr = String.join(", ", skills); + String prompt = String.format( + "你是一位专业的软件开发岗位技术面试官。" + + "请根据候选人的以下技术栈、项目经历、简历内容,生成 %d 道有深度和广度的面试题。" + + "题目应覆盖候选人的主要技术领域,并能考察其解决问题的能力。" + + "请严格按照以下JSON格式返回,question数组中必须包含 %d 个问题对象:\n" + + "{\"questions\": [{\"id\": \"ai-gen-1\", \"content\": \"问题1内容...\"}, {\"id\": \"ai-gen-2\", \"content\": \"问题2内容...\"}]}\n\n" + + "候选人技术栈:%s\n" + + "候选人简历:%s", + count, count, skillsStr, resumeContent + ); + + ChatDTO chatDTO = new ChatDTO() + .setSessionId(sessionId) + .setContent(prompt) + .setRole(Role.SYSTEM.name()) + .setDataType(CommonConstant.ONE); + ChatVO chatVO = chatService.createChat(chatDTO); + return JSON.parseObject(chatVO.getContent()); + } + + @Override + public JSONObject generateQuestionOfLocal(String sessionId, List questions, List skills, String resumeContent, int count) { + String skillsStr = String.join(", ", skills); + // 2. 构建发送给AI的提示 + String prompt = String.format(""" + 你是一位专业的面试官。请根据以下候选人的技术栈、项目经历、简历内容,从提供的题库中,精心挑选出 %d 道最相关的题目进行面试。 + 题目应覆盖候选人的主要技术领域,并能考察其解决问题的能力。 + 要求: + 1. 题目必须严格从【题库JSON】中选择。 + 2. 挑选的题目应根据候选人的简历内容来抽取。 + 3. 返回一个只包含所选题目ID的JSON数组,格式为:{"question_ids": [1, 5, 23, ...]}。 + 4. 不要返回任何多余的代码,包括markdown形式的代码,我只需要JSON对象,请严格按照api接口形式返回 + 5. 不要返回任何额外的解释或文字,只返回JSON对象。 + 6. 严格按照前后端分离的接口形式返回JSON数据给我,不要返回"```json```" + 7. 请保证返回数据的完整性,不要返回不完整的数据,否则我的JSON解析会报错!!! + + 【候选人技术栈】: + %s + 【候选人简历】: + [%s] + 【题库JSON】: + %s + """, count, skillsStr, resumeContent, JSONObject.toJSONString(questions)); + ChatDTO chatDTO = new ChatDTO() + .setSessionId(sessionId) + .setContent(prompt) + .setRole(Role.SYSTEM.name()) + .setDataType(CommonConstant.ONE); + ChatVO chatVO = chatService.createChat(chatDTO); + return JSON.parseObject(chatVO.getContent()); + } + + @Override + public JSONObject evaluateAnswer(String sessionId, String question, String userAnswer, List context) { + // 构建上下文历史 + String history = context.stream() + .map(p -> String.format("Q: %s\nA: %s", p.getQuestionContent(), p.getUserAnswer())) + .collect(Collectors.joining("\n---\n")); + + String prompt = "你是一位资深的技术面试官,以严格和深入著称。" + + "你需要评估候选人对以下问题的回答。请注意:\n" + + "1. 如果回答模糊、不完整或有错误,你必须提出一个具体的追问问题(followUpQuestion)来深入考察,此时'continueAsking'应为true。\n" + + "2. 如果回答得很好,则'continueAsking'为false,'followUpQuestion'为空字符串。\n" + + "3. 'score'范围为0-100分。\n" + + "4. 'feedback'和'suggestions'需要给出专业、有建设性的意见。\n" + + "请严格按照以下JSON格式返回,不要有任何额外说明:\n" + + "{\"feedback\": \"...\", \"suggestions\": \"...\", \"aiAnswer\": \"...\", \"score\": 85.5, \"continueAsking\": false, \"followUpQuestion\": \"...\"}\n\n" + + "面试历史上下文:\n" + history + "\n\n" + + "当前问题:\n" + question + "\n\n" + + "候选人回答:\n" + userAnswer; + + ChatDTO chatDTO = new ChatDTO() + .setContent(prompt) + .setRole(Role.SYSTEM.name()) + .setDataType(CommonConstant.ONE); + ChatVO chatVO = chatService.createChat(chatDTO); + return JSON.parseObject(chatVO.getContent()); + } + + @Override + public JSONObject generateFinalReport(InterviewSession session, List progressList) { + String transcript = progressList.stream() + .map(p -> String.format("问题: %s\n回答: %s\nAI评分: %.1f\nAI反馈: %s\n", + p.getQuestionContent(), p.getUserAnswer(), p.getScore(), p.getFeedback())) + .collect(Collectors.joining("\n-----------------\n")); + + String prompt = "你是一位经验丰富的招聘经理。" + + "请根据以下完整的面试记录,为候选人生成一份综合评估报告。" + + "报告需要包括一个总分(overallScore),简明扼要的总结(summary),以及候选人的优点(strengths)和待提升点(weaknesses)。" + + "请严格按照以下JSON格式返回:\n" + + "{\"overallScore\": 88.0, \"summary\": \"...\", \"strengths\": [\"...\"], \"weaknesses\": [\"...\"]}\n\n" + + "候选人姓名:" + session.getCandidateName() + "\n" + + "面试完整记录:\n" + transcript; + + ChatDTO chatDTO = new ChatDTO() + .setRole(Role.SYSTEM.name()) + .setDataType(CommonConstant.ONE) + .setContent(prompt); + ChatVO chatVO = chatService.createChat(chatDTO); + return JSON.parseObject(chatVO.getContent()); + } +} diff --git a/src/main/java/com/qingqiu/interview/service/impl/InterviewServiceImpl.java b/src/main/java/com/qingqiu/interview/service/impl/InterviewServiceImpl.java new file mode 100644 index 0000000..b1393e9 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/impl/InterviewServiceImpl.java @@ -0,0 +1,300 @@ +package com.qingqiu.interview.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.io.file.FileNameUtil; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.qingqiu.interview.common.enums.DocumentParserProvider; +import com.qingqiu.interview.dto.InterviewStartRequest; +import com.qingqiu.interview.dto.SubmitAnswerDTO; +import com.qingqiu.interview.entity.InterviewEvaluation; +import com.qingqiu.interview.entity.InterviewQuestionProgress; +import com.qingqiu.interview.entity.InterviewSession; +import com.qingqiu.interview.entity.Question; +import com.qingqiu.interview.mapper.InterviewEvaluationMapper; +import com.qingqiu.interview.mapper.InterviewQuestionProgressMapper; +import com.qingqiu.interview.mapper.InterviewSessionMapper; +import com.qingqiu.interview.service.InterviewAiService; +import com.qingqiu.interview.service.InterviewService; +import com.qingqiu.interview.service.QuestionService; +import com.qingqiu.interview.service.parser.DocumentParser; +import com.qingqiu.interview.service.parser.DocumentParserManager; +import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/19 16:07 + */ +@Slf4j +@Service +@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy}) +public class InterviewServiceImpl extends ServiceImpl implements InterviewService { + + private final QuestionService questionService; + + private final InterviewQuestionProgressMapper progressMapper; + + private final InterviewEvaluationMapper evaluationMapper; + + private final InterviewAiService aiService; + + private final DocumentParserManager documentParserManager; + + @Override + @Transactional(rollbackFor = Exception.class) + public InterviewSession startInterview(MultipartFile file, InterviewStartRequest dto) throws IOException { + // 1. 创建并保存会话主记录 + String sessionId = UUID.randomUUID().toString().replace("-", ""); + String resumeContent = parseResume(file); + InterviewSession session = new InterviewSession(); + + session.setSessionId(sessionId); + session.setCandidateName(dto.getCandidateName()); + session.setResumeContent(resumeContent); + session.setAiModel(dto.getAiModel()); + session.setStatus(InterviewSession.Status.ACTIVE.name()); + session.setTotalQuestions(dto.getTotalQuestions()); + this.baseMapper.insert(session); // 先插入以获取ID + + // 2. 调用AI服务从简历提取技能 + JSONObject skillsJson = aiService.extractSkillsFromResume(resumeContent); + // ---> 解析AI返回的JSON数据,获取技能列表 <--- + List skills = skillsJson.getList("skills", String.class); + session.setExtractedSkills(skillsJson.toJSONString()); + + // 3. 准备面试问题(本地 + AI生成) + if (dto.getModel().equals("local")) { + localGenerateQuestions(session, skills, dto.getSelectedNodes()); + } else { + aiGenerateQuestions(session, skills); + } + + // 4. 更新会话信息 + this.baseMapper.updateById(session); + return session; + } + + + private void aiGenerateQuestions(InterviewSession session, List skills) { + List progressList = new ArrayList<>(); + JSONObject aiQuestionsJson = aiService.generateQuestionsOfAi( + session.getSessionId(), + skills, + session.getResumeContent(), + session.getTotalQuestions() + ); + // ---> 解析AI返回的JSON数据,获取问题列表 <--- + JSONArray questions = aiQuestionsJson.getJSONArray("questions"); + if (questions != null) { + questions.forEach(item -> { + JSONObject q = (JSONObject) item; + InterviewQuestionProgress progress = new InterviewQuestionProgress(); + progress.setSessionId(session.getSessionId()); + progress.setQuestionId(0L); // AI生成的问题没有本地ID + // ---> 解析单个问题内容 <--- + progress.setQuestionContent(q.getString("content")); + progress.setStatus(InterviewQuestionProgress.Status.DEFAULT.name()); + progressList.add(progress); + }); + } + // 批量保存问题进度 + if (CollectionUtil.isNotEmpty(progressList)) { + progressList.forEach(progressMapper::insert); + } + } + + + private void localGenerateQuestions(InterviewSession session, + List skills, + List selectedNodes) { + List localQuestionDataList = new ArrayList<>(); + // 如果用户选择了题目 则使用用户选择的题目 否则直接使用全部的题目 + if (CollectionUtil.isNotEmpty(selectedNodes)) { + List question = selectedNodes.stream() + .filter(node -> node.getType().equals("question")) + .toList(); + if (CollectionUtil.isNotEmpty(question)) { + localQuestionDataList = question.stream() + .map(node -> { + return new Question().setId(node.getId()).setContent(node.getName()); + }).toList(); + + } + } + if (CollectionUtil.isEmpty(localQuestionDataList)) { + localQuestionDataList = questionService.list( + new LambdaQueryWrapper() + .select(Question::getId, Question::getContent) + ); + } + // ai调用返回的内容进行提取 + JSONObject jsonObject = aiService.generateQuestionOfLocal( + session.getSessionId(), + localQuestionDataList, + skills, + session.getResumeContent(), + session.getTotalQuestions() + ); + JSONArray questionIds = jsonObject.getJSONArray("question_ids"); + List list = questionIds.toList(Long.class); + // 查询返回的内容 并将其保存为问题进度的相关数据 + List questionList = questionService.list( + new LambdaQueryWrapper() + .in(Question::getId, list) + ); + List progressList = new ArrayList<>(); + questionList.forEach(q -> { + InterviewQuestionProgress progress = new InterviewQuestionProgress(); + progress.setSessionId(session.getSessionId()); + progress.setQuestionId(q.getId()); + progress.setQuestionContent(q.getContent()); + progress.setStatus(InterviewQuestionProgress.Status.DEFAULT.name()); + progressList.add(progress); + }); + // 批量保存问题进度 + if (CollectionUtil.isNotEmpty(progressList)) { + progressList.forEach(progressMapper::insert); + } + + } + + + @Override + @Transactional(rollbackFor = Exception.class) + public InterviewQuestionProgress getNextQuestion(String sessionId) { + // 1. 查找第一个处于“默认”状态的问题 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(InterviewQuestionProgress::getSessionId, sessionId) + .eq(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.DEFAULT.name()) + .orderByAsc(InterviewQuestionProgress::getId) // 按插入顺序 + .last("LIMIT 1"); + InterviewQuestionProgress nextQuestion = progressMapper.selectOne(queryWrapper); + + if (nextQuestion == null) { + // 没有更多的问题了 + return null; + } + + // 2. 将问题状态更新为“进行中” + nextQuestion.setStatus(InterviewQuestionProgress.Status.ACTIVE.name()); + progressMapper.updateById(nextQuestion); + + return nextQuestion; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public InterviewQuestionProgress submitAnswer(SubmitAnswerDTO dto) { + // 1. 查询当前正在进行的这个问题 + InterviewQuestionProgress currentProgress = progressMapper.selectById(dto.getProgressId()); + if (currentProgress == null || !InterviewQuestionProgress.Status.ACTIVE.name().equals(currentProgress.getStatus())) { + throw new RuntimeException("问题进度不存在或已处理"); + } + currentProgress.setUserAnswer(dto.getAnswer()); + + // 2. 调用AI服务评估回答 + List context = progressMapper.selectList( + new LambdaQueryWrapper() + .eq(InterviewQuestionProgress::getSessionId, currentProgress.getSessionId()) + .eq(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.COMPLETED.name()) + .orderByAsc(InterviewQuestionProgress::getId) + ); + JSONObject evalResult = aiService.evaluateAnswer( + currentProgress.getSessionId(), + currentProgress.getQuestionContent(), + dto.getAnswer(), + context + ); + + // 3. ---> 解析AI返回的JSON评估结果并存入数据库 <--- + currentProgress.setFeedback(evalResult.getString("feedback")); + currentProgress.setSuggestions(evalResult.getString("suggestions")); + currentProgress.setAiAnswer(evalResult.getString("aiAnswer")); + currentProgress.setScore(evalResult.getBigDecimal("score")); + currentProgress.setStatus(InterviewQuestionProgress.Status.COMPLETED.name()); + progressMapper.updateById(currentProgress); + + // 4. 将单题评估结果存入 evaluation 表用于分析 + saveEvaluationRecord(currentProgress, evalResult); + + // 5. ---> 解析AI的是否追问判断,并处理追问逻辑 <--- + if (evalResult.getBooleanValue("continueAsking", false)) { + // 创建一个新的、状态为ACTIVE的追问问题 + InterviewQuestionProgress followUp = new InterviewQuestionProgress(); + followUp.setSessionId(currentProgress.getSessionId()); + followUp.setQuestionId(0L); // 追问问题没有本地ID + followUp.setQuestionContent(evalResult.getString("followUpQuestion")); + followUp.setStatus(InterviewQuestionProgress.Status.ACTIVE.name()); // 直接设为激活状态,作为下一个问题 + progressMapper.insert(followUp); + return followUp; // 将这个新的追问问题返回给前端 + } + + return currentProgress; + } + + private void saveEvaluationRecord(InterviewQuestionProgress progress, JSONObject evalResult) { + InterviewEvaluation evaluation = new InterviewEvaluation(); + evaluation.setSessionId(progress.getSessionId()); + evaluation.setQuestionId(progress.getQuestionId()); + evaluation.setUserAnswer(progress.getUserAnswer()); + // ---> 解析AI评估结果并存入分析表 <--- + evaluation.setAiFeedback(evalResult.getString("feedback")); + evaluation.setScore(evalResult.getBigDecimal("score")); + evaluationMapper.insert(evaluation); + } + + @Override + public InterviewSession endInterview(String sessionId) { + InterviewSession session = this.getOne(new LambdaQueryWrapper() + .eq(InterviewSession::getSessionId, sessionId)); + if (session == null) throw new RuntimeException("会话不存在"); + + List completedProgresses = progressMapper.selectList( + new LambdaQueryWrapper() + .eq(InterviewQuestionProgress::getSessionId, sessionId) + .eq(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.COMPLETED.name()) + ); + if (CollectionUtil.isEmpty(completedProgresses)) { + session.setStatus(InterviewSession.Status.COMPLETED.name()); + this.baseMapper.updateById(session); + return session; + } + + // 2. 调用AI服务生成最终报告 + JSONObject finalReportJson = aiService.generateFinalReport(session, completedProgresses); + + // 3. ---> 解析AI返回的最终报告JSON,更新会话状态 <--- + session.setStatus(InterviewSession.Status.COMPLETED.name()); + session.setScore(finalReportJson.getBigDecimal("overallScore")); + session.setFinalReport(finalReportJson.toJSONString()); + this.baseMapper.updateById(session); + + return session; + } + + + private String parseResume(MultipartFile resume) throws IOException { + // 获取文件扩展名 + String extName = FileNameUtil.extName(resume.getOriginalFilename()); + // 1. 获取简历解析器 + DocumentParser parser = documentParserManager.getParser(DocumentParserProvider.fromCode(extName)); + // 2. 解析简历 + return parser.parse(resume.getInputStream()); + } +} diff --git a/src/main/java/com/qingqiu/interview/service/impl/QuestionServiceImpl.java b/src/main/java/com/qingqiu/interview/service/impl/QuestionServiceImpl.java index e645539..f862aad 100644 --- a/src/main/java/com/qingqiu/interview/service/impl/QuestionServiceImpl.java +++ b/src/main/java/com/qingqiu/interview/service/impl/QuestionServiceImpl.java @@ -31,10 +31,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.stream.Collectors; @Service @@ -242,6 +239,7 @@ public class QuestionServiceImpl extends ServiceImpl i log.info("根节点题目总数: {}", i); QuestionAndCategoryTreeListVO rootVO = new QuestionAndCategoryTreeListVO(); rootVO.setId(0L); + rootVO.setNodeKey(UUID.randomUUID().toString().replace("-", "")); rootVO.setName("全部题目"); rootVO.setType("root"); rootVO.setChildren(voList); @@ -252,6 +250,20 @@ public class QuestionServiceImpl extends ServiceImpl i return voList; } + @Override + public List selectLocalQuestions(List skills, String difficulty, int count) { + // TODO: 实现更智能的选题逻辑,例如: + // 1. 根据技能(skills)匹配题目的`tags`或`category_name`。 + // 2. 使用`difficulty`进行筛选。 + // 3. 随机选取`count`道题目。 + // 4. 此处仅为简单示例,随机获取指定数量的题目。 + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.last("ORDER BY RAND() LIMIT " + count); + + return this.baseMapper.selectList(queryWrapper); + } + /** * 将QuestionCategory列表转换为QuestionAndCategoryTreeListVO列表,并整合题目数据 * @@ -302,6 +314,7 @@ public class QuestionServiceImpl extends ServiceImpl i vo.setName(category.getName()); vo.setType("category"); vo.setCount(0); + vo.setNodeKey(UUID.randomUUID().toString().replace("-", "")); // 处理子节点(包括子分类和题目) List childrenVOs = new ArrayList<>(); @@ -355,6 +368,7 @@ public class QuestionServiceImpl extends ServiceImpl i vo.setChildren(List.of()); vo.setType("question"); vo.setCount(0); // 题目节点没有子节点,count设为0 + vo.setNodeKey(UUID.randomUUID().toString().replace("-", "")); return vo; } diff --git a/src/main/java/com/qingqiu/interview/vo/ChatVO.java b/src/main/java/com/qingqiu/interview/vo/ChatVO.java index 1d97b1e..ffb223b 100644 --- a/src/main/java/com/qingqiu/interview/vo/ChatVO.java +++ b/src/main/java/com/qingqiu/interview/vo/ChatVO.java @@ -11,7 +11,7 @@ import lombok.experimental.Accessors; * @date 2025/9/18 12:56 */ @Data -@Accessors +@Accessors(chain = true) @Builder public class ChatVO { diff --git a/src/main/java/com/qingqiu/interview/vo/QuestionAndCategoryTreeListVO.java b/src/main/java/com/qingqiu/interview/vo/QuestionAndCategoryTreeListVO.java index d9228ff..25b7fc9 100644 --- a/src/main/java/com/qingqiu/interview/vo/QuestionAndCategoryTreeListVO.java +++ b/src/main/java/com/qingqiu/interview/vo/QuestionAndCategoryTreeListVO.java @@ -19,6 +19,8 @@ public class QuestionAndCategoryTreeListVO implements Serializable { private Long id; + private String nodeKey; + private String name; /** * category:分类 diff --git a/src/main/resources/mapper/InterviewMessageMapper.xml b/src/main/resources/mapper/InterviewService.xml similarity index 97% rename from src/main/resources/mapper/InterviewMessageMapper.xml rename to src/main/resources/mapper/InterviewService.xml index d938622..6ecdb77 100755 --- a/src/main/resources/mapper/InterviewMessageMapper.xml +++ b/src/main/resources/mapper/InterviewService.xml @@ -1,23 +1,23 @@ - - - - - - - - - - - + + + + + + + + + + +