diff --git a/pom.xml b/pom.xml index 7e2ea8d..891d80f 100755 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,11 @@ org.springframework.boot spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-aop + org.springframework.boot spring-boot-starter-webflux diff --git a/src/main/java/com/qingqiu/interview/ai/factory/AIClientFactory.java b/src/main/java/com/qingqiu/interview/ai/factory/AIClientFactory.java index aea32c2..a7c737a 100755 --- a/src/main/java/com/qingqiu/interview/ai/factory/AIClientFactory.java +++ b/src/main/java/com/qingqiu/interview/ai/factory/AIClientFactory.java @@ -1,6 +1,6 @@ package com.qingqiu.interview.ai.factory; -import com.qingqiu.interview.ai.enums.LLMProvider; +import com.qingqiu.interview.common.enums.LLMProvider; import com.qingqiu.interview.ai.service.AIClientService; public interface AIClientFactory { diff --git a/src/main/java/com/qingqiu/interview/ai/factory/AIClientManager.java b/src/main/java/com/qingqiu/interview/ai/factory/AIClientManager.java index 263c79f..fb79371 100755 --- a/src/main/java/com/qingqiu/interview/ai/factory/AIClientManager.java +++ b/src/main/java/com/qingqiu/interview/ai/factory/AIClientManager.java @@ -1,6 +1,6 @@ package com.qingqiu.interview.ai.factory; -import com.qingqiu.interview.ai.enums.LLMProvider; +import com.qingqiu.interview.common.enums.LLMProvider; import com.qingqiu.interview.ai.service.AIClientService; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/qingqiu/interview/ai/factory/DeepSeekClientFactory.java b/src/main/java/com/qingqiu/interview/ai/factory/DeepSeekClientFactory.java index 73ffce2..c60d5e4 100755 --- a/src/main/java/com/qingqiu/interview/ai/factory/DeepSeekClientFactory.java +++ b/src/main/java/com/qingqiu/interview/ai/factory/DeepSeekClientFactory.java @@ -1,6 +1,6 @@ package com.qingqiu.interview.ai.factory; -import com.qingqiu.interview.ai.enums.LLMProvider; +import com.qingqiu.interview.common.enums.LLMProvider; import com.qingqiu.interview.ai.service.AIClientService; import com.qingqiu.interview.ai.service.impl.DeepSeekClientServiceImpl; import com.qingqiu.interview.common.utils.SpringApplicationContextUtil; diff --git a/src/main/java/com/qingqiu/interview/ai/factory/QwenClientFactory.java b/src/main/java/com/qingqiu/interview/ai/factory/QwenClientFactory.java index 6d6a5d8..aeca885 100755 --- a/src/main/java/com/qingqiu/interview/ai/factory/QwenClientFactory.java +++ b/src/main/java/com/qingqiu/interview/ai/factory/QwenClientFactory.java @@ -1,6 +1,6 @@ package com.qingqiu.interview.ai.factory; -import com.qingqiu.interview.ai.enums.LLMProvider; +import com.qingqiu.interview.common.enums.LLMProvider; import com.qingqiu.interview.ai.service.AIClientService; import com.qingqiu.interview.ai.service.impl.QwenClientServiceImpl; import com.qingqiu.interview.common.utils.SpringApplicationContextUtil; diff --git a/src/main/java/com/qingqiu/interview/annotation/AiChatLog.java b/src/main/java/com/qingqiu/interview/annotation/AiChatLog.java new file mode 100644 index 0000000..fb32e51 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/annotation/AiChatLog.java @@ -0,0 +1,15 @@ +package com.qingqiu.interview.annotation; + +import java.lang.annotation.*; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 12:58 + */ +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface AiChatLog { +} diff --git a/src/main/java/com/qingqiu/interview/aspect/AiChatLogAspect.java b/src/main/java/com/qingqiu/interview/aspect/AiChatLogAspect.java new file mode 100644 index 0000000..13451ed --- /dev/null +++ b/src/main/java/com/qingqiu/interview/aspect/AiChatLogAspect.java @@ -0,0 +1,34 @@ +package com.qingqiu.interview.aspect; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.stereotype.Component; + +/** + *

+ * ai聊天的切面 + *

+ * + * @author qingqiu + * @date 2025/9/18 13:00 + */ +@Aspect +@Component +public class AiChatLogAspect { + + public AiChatLogAspect() { + + } + + @Pointcut("@annotation(com.qingqiu.interview.annotation.AiChatLog)") + public void logPointCut() { + } + + @Around("logPointCut()") + public Object around(ProceedingJoinPoint point) throws Throwable { + Object result = point.proceed(); + return result; + } +} diff --git a/src/main/java/com/qingqiu/interview/common/constants/CommonConstant.java b/src/main/java/com/qingqiu/interview/common/constants/CommonConstant.java index 5fc459b..b2a9316 100755 --- a/src/main/java/com/qingqiu/interview/common/constants/CommonConstant.java +++ b/src/main/java/com/qingqiu/interview/common/constants/CommonConstant.java @@ -1,5 +1,7 @@ package com.qingqiu.interview.common.constants; +import java.math.BigDecimal; + /** *

公共常量

* @author huangpeng @@ -10,4 +12,6 @@ public class CommonConstant { public static final Integer ZERO = 0; public static final Integer ONE = 1; public static final Long ROOT_PARENT_ID = 0L; + public static final Integer MAX_TOKEN = 64000; + public static final BigDecimal DEFAULT_TRUNCATE_RATIO = new BigDecimal("0.1"); } diff --git a/src/main/java/com/qingqiu/interview/common/enums/DocumentParserProvider.java b/src/main/java/com/qingqiu/interview/common/enums/DocumentParserProvider.java new file mode 100644 index 0000000..18d6f6a --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/enums/DocumentParserProvider.java @@ -0,0 +1,33 @@ +package com.qingqiu.interview.common.enums; + +import lombok.Getter; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 16:43 + */ +@Getter +public enum DocumentParserProvider { + + PDF("pdf"), + MARKDOWN("md"), + + ; + + private final String code; + + DocumentParserProvider(String code) { + this.code = code; + } + + public static DocumentParserProvider fromCode(String code) { + for (DocumentParserProvider provider : values()) { + if (provider.getCode().equals(code)) { + return provider; + } + } + throw new IllegalArgumentException("Unknown provider: " + code); + } +} diff --git a/src/main/java/com/qingqiu/interview/ai/enums/LLMProvider.java b/src/main/java/com/qingqiu/interview/common/enums/LLMProvider.java similarity index 85% rename from src/main/java/com/qingqiu/interview/ai/enums/LLMProvider.java rename to src/main/java/com/qingqiu/interview/common/enums/LLMProvider.java index 82aaa77..17379f4 100644 --- a/src/main/java/com/qingqiu/interview/ai/enums/LLMProvider.java +++ b/src/main/java/com/qingqiu/interview/common/enums/LLMProvider.java @@ -1,5 +1,8 @@ -package com.qingqiu.interview.ai.enums; +package com.qingqiu.interview.common.enums; +import lombok.Getter; + +@Getter public enum LLMProvider { OPEN_AI("openai"), @@ -16,10 +19,6 @@ public enum LLMProvider { this.code = code; } - public String getCode() { - return code; - } - public static LLMProvider fromCode(String code) { for (LLMProvider provider : values()) { if (provider.getCode().equals(code)) { diff --git a/src/main/java/com/qingqiu/interview/common/utils/AIUtils.java b/src/main/java/com/qingqiu/interview/common/utils/AIUtils.java index 35b8e66..d59b1f7 100755 --- a/src/main/java/com/qingqiu/interview/common/utils/AIUtils.java +++ b/src/main/java/com/qingqiu/interview/common/utils/AIUtils.java @@ -2,6 +2,10 @@ package com.qingqiu.interview.common.utils; import com.alibaba.dashscope.common.Message; import com.alibaba.dashscope.common.Role; +import com.alibaba.dashscope.tokenizers.Tokenizer; +import com.alibaba.dashscope.tokenizers.TokenizerFactory; + +import java.util.List; public class AIUtils { @@ -23,4 +27,15 @@ public class AIUtils { public static Message createSystemMessage(String prompt) { return createMessage(Role.SYSTEM.getValue(), prompt); } + + /** + * 获取prompt的token数 + * @param prompt 输入 + * @return tokens + */ + public static Integer getPromptTokens(String prompt) { + Tokenizer tokenizer = TokenizerFactory.qwen(); + List integers = tokenizer.encodeOrdinary(prompt); + return integers.size(); + } } diff --git a/src/main/java/com/qingqiu/interview/controller/ChatController.java b/src/main/java/com/qingqiu/interview/controller/ChatController.java new file mode 100644 index 0000000..bf00c50 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/controller/ChatController.java @@ -0,0 +1,36 @@ +package com.qingqiu.interview.controller; + +import com.qingqiu.interview.common.res.R; +import com.qingqiu.interview.dto.ChatDTO; +import com.qingqiu.interview.dto.InterviewStartRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + *

AI聊天控制器

+ * + * @author qingqiu + * @date 2025/9/18 12:11 + */ +@RestController +@RequestMapping("/chat") +@RequiredArgsConstructor +public class ChatController { + + /** + * 创建聊天 + * @return + */ + @PostMapping("/send") + public R createChat(@RequestBody ChatDTO dto) { + return R.success(); + } + + @PostMapping("/interview/create") + public R createInterview(@RequestParam("resume") MultipartFile resume, + @Validated @ModelAttribute InterviewStartRequest request) { + return R.success(); + } +} diff --git a/src/main/java/com/qingqiu/interview/dto/ChatDTO.java b/src/main/java/com/qingqiu/interview/dto/ChatDTO.java new file mode 100644 index 0000000..c12e04f --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/ChatDTO.java @@ -0,0 +1,26 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 12:54 + */ +@Data +@Accessors(chain = true) +public class ChatDTO { + + /** 会话id */ + private String sessionId; + /** 调用模型 */ + private String aiModel; + /** 输入内容 */ + private String content; + /** 0 普通会话 1 面试会话 */ + private Integer dataType; + /** 角色类型:user/assistant/system */ + private String role; +} diff --git a/src/main/java/com/qingqiu/interview/entity/AiSessionLog.java b/src/main/java/com/qingqiu/interview/entity/AiSessionLog.java index 9de8909..ee6b34d 100755 --- a/src/main/java/com/qingqiu/interview/entity/AiSessionLog.java +++ b/src/main/java/com/qingqiu/interview/entity/AiSessionLog.java @@ -5,6 +5,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -22,6 +23,7 @@ import java.time.LocalDateTime; @TableName("ai_session_log") public class AiSessionLog implements Serializable { + @Serial private static final long serialVersionUID = 1L; @TableId(value = "id", type = IdType.AUTO) @@ -32,6 +34,11 @@ public class AiSessionLog implements Serializable { */ private String role; + /** + * 数据类型 0 普通会话 1 面试会话 + */ + private Integer dataType; + /** * 输入内容 */ @@ -54,5 +61,8 @@ public class AiSessionLog implements Serializable { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedTime; + @TableLogic + private Integer deleted; + } diff --git a/src/main/java/com/qingqiu/interview/entity/InterviewQuestionProgress.java b/src/main/java/com/qingqiu/interview/entity/InterviewQuestionProgress.java index 8fa8358..6c9a382 100755 --- a/src/main/java/com/qingqiu/interview/entity/InterviewQuestionProgress.java +++ b/src/main/java/com/qingqiu/interview/entity/InterviewQuestionProgress.java @@ -33,6 +33,15 @@ public class InterviewQuestionProgress { @TableField("question_content") private String questionContent; + /** 问题序号 */ + private Integer questionIndex; + + /** 答题耗时(秒) */ + private Long timeTaken; + + /** 详细评估信息 */ + private String evaluationDetails; + /** * 面试会话ID */ diff --git a/src/main/java/com/qingqiu/interview/entity/InterviewSession.java b/src/main/java/com/qingqiu/interview/entity/InterviewSession.java index d7db6f0..c5a623f 100755 --- a/src/main/java/com/qingqiu/interview/entity/InterviewSession.java +++ b/src/main/java/com/qingqiu/interview/entity/InterviewSession.java @@ -33,6 +33,14 @@ public class InterviewSession implements Serializable { @TableField("extracted_skills") private String extractedSkills; + @TableField("interview_type") + private String interviewType; + + @TableField("estimated_duration") + private Integer estimatedDuration; + + @TableField("current_question_id") + private Long currentQuestionId; @TableField("ai_model") private String aiModel; diff --git a/src/main/java/com/qingqiu/interview/service/ChatService.java b/src/main/java/com/qingqiu/interview/service/ChatService.java new file mode 100644 index 0000000..90d7241 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/ChatService.java @@ -0,0 +1,29 @@ +package com.qingqiu.interview.service; + +import com.qingqiu.interview.dto.ChatDTO; +import com.qingqiu.interview.dto.InterviewStartRequest; +import com.qingqiu.interview.vo.ChatVO; +import org.springframework.web.multipart.MultipartFile; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 12:45 + */ +public interface ChatService { + + /** + * 创建普通会话 + * @return sessionId + */ + ChatVO createChat(ChatDTO dto); + + /** + * 创建面试会话 + * @param resume 简历 + * @param request 面试信息 + * @return sessionId + */ + String createInterviewChat(MultipartFile resume, InterviewStartRequest request); +} diff --git a/src/main/java/com/qingqiu/interview/service/InterviewChatService.java b/src/main/java/com/qingqiu/interview/service/InterviewChatService.java new file mode 100644 index 0000000..b57f420 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/InterviewChatService.java @@ -0,0 +1,17 @@ +package com.qingqiu.interview.service; + +import com.qingqiu.interview.dto.InterviewStartRequest; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 16:37 + */ +public interface InterviewChatService { + + void startInterview(MultipartFile resume, InterviewStartRequest request) throws IOException; +} diff --git a/src/main/java/com/qingqiu/interview/service/InterviewService.java b/src/main/java/com/qingqiu/interview/service/InterviewService.java index f81beec..2dcfb1b 100755 --- a/src/main/java/com/qingqiu/interview/service/InterviewService.java +++ b/src/main/java/com/qingqiu/interview/service/InterviewService.java @@ -63,6 +63,12 @@ public class InterviewService { // 1. 解析简历 String resumeContent = parseResume(resume); + // 判断是否AI出题 + if (request.getModel().equals("local")) { + if (CollectionUtil.isEmpty(request.getSelectedNodes())) { + + } + } // 2. 创建会话 并发送AI请求 让其从题库中智能抽题 @@ -212,40 +218,8 @@ public class InterviewService { } - /** - * 导入题库(使用AI自动分类) - */ - /** - * 获取会话历史 - */ - public SessionHistoryResponse getSessionHistory(String sessionId) { - InterviewSession session = sessionMapper.selectBySessionId(sessionId); - if (session == null) { - throw new IllegalArgumentException("会话不存在: " + sessionId); - } - - List messages = messageMapper.selectBySessionIdOrderByOrder(sessionId); - List messageDtos = messages.stream() - .map(msg -> new SessionHistoryResponse.MessageDto() - .setMessageType(msg.getMessageType()) - .setSender(msg.getSender()) - .setContent(msg.getContent()) - .setMessageOrder(msg.getMessageOrder()) - .setCreatedTime(msg.getCreatedTime())) - .collect(Collectors.toList()); - - return new SessionHistoryResponse() - .setSessionId(sessionId) - .setCandidateName(session.getCandidateName()) - .setAiModel(session.getAiModel()) - .setStatus(session.getStatus()) - .setTotalQuestions(session.getTotalQuestions()) - .setCurrentQuestionIndex(session.getCurrentQuestionIndex()) - .setCreatedTime(session.getCreatedTime()) - .setMessages(messageDtos); - } private String parseResume(MultipartFile resume) throws IOException { String fileExtension = getFileExtension(resume.getOriginalFilename()); @@ -539,55 +513,6 @@ public class InterviewService { """, session.getResumeContent(), historyBuilder.toString()); } - private InterviewResponse generateNextQuestion(InterviewSession session) { - try { - // 1. 解析出AI选择的题目ID列表 - List selectedQuestionIds = objectMapper.readValue(session.getSelectedQuestionIds(), new com.fasterxml.jackson.core.type.TypeReference>() { - }); - - // 2. 获取下一个问题的索引 - int nextQuestionIndex = session.getCurrentQuestionIndex(); // 数据库中存的是已回答问题的数量 - if (nextQuestionIndex >= selectedQuestionIds.size()) { - return finishInterview(session); // 如果没有更多问题,则结束面试 - } - - // 3. 获取下一个问题的ID并从数据库查询 - Long nextQuestionId = selectedQuestionIds.get(nextQuestionIndex); - Question nextQuestion = questionMapper.selectById(nextQuestionId); - if (nextQuestion == null) { - log.error("无法找到ID为 {} 的问题,跳过此问题。", nextQuestionId); - // 更新会话状态并尝试下一个问题 - session.setCurrentQuestionIndex(nextQuestionIndex + 1); - sessionMapper.updateById(session); - return generateNextQuestion(session); // 递归调用以获取再下一个问题 - } - - // 4. 更新会话状态(当前问题索引+1) - session.setCurrentQuestionIndex(nextQuestionIndex + 1); - sessionMapper.updateById(session); - - // 5. 生成并保存AI的提问消息 - String questionContent = String.format("好的,下一个问题是:%s", nextQuestion.getContent()); - int messageOrder = messageMapper.selectMaxOrderBySessionId(session.getSessionId()) + 1; - saveMessage(session.getSessionId(), InterviewMessage.MessageType.QUESTION.name(), - InterviewMessage.Sender.AI.name(), questionContent, nextQuestion.getId(), messageOrder); - - // 6. 返回响应 - return new InterviewResponse() - .setSessionId(session.getSessionId()) - .setMessage(questionContent) - .setMessageType(InterviewMessage.MessageType.QUESTION.name()) - .setSender(InterviewMessage.Sender.AI.name()) - .setCurrentQuestionIndex(session.getCurrentQuestionIndex()) - .setTotalQuestions(session.getTotalQuestions()) - .setStatus(InterviewSession.Status.ACTIVE.name()); - - } catch (JsonProcessingException e) { - log.error("解析会话中的题目ID列表失败", e); - return finishInterview(session); // 解析失败则直接结束面试 - } - } - /** * 获取所有面试会话列表 diff --git a/src/main/java/com/qingqiu/interview/service/impl/ChatServiceImpl.java b/src/main/java/com/qingqiu/interview/service/impl/ChatServiceImpl.java new file mode 100644 index 0000000..5ee2841 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/impl/ChatServiceImpl.java @@ -0,0 +1,96 @@ +package com.qingqiu.interview.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import com.alibaba.dashscope.common.Message; +import com.alibaba.dashscope.common.Role; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.qingqiu.interview.common.enums.LLMProvider; +import com.qingqiu.interview.ai.factory.AIClientManager; +import com.qingqiu.interview.common.utils.AIUtils; +import com.qingqiu.interview.dto.ChatDTO; +import com.qingqiu.interview.dto.InterviewStartRequest; +import com.qingqiu.interview.entity.AiSessionLog; +import com.qingqiu.interview.service.ChatService; +import com.qingqiu.interview.service.IAiSessionLogService; +import com.qingqiu.interview.vo.ChatVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.qingqiu.interview.common.constants.CommonConstant.DEFAULT_TRUNCATE_RATIO; +import static com.qingqiu.interview.common.constants.CommonConstant.MAX_TOKEN; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 12:56 + */ + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatServiceImpl implements ChatService { + + private final AIClientManager aiClientManager; + + private IAiSessionLogService aiSessionLogService; + + @Override + public ChatVO createChat(ChatDTO dto) { + LLMProvider llmProvider = LLMProvider.fromCode(dto.getAiModel()); + List messages = new ArrayList<>(); + AtomicInteger tokens = new AtomicInteger(); + // 如果会话id不为空 则从数据库中获取会话记录 + if (dto.getSessionId() != null) { + List list = aiSessionLogService.list( + new LambdaQueryWrapper() + .eq(AiSessionLog::getToken, dto.getSessionId()) + .eq(AiSessionLog::getDataType, dto.getDataType()) + .orderByAsc(AiSessionLog::getCreatedTime) + ); + if (CollectionUtil.isNotEmpty(list)) { + messages = list.stream().map(data -> { + tokens.getAndAdd(AIUtils.getPromptTokens(data.getContent())); + return AIUtils.createMessage(data.getRole(), data.getContent()); + }).toList(); + } + + } + if (CollectionUtil.isEmpty( messages)) { + messages = new ArrayList<>(); + } + messages.add(AIUtils.createMessage(dto.getRole(), dto.getContent())); + List finalMessage = new ArrayList<>(); + // 剪切 10%的消息 + if (tokens.get() > MAX_TOKEN) { + BigDecimal size = new BigDecimal(String.valueOf(messages.size())); + size = size.multiply(DEFAULT_TRUNCATE_RATIO).setScale(0, RoundingMode.HALF_UP); + for (int i = size.intValue(); i < messages.size(); i++) { + finalMessage.add(messages.get(i)); + } + } else { + finalMessage = messages; + } + String res = aiClientManager.getClient(llmProvider).chatCompletion(finalMessage); + + + return ChatVO.builder() + .role(Role.ASSISTANT.getValue()) + .sessionId(dto.getSessionId()) + .content(res) + .build(); + } + + @Override + public String createInterviewChat(MultipartFile resume, InterviewStartRequest request) { + return ""; + } +} diff --git a/src/main/java/com/qingqiu/interview/service/impl/InterviewChatServiceImpl.java b/src/main/java/com/qingqiu/interview/service/impl/InterviewChatServiceImpl.java new file mode 100644 index 0000000..7363cd1 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/impl/InterviewChatServiceImpl.java @@ -0,0 +1,50 @@ +package com.qingqiu.interview.service.impl; + +import cn.hutool.core.io.file.FileNameUtil; +import com.qingqiu.interview.common.constants.AIStrategyConstant; +import com.qingqiu.interview.common.enums.DocumentParserProvider; +import com.qingqiu.interview.dto.InterviewStartRequest; +import com.qingqiu.interview.service.InterviewChatService; +import com.qingqiu.interview.service.parser.DocumentParser; +import com.qingqiu.interview.service.parser.DocumentParserManager; +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.web.multipart.MultipartFile; + +import java.io.IOException; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 16:38 + */ +@Slf4j +@Service +@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy}) +public class InterviewChatServiceImpl implements InterviewChatService { + + private final DocumentParserManager documentParserManager; + @Override + public void startInterview(MultipartFile resume, InterviewStartRequest request) throws IOException { + log.info("开始新面试会话,当前模式: {}, 候选人: {}, 默认AI模型: {}", request.getModel(), request.getCandidateName(), AIStrategyConstant.QWEN); + // 1. 解析简历 + String resumeContent = parseResume(resume); + // 判断是否使用本地题库 + if (request.getModel().equals("local")) { + + } + } + + 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 69241be..e645539 100644 --- a/src/main/java/com/qingqiu/interview/service/impl/QuestionServiceImpl.java +++ b/src/main/java/com/qingqiu/interview/service/impl/QuestionServiceImpl.java @@ -6,7 +6,7 @@ import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.qingqiu.interview.ai.enums.LLMProvider; +import com.qingqiu.interview.common.enums.LLMProvider; import com.qingqiu.interview.common.constants.CommonConstant; import com.qingqiu.interview.common.utils.TreeUtil; import com.qingqiu.interview.dto.QuestionOptionsDTO; diff --git a/src/main/java/com/qingqiu/interview/service/impl/parser/DocumentParserManagerImpl.java b/src/main/java/com/qingqiu/interview/service/impl/parser/DocumentParserManagerImpl.java new file mode 100644 index 0000000..5527b7a --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/impl/parser/DocumentParserManagerImpl.java @@ -0,0 +1,40 @@ +package com.qingqiu.interview.service.impl.parser; + +import com.qingqiu.interview.common.enums.DocumentParserProvider; +import com.qingqiu.interview.service.parser.DocumentParser; +import com.qingqiu.interview.service.parser.DocumentParserManager; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 16:45 + */ +@Service +public class DocumentParserManagerImpl implements DocumentParserManager { + private final Map factories; + + public DocumentParserManagerImpl(List strategies) { + this.factories = strategies.stream() + .collect(Collectors.toMap( + DocumentParser::getSupportedProvider, + Function.identity() + )); + } + + + @Override + public DocumentParser getParser(DocumentParserProvider provider) { + DocumentParser parser = factories.get(provider); + if (parser == null) { + throw new IllegalArgumentException("不支持的AI type: " + provider); + } + return parser; + } +} diff --git a/src/main/java/com/qingqiu/interview/service/parser/MarkdownParserService.java b/src/main/java/com/qingqiu/interview/service/impl/parser/MarkdownParserServiceImpl.java similarity index 69% rename from src/main/java/com/qingqiu/interview/service/parser/MarkdownParserService.java rename to src/main/java/com/qingqiu/interview/service/impl/parser/MarkdownParserServiceImpl.java index e917acf..b855d18 100755 --- a/src/main/java/com/qingqiu/interview/service/parser/MarkdownParserService.java +++ b/src/main/java/com/qingqiu/interview/service/impl/parser/MarkdownParserServiceImpl.java @@ -1,32 +1,39 @@ -package com.qingqiu.interview.service.parser; - -import org.commonmark.node.Node; -import org.commonmark.parser.Parser; -import org.commonmark.renderer.text.TextContentRenderer; -import org.springframework.stereotype.Service; - -import java.io.InputStream; -import java.io.InputStreamReader; - -@Service("mdParser") -public class MarkdownParserService implements DocumentParser { - - private final Parser parser = Parser.builder().build(); - private final TextContentRenderer renderer = TextContentRenderer.builder().build(); - - @Override - public String parse(InputStream inputStream) { - try (InputStreamReader reader = new InputStreamReader(inputStream)) { - Node document = parser.parseReader(reader); - return renderer.render(document); - } catch (Exception e) { - throw new RuntimeException("Failed to parse Markdown document", e); - } - } - - @Override - public String getSupportedType() { - return "md"; - } -} - +package com.qingqiu.interview.service.impl.parser; + +import com.qingqiu.interview.common.enums.DocumentParserProvider; +import com.qingqiu.interview.service.parser.DocumentParser; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.text.TextContentRenderer; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.io.InputStreamReader; + +@Service("mdParser") +public class MarkdownParserServiceImpl implements DocumentParser { + + private final Parser parser = Parser.builder().build(); + private final TextContentRenderer renderer = TextContentRenderer.builder().build(); + + @Override + public String parse(InputStream inputStream) { + try (InputStreamReader reader = new InputStreamReader(inputStream)) { + Node document = parser.parseReader(reader); + return renderer.render(document); + } catch (Exception e) { + throw new RuntimeException("Failed to parse Markdown document", e); + } + } + + @Override + public String getSupportedType() { + return "md"; + } + + @Override + public DocumentParserProvider getSupportedProvider() { + return DocumentParserProvider.MARKDOWN; + } +} + diff --git a/src/main/java/com/qingqiu/interview/service/parser/PdfParserService.java b/src/main/java/com/qingqiu/interview/service/impl/parser/PdfParserServiceImpl.java similarity index 82% rename from src/main/java/com/qingqiu/interview/service/parser/PdfParserService.java rename to src/main/java/com/qingqiu/interview/service/impl/parser/PdfParserServiceImpl.java index 9f1b022..57ea707 100755 --- a/src/main/java/com/qingqiu/interview/service/parser/PdfParserService.java +++ b/src/main/java/com/qingqiu/interview/service/impl/parser/PdfParserServiceImpl.java @@ -1,57 +1,62 @@ -package com.qingqiu.interview.service.parser; - - -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.cos.COSDocument; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.fdf.FDFDocument; -import org.apache.pdfbox.text.PDFTextStripper; -import org.springframework.stereotype.Service; - -import java.io.InputStream; -import java.util.Objects; - -@Service("pdfParser") -public class PdfParserService implements DocumentParser { - - /** - * 解析 PDF 文档内容 - * @param inputStream PDF 文件输入流 - * @return 提取的文本内容 - */ - @Override - public String parse(InputStream inputStream) { - // 检查输入流是否为 null,避免空指针异常 - Objects.requireNonNull(inputStream, "PDF文件输入流不能为空"); - - // 使用 try-with-resources 确保 PDDocument 资源自动关闭 - - try (PDDocument document = Loader.loadPDF(inputStream.readAllBytes())) { - - // 创建 PDF 文本提取器 - PDFTextStripper pdfStripper = new PDFTextStripper(); - - // 配置提取参数 - pdfStripper.setSortByPosition(true); // 按位置排序,保持原始布局 - pdfStripper.setAddMoreFormatting(true); // 保留更多格式信息 - pdfStripper.setSuppressDuplicateOverlappingText(true); // 去除重复文本 - - // 执行文本提取并返回结果 - return pdfStripper.getText(document); - - } catch (Exception e) { - // 处理其他未知异常 - throw new RuntimeException("解析PDF时发生未知错误", e); - } - } - - /** - * 获取该解析器支持的文档类型 - * @return 支持的文档类型标识(此处为"pdf") - */ - @Override - public String getSupportedType() { - return "pdf"; // 返回支持的文档类型 - } -} - +package com.qingqiu.interview.service.impl.parser; + + +import com.qingqiu.interview.common.enums.DocumentParserProvider; +import com.qingqiu.interview.service.parser.DocumentParser; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.util.Objects; + +@Service("pdfParser") +public class PdfParserServiceImpl implements DocumentParser { + + /** + * 解析 PDF 文档内容 + * @param inputStream PDF 文件输入流 + * @return 提取的文本内容 + */ + @Override + public String parse(InputStream inputStream) { + // 检查输入流是否为 null,避免空指针异常 + Objects.requireNonNull(inputStream, "PDF文件输入流不能为空"); + + // 使用 try-with-resources 确保 PDDocument 资源自动关闭 + + try (PDDocument document = Loader.loadPDF(inputStream.readAllBytes())) { + + // 创建 PDF 文本提取器 + PDFTextStripper pdfStripper = new PDFTextStripper(); + + // 配置提取参数 + pdfStripper.setSortByPosition(true); // 按位置排序,保持原始布局 + pdfStripper.setAddMoreFormatting(true); // 保留更多格式信息 + pdfStripper.setSuppressDuplicateOverlappingText(true); // 去除重复文本 + + // 执行文本提取并返回结果 + return pdfStripper.getText(document); + + } catch (Exception e) { + // 处理其他未知异常 + throw new RuntimeException("解析PDF时发生未知错误", e); + } + } + + /** + * 获取该解析器支持的文档类型 + * @return 支持的文档类型标识(此处为"pdf") + */ + @Override + public String getSupportedType() { + return "pdf"; // 返回支持的文档类型 + } + + @Override + public DocumentParserProvider getSupportedProvider() { + return DocumentParserProvider.PDF; + } +} + diff --git a/src/main/java/com/qingqiu/interview/service/llm/LlmService.java b/src/main/java/com/qingqiu/interview/service/llm/LlmService.java index 21cf61a..afa630d 100755 --- a/src/main/java/com/qingqiu/interview/service/llm/LlmService.java +++ b/src/main/java/com/qingqiu/interview/service/llm/LlmService.java @@ -1,6 +1,6 @@ package com.qingqiu.interview.service.llm; -import com.qingqiu.interview.ai.enums.LLMProvider; +import com.qingqiu.interview.common.enums.LLMProvider; public interface LlmService { diff --git a/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java b/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java index a9cf224..a7a6ef2 100755 --- a/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java +++ b/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java @@ -7,7 +7,7 @@ import com.alibaba.dashscope.common.Role; import com.alibaba.dashscope.tokenizers.Tokenizer; import com.alibaba.dashscope.tokenizers.TokenizerFactory; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.qingqiu.interview.ai.enums.LLMProvider; +import com.qingqiu.interview.common.enums.LLMProvider; import com.qingqiu.interview.ai.factory.AIClientManager; import com.qingqiu.interview.entity.AiSessionLog; import com.qingqiu.interview.mapper.AiSessionLogMapper; diff --git a/src/main/java/com/qingqiu/interview/service/parser/DocumentParser.java b/src/main/java/com/qingqiu/interview/service/parser/DocumentParser.java index c47d5f8..a26be4f 100755 --- a/src/main/java/com/qingqiu/interview/service/parser/DocumentParser.java +++ b/src/main/java/com/qingqiu/interview/service/parser/DocumentParser.java @@ -1,5 +1,7 @@ package com.qingqiu.interview.service.parser; +import com.qingqiu.interview.common.enums.DocumentParserProvider; + import java.io.InputStream; public interface DocumentParser { @@ -15,5 +17,7 @@ public interface DocumentParser { * @return "pdf", "md", etc. */ String getSupportedType(); + + DocumentParserProvider getSupportedProvider(); } diff --git a/src/main/java/com/qingqiu/interview/service/parser/DocumentParserManager.java b/src/main/java/com/qingqiu/interview/service/parser/DocumentParserManager.java new file mode 100644 index 0000000..f962a19 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/parser/DocumentParserManager.java @@ -0,0 +1,14 @@ +package com.qingqiu.interview.service.parser; + +import com.qingqiu.interview.common.enums.DocumentParserProvider; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 16:42 + */ +public interface DocumentParserManager { + + DocumentParser getParser(DocumentParserProvider provider); +} diff --git a/src/main/java/com/qingqiu/interview/vo/ChatVO.java b/src/main/java/com/qingqiu/interview/vo/ChatVO.java new file mode 100644 index 0000000..1d97b1e --- /dev/null +++ b/src/main/java/com/qingqiu/interview/vo/ChatVO.java @@ -0,0 +1,22 @@ +package com.qingqiu.interview.vo; + +import lombok.Builder; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + *

+ * + * @author qingqiu + * @date 2025/9/18 12:56 + */ +@Data +@Accessors +@Builder +public class ChatVO { + + private String sessionId; + private String content; + /** 角色 */ + private String role; +}