优化代码
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
package com.qingqiu.interview.dto;
|
package com.qingqiu.interview.common.dto;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
96
src/main/java/com/qingqiu/interview/controller/InterviewController.java
Executable file → Normal file
96
src/main/java/com/qingqiu/interview/controller/InterviewController.java
Executable file → Normal file
@@ -1,65 +1,105 @@
|
|||||||
package com.qingqiu.interview.controller;
|
package com.qingqiu.interview.controller;
|
||||||
|
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.qingqiu.interview.dto.*;
|
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.entity.InterviewSession;
|
||||||
import com.qingqiu.interview.service.InterviewService;
|
import com.qingqiu.interview.service.InterviewService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 面试流程相关接口
|
* <h1></h1>
|
||||||
|
*
|
||||||
|
* @author qingqiu
|
||||||
|
* @date 2025/9/19 16:13
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/interview")
|
@RequestMapping("/interview")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
|
||||||
public class InterviewController {
|
public class InterviewController {
|
||||||
|
|
||||||
private final InterviewService interviewService;
|
private final InterviewService interviewService;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开始新的面试会话
|
* 开始面试
|
||||||
|
*
|
||||||
|
* @return 包含会话ID的会话信息
|
||||||
*/
|
*/
|
||||||
@PostMapping("/start")
|
@PostMapping("/start")
|
||||||
public ApiResponse<InterviewResponse> startInterview(
|
public R<InterviewSession> start(@RequestParam("resume") MultipartFile resume,
|
||||||
@RequestParam("resume") MultipartFile resume,
|
@Validated @ModelAttribute InterviewStartRequest request) {
|
||||||
@Validated @ModelAttribute InterviewStartRequest request) throws IOException {
|
log.info("接受的数据: {}", JSONObject.toJSONString(request));
|
||||||
// InterviewResponse response = interviewService.startInterview(resume, request);
|
return R.success();
|
||||||
log.info("接收到的数据: {}", JSONObject.toJSONString(request));
|
// try {
|
||||||
InterviewResponse interviewResponse = new InterviewResponse();
|
// InterviewSession session = interviewService.startInterview(resume, request);
|
||||||
interviewResponse.setSessionId(UUID.randomUUID().toString().replace("-", ""));
|
// return R.success(session);
|
||||||
return ApiResponse.success(interviewResponse);
|
// } catch (Exception e) {
|
||||||
|
// // log.error("开始面试失败", e);
|
||||||
|
// return R.error("开始面试失败:" + e.getMessage());
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 继续面试会话(用户回答)
|
* 获取下一个问题
|
||||||
|
*
|
||||||
|
* @param sessionId 会话ID
|
||||||
|
* @return 下一个问题
|
||||||
*/
|
*/
|
||||||
@PostMapping("/chat")
|
@GetMapping("/{sessionId}/next-question")
|
||||||
public ApiResponse<InterviewResponse> continueInterview(@Validated @RequestBody ChatRequest request) {
|
public R<InterviewQuestionProgress> getNextQuestion(@PathVariable String sessionId) {
|
||||||
InterviewResponse response = interviewService.continueInterview(request);
|
try {
|
||||||
return ApiResponse.success(response);
|
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("/get-history-list")
|
@PostMapping("/submit-answer")
|
||||||
public ApiResponse<java.util.List<InterviewSession>> getInterviewHistoryList() {
|
public R<InterviewQuestionProgress> submitAnswer(@RequestBody SubmitAnswerDTO submitDto) {
|
||||||
return ApiResponse.success(interviewService.getInterviewSessions());
|
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("/get-report-detail")
|
@PostMapping("/{sessionId}/end")
|
||||||
public ApiResponse<InterviewReportResponse> getInterviewReportDetail(@RequestBody SessionRequest request) {
|
public R<InterviewSession> endInterview(@PathVariable String sessionId) {
|
||||||
return ApiResponse.success(interviewService.getInterviewReport(request.getSessionId()));
|
try {
|
||||||
|
InterviewSession finalSession = interviewService.endInterview(sessionId);
|
||||||
|
return R.success(finalSession);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// log.error("结束面试失败", e);
|
||||||
|
return R.error("结束面试失败:" + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.qingqiu.interview.dto;
|
package com.qingqiu.interview.dto;
|
||||||
|
|
||||||
|
import com.qingqiu.interview.common.enums.LLMProvider;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.experimental.Accessors;
|
import lombok.experimental.Accessors;
|
||||||
|
|
||||||
@@ -10,13 +11,14 @@ import lombok.experimental.Accessors;
|
|||||||
* @date 2025/9/18 12:54
|
* @date 2025/9/18 12:54
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
|
|
||||||
@Accessors(chain = true)
|
@Accessors(chain = true)
|
||||||
public class ChatDTO {
|
public class ChatDTO {
|
||||||
|
|
||||||
/** 会话id */
|
/** 会话id */
|
||||||
private String sessionId;
|
private String sessionId;
|
||||||
/** 调用模型 */
|
/** 调用模型 */
|
||||||
private String aiModel;
|
private String aiModel = LLMProvider.DEEPSEEK.getCode();
|
||||||
/** 输入内容 */
|
/** 输入内容 */
|
||||||
private String content;
|
private String content;
|
||||||
/** 0 普通会话 1 面试会话 */
|
/** 0 普通会话 1 面试会话 */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.qingqiu.interview.dto;
|
package com.qingqiu.interview.dto;
|
||||||
|
|
||||||
|
import com.qingqiu.interview.common.enums.LLMProvider;
|
||||||
import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
|
import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -18,6 +19,11 @@ public class InterviewStartRequest {
|
|||||||
@NotBlank(message = "面试类型不能为空")
|
@NotBlank(message = "面试类型不能为空")
|
||||||
private String model;
|
private String model;
|
||||||
|
|
||||||
|
/** 选择的AI模型 */
|
||||||
|
private String aiModel = LLMProvider.QWEN.getCode();
|
||||||
|
|
||||||
|
/** 生成的面试题目数量 */
|
||||||
|
private Integer totalQuestions = 10;
|
||||||
|
|
||||||
// 简历文件通过MultipartFile单独传递
|
// 简历文件通过MultipartFile单独传递
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.qingqiu.interview.dto;
|
package com.qingqiu.interview.dto;
|
||||||
|
|
||||||
|
import com.qingqiu.interview.common.dto.PageBaseParams;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
import lombok.experimental.Accessors;
|
import lombok.experimental.Accessors;
|
||||||
@@ -14,7 +15,7 @@ import lombok.experimental.Accessors;
|
|||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@Data
|
@Data
|
||||||
@Accessors(chain = true)
|
@Accessors(chain = true)
|
||||||
public class QuestionCategoryPageParams extends PageBaseParams{
|
public class QuestionCategoryPageParams extends PageBaseParams {
|
||||||
/**
|
/**
|
||||||
* 分类名称(模糊查询)
|
* 分类名称(模糊查询)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.qingqiu.interview.dto;
|
package com.qingqiu.interview.dto;
|
||||||
|
|
||||||
|
import com.qingqiu.interview.common.dto.PageBaseParams;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
import lombok.experimental.Accessors;
|
import lombok.experimental.Accessors;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.qingqiu.interview.dto;
|
package com.qingqiu.interview.dto;
|
||||||
|
|
||||||
|
import com.qingqiu.interview.common.dto.PageBaseParams;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
import lombok.experimental.Accessors;
|
import lombok.experimental.Accessors;
|
||||||
@@ -7,6 +8,6 @@ import lombok.experimental.Accessors;
|
|||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@Data
|
@Data
|
||||||
@Accessors(chain = true)
|
@Accessors(chain = true)
|
||||||
public class QuestionProgressPageParams extends PageBaseParams{
|
public class QuestionProgressPageParams extends PageBaseParams {
|
||||||
private String questionName;
|
private String questionName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.qingqiu.interview.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.experimental.Accessors;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <h1>
|
||||||
|
* 开始面试请求的数据传输对象
|
||||||
|
* </h1>
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
31
src/main/java/com/qingqiu/interview/dto/SubmitAnswerDTO.java
Normal file
31
src/main/java/com/qingqiu/interview/dto/SubmitAnswerDTO.java
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package com.qingqiu.interview.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.experimental.Accessors;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <h1></h1>
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <h1>
|
||||||
|
* 面试接入AI的接口
|
||||||
|
* </h1>
|
||||||
|
*
|
||||||
|
* @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<String> skills, String resumeContent, int count);
|
||||||
|
|
||||||
|
JSONObject generateQuestionOfLocal(String sessionId, List<Question> questions, List<String> skills, String resumeContent, int count);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评估用户的回答
|
||||||
|
*
|
||||||
|
* @param question 问题内容
|
||||||
|
* @param userAnswer 用户的回答
|
||||||
|
* @param context 可选的上下文(之前的问答历史)
|
||||||
|
* @return 包含评估结果的JSON对象
|
||||||
|
*/
|
||||||
|
JSONObject evaluateAnswer(String sessionId, String question, String userAnswer, List<InterviewQuestionProgress> context);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成最终的面试评估报告
|
||||||
|
*
|
||||||
|
* @param session 面试会话信息
|
||||||
|
* @param progressList 整个面试的问答记录
|
||||||
|
* @return 包含最终报告的JSON对象
|
||||||
|
*/
|
||||||
|
JSONObject generateFinalReport(InterviewSession session, List<InterviewQuestionProgress> progressList);
|
||||||
|
}
|
||||||
595
src/main/java/com/qingqiu/interview/service/InterviewService.java
Executable file → Normal file
595
src/main/java/com/qingqiu/interview/service/InterviewService.java
Executable file → Normal file
@@ -1,579 +1,52 @@
|
|||||||
package com.qingqiu.interview.service;
|
package com.qingqiu.interview.service;
|
||||||
|
|
||||||
import cn.hutool.core.collection.CollectionUtil;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import com.qingqiu.interview.dto.InterviewStartRequest;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.qingqiu.interview.dto.SubmitAnswerDTO;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.qingqiu.interview.entity.InterviewQuestionProgress;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.qingqiu.interview.entity.InterviewSession;
|
||||||
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 org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
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;
|
/**
|
||||||
|
* <h1></h1>
|
||||||
@Slf4j
|
*
|
||||||
@Service
|
* @author qingqiu
|
||||||
@RequiredArgsConstructor
|
* @date 2025/9/19 16:05
|
||||||
public class InterviewService {
|
*/
|
||||||
|
|
||||||
private final LlmService llmService; // Changed to a single service
|
|
||||||
private final List<DocumentParser> 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<String, DocumentParser> 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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
public interface InterviewService extends IService<InterviewSession> {
|
||||||
/**
|
/**
|
||||||
* 开始新的面试会话
|
* 开始一场新的面试
|
||||||
*/
|
|
||||||
@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<Question> 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<Long> 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<InterviewQuestionProgress>()
|
|
||||||
.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<InterviewQuestionProgress>()
|
|
||||||
.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<InterviewQuestionProgress>()
|
|
||||||
.eq(InterviewQuestionProgress::getSessionId, request.getSessionId())
|
|
||||||
.eq(InterviewQuestionProgress::getQuestionId, currentQuestionId)
|
|
||||||
.orderByDesc(InterviewQuestionProgress::getCreatedTime)
|
|
||||||
.last("limit 1")
|
|
||||||
);
|
|
||||||
// 将ai返回的内容拼装返回给页面
|
|
||||||
// 查询数据
|
|
||||||
InterviewQuestionProgress currentQuestionData = questionProgressMapper.selectOne(
|
|
||||||
new LambdaQueryWrapper<InterviewQuestionProgress>()
|
|
||||||
.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<Question> selectQuestionsByAi(String resumeContent, String sessionId) throws JsonProcessingException {
|
|
||||||
// 1. 获取全部题库
|
|
||||||
List<Question> 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<Long> 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<Question> 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 file 简历文件
|
||||||
* @param userAnswer 用户回答
|
* @param dto 开始面试的请求参数
|
||||||
* @return 当前问题id
|
* @return 创建的面试会话
|
||||||
*/
|
*/
|
||||||
private Long evaluateAnswer(InterviewSession session, String userAnswer) {
|
InterviewSession startInterview(MultipartFile file, InterviewStartRequest dto) throws IOException;
|
||||||
// 根据会话id查询当前会话所有问题
|
|
||||||
List<InterviewQuestionProgress> interviewQuestionProgresses = questionProgressMapper.selectList(
|
|
||||||
new LambdaQueryWrapper<InterviewQuestionProgress>()
|
|
||||||
.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<String> 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<InterviewQuestionProgress>()
|
|
||||||
.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<InterviewEvaluation> 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<InterviewEvaluation> 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有面试会话列表
|
* 获取下一个问题
|
||||||
|
*
|
||||||
|
* @param sessionId 会话ID
|
||||||
|
* @return 下一个问题 或 null(如果没有更多问题)
|
||||||
*/
|
*/
|
||||||
public List<InterviewSession> getInterviewSessions() {
|
InterviewQuestionProgress getNextQuestion(String sessionId);
|
||||||
log.info("Fetching all interview sessions");
|
|
||||||
return sessionMapper.selectList(null); // 实际中可能需要分页
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取详细的面试复盘报告
|
* 提交答案并获取AI评估
|
||||||
|
*
|
||||||
|
* @param submitAnswerDTO 提交答案的请求参数
|
||||||
|
* @return 对当前问题的评估和反馈
|
||||||
*/
|
*/
|
||||||
public InterviewReportResponse getInterviewReport(String sessionId) {
|
InterviewQuestionProgress submitAnswer(SubmitAnswerDTO submitAnswerDTO);
|
||||||
log.info("Fetching interview report for session id: {}", sessionId);
|
|
||||||
|
|
||||||
InterviewSession session = sessionMapper.selectBySessionId(sessionId);
|
/**
|
||||||
if (session == null) {
|
* 结束面试并生成最终报告
|
||||||
throw new IllegalArgumentException("找不到ID为 " + sessionId + " 的面试会话。");
|
*
|
||||||
}
|
* @param sessionId 会话ID
|
||||||
|
* @return 包含最终报告的面试会话信息
|
||||||
List<InterviewEvaluation> evaluations = evaluationMapper.selectBySessionId(sessionId);
|
*/
|
||||||
|
InterviewSession endInterview(String sessionId);
|
||||||
List<InterviewReportResponse.QuestionDetail> 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<InterviewMessage> interviewMessages = messageMapper.selectList(
|
|
||||||
new LambdaQueryWrapper<InterviewMessage>()
|
|
||||||
.eq(InterviewMessage::getSessionId, sessionId)
|
|
||||||
);
|
|
||||||
// 获取当前面试的 问题
|
|
||||||
InterviewQuestionProgress progress = questionProgressMapper.selectOne(
|
|
||||||
new LambdaQueryWrapper<InterviewQuestionProgress>()
|
|
||||||
.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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||||||
import com.qingqiu.interview.dto.QuestionOptionsDTO;
|
import com.qingqiu.interview.dto.QuestionOptionsDTO;
|
||||||
import com.qingqiu.interview.dto.QuestionPageParams;
|
import com.qingqiu.interview.dto.QuestionPageParams;
|
||||||
import com.qingqiu.interview.entity.Question;
|
import com.qingqiu.interview.entity.Question;
|
||||||
import com.qingqiu.interview.entity.QuestionCategory;
|
|
||||||
import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
|
import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@@ -27,4 +26,14 @@ public interface QuestionService extends IService<Question> {
|
|||||||
void useAiCheckQuestionData();
|
void useAiCheckQuestionData();
|
||||||
|
|
||||||
List<QuestionAndCategoryTreeListVO> getTreeListCategory(QuestionOptionsDTO dto);
|
List<QuestionAndCategoryTreeListVO> getTreeListCategory(QuestionOptionsDTO dto);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据技能和难度从本地题库随机选择题目
|
||||||
|
*
|
||||||
|
* @param skills 技能列表
|
||||||
|
* @param difficulty 难度
|
||||||
|
* @param count 题目数量
|
||||||
|
* @return 题目列表
|
||||||
|
*/
|
||||||
|
List<Question> selectLocalQuestions(List<String> skills, String difficulty, int count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <h1></h1>
|
||||||
|
*
|
||||||
|
* @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<String> 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<Question> questions, List<String> 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<InterviewQuestionProgress> 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<InterviewQuestionProgress> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <h1></h1>
|
||||||
|
*
|
||||||
|
* @author qingqiu
|
||||||
|
* @date 2025/9/19 16:07
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
|
||||||
|
public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, InterviewSession> 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<String> 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<String> skills) {
|
||||||
|
List<InterviewQuestionProgress> 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<String> skills,
|
||||||
|
List<QuestionAndCategoryTreeListVO> selectedNodes) {
|
||||||
|
List<Question> localQuestionDataList = new ArrayList<>();
|
||||||
|
// 如果用户选择了题目 则使用用户选择的题目 否则直接使用全部的题目
|
||||||
|
if (CollectionUtil.isNotEmpty(selectedNodes)) {
|
||||||
|
List<QuestionAndCategoryTreeListVO> 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<Question>()
|
||||||
|
.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<Long> list = questionIds.toList(Long.class);
|
||||||
|
// 查询返回的内容 并将其保存为问题进度的相关数据
|
||||||
|
List<Question> questionList = questionService.list(
|
||||||
|
new LambdaQueryWrapper<Question>()
|
||||||
|
.in(Question::getId, list)
|
||||||
|
);
|
||||||
|
List<InterviewQuestionProgress> 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<InterviewQuestionProgress> 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<InterviewQuestionProgress> context = progressMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<InterviewQuestionProgress>()
|
||||||
|
.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<InterviewSession>()
|
||||||
|
.eq(InterviewSession::getSessionId, sessionId));
|
||||||
|
if (session == null) throw new RuntimeException("会话不存在");
|
||||||
|
|
||||||
|
List<InterviewQuestionProgress> completedProgresses = progressMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<InterviewQuestionProgress>()
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,10 +31,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.*;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -242,6 +239,7 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
|||||||
log.info("根节点题目总数: {}", i);
|
log.info("根节点题目总数: {}", i);
|
||||||
QuestionAndCategoryTreeListVO rootVO = new QuestionAndCategoryTreeListVO();
|
QuestionAndCategoryTreeListVO rootVO = new QuestionAndCategoryTreeListVO();
|
||||||
rootVO.setId(0L);
|
rootVO.setId(0L);
|
||||||
|
rootVO.setNodeKey(UUID.randomUUID().toString().replace("-", ""));
|
||||||
rootVO.setName("全部题目");
|
rootVO.setName("全部题目");
|
||||||
rootVO.setType("root");
|
rootVO.setType("root");
|
||||||
rootVO.setChildren(voList);
|
rootVO.setChildren(voList);
|
||||||
@@ -252,6 +250,20 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
|||||||
return voList;
|
return voList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Question> selectLocalQuestions(List<String> skills, String difficulty, int count) {
|
||||||
|
// TODO: 实现更智能的选题逻辑,例如:
|
||||||
|
// 1. 根据技能(skills)匹配题目的`tags`或`category_name`。
|
||||||
|
// 2. 使用`difficulty`进行筛选。
|
||||||
|
// 3. 随机选取`count`道题目。
|
||||||
|
// 4. 此处仅为简单示例,随机获取指定数量的题目。
|
||||||
|
|
||||||
|
LambdaQueryWrapper<Question> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.last("ORDER BY RAND() LIMIT " + count);
|
||||||
|
|
||||||
|
return this.baseMapper.selectList(queryWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将QuestionCategory列表转换为QuestionAndCategoryTreeListVO列表,并整合题目数据
|
* 将QuestionCategory列表转换为QuestionAndCategoryTreeListVO列表,并整合题目数据
|
||||||
*
|
*
|
||||||
@@ -302,6 +314,7 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
|||||||
vo.setName(category.getName());
|
vo.setName(category.getName());
|
||||||
vo.setType("category");
|
vo.setType("category");
|
||||||
vo.setCount(0);
|
vo.setCount(0);
|
||||||
|
vo.setNodeKey(UUID.randomUUID().toString().replace("-", ""));
|
||||||
|
|
||||||
// 处理子节点(包括子分类和题目)
|
// 处理子节点(包括子分类和题目)
|
||||||
List<QuestionAndCategoryTreeListVO> childrenVOs = new ArrayList<>();
|
List<QuestionAndCategoryTreeListVO> childrenVOs = new ArrayList<>();
|
||||||
@@ -355,6 +368,7 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
|||||||
vo.setChildren(List.of());
|
vo.setChildren(List.of());
|
||||||
vo.setType("question");
|
vo.setType("question");
|
||||||
vo.setCount(0); // 题目节点没有子节点,count设为0
|
vo.setCount(0); // 题目节点没有子节点,count设为0
|
||||||
|
vo.setNodeKey(UUID.randomUUID().toString().replace("-", ""));
|
||||||
|
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import lombok.experimental.Accessors;
|
|||||||
* @date 2025/9/18 12:56
|
* @date 2025/9/18 12:56
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Accessors
|
@Accessors(chain = true)
|
||||||
@Builder
|
@Builder
|
||||||
public class ChatVO {
|
public class ChatVO {
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public class QuestionAndCategoryTreeListVO implements Serializable {
|
|||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
private String nodeKey;
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
/**
|
/**
|
||||||
* category:分类
|
* category:分类
|
||||||
|
|||||||
Reference in New Issue
Block a user