使用spring-ai-alibaba重构项目

This commit is contained in:
2025-11-24 13:37:37 +08:00
parent 5fb4ed754c
commit 80dcb23bbc
21 changed files with 514 additions and 450 deletions

View File

@@ -13,6 +13,7 @@
<version>0.0.1-SNAPSHOT</version>
<name>AI-Interview</name>
<description>AI-Interview</description>
<!-- TODO: 考虑删除空的元数据元素 -->
<url/>
<licenses>
<license/>
@@ -56,6 +57,7 @@
<artifactId>spring-boot-starter-webflux</artifactId> <!-- 用于 WebClient -->
</dependency>
<!-- TODO: 考虑删除已注释的依赖 -->
<!-- <dependency>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-openai-spring-boot-starter</artifactId>-->
@@ -72,6 +74,7 @@
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<!-- TODO: 检查内存相关依赖是否都需要 -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory</artifactId>
@@ -100,7 +103,7 @@
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- TODO: 考虑删除已注释的依赖 -->
<!-- <dependency>-->
<!-- <groupId>com.alibaba</groupId>-->
<!-- <artifactId>dashscope-sdk-java</artifactId>-->
@@ -198,11 +201,13 @@
<build>
<finalName>ai-interview</finalName>
<plugins>
<!-- TODO: 检查Spring Boot插件版本是否与父POM一致 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.5.0</version>
</plugin>
<!-- TODO: 考虑在正式环境中启用测试 -->
<!-- maven 打包时跳过测试 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>

View File

@@ -1,15 +0,0 @@
package com.qingqiu.interview.annotation;
import java.lang.annotation.*;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/18 12:58
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AiChatLog {
}

View File

@@ -1,71 +0,0 @@
package com.qingqiu.interview.aspect;
import com.qingqiu.interview.dto.ChatDTO;
import com.qingqiu.interview.entity.AiSessionLog;
import com.qingqiu.interview.service.IAiSessionLogService;
import com.qingqiu.interview.vo.ChatVO;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
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;
import org.springframework.transaction.annotation.Transactional;
/**
* <h1>
* ai聊天的切面
* </h1>
*
* @author qingqiu
* @date 2025/9/18 13:00
*/
@Aspect
@Component
public class AiChatLogAspect {
@Resource
private IAiSessionLogService aiSessionLogService;
public AiChatLogAspect() {
}
@Pointcut("@annotation(com.qingqiu.interview.annotation.AiChatLog)")
public void logPointCut() {
}
@Around("logPointCut()")
@Transactional(rollbackFor = Exception.class)
public Object around(ProceedingJoinPoint point) throws Throwable {
Object[] args = point.getArgs();
ChatDTO arg = (ChatDTO) args[0];
if (StringUtils.isNoneBlank(arg.getSessionId())) {
AiSessionLog userSessionLog = new AiSessionLog();
userSessionLog
.setRole(arg.getRole())
.setDataType(arg.getDataType())
.setContent(arg.getContent())
.setToken(arg.getSessionId())
;
aiSessionLogService.save(userSessionLog);
}
Object result = point.proceed();
ChatVO chatVO = (ChatVO) result;
if (StringUtils.isNotBlank(chatVO.getSessionId())) {
AiSessionLog aiSessionLog = new AiSessionLog();
aiSessionLog
.setRole(chatVO.getRole())
.setContent(chatVO.getContent())
.setToken(chatVO.getSessionId())
;
aiSessionLogService.save(aiSessionLog);
}
return result;
}
}

View File

@@ -6,12 +6,12 @@ import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class SpringApplicationContextUtil implements ApplicationContextAware {
public class SpringApplicationContextUtils implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringApplicationContextUtil.applicationContext = applicationContext;
SpringApplicationContextUtils.applicationContext = applicationContext;
}
public static <T> T getBean(Class<T> beanClass) {

View File

@@ -7,7 +7,7 @@ import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
public class TreeUtil {
public class TreeUtils {
/**
* 通用树形结构构建方法

View File

@@ -0,0 +1,46 @@
package com.qingqiu.interview.common.utils;
import io.netty.channel.ChannelOption;
import org.springframework.http.client.ReactorClientHttpRequestFactory;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
/**
* @program: ai-interview
* @description:
* @author: huangpeng
* @create: 2025-11-06 20:04
**/
public class WebClientUtils {
/**
* 创建全局默认的WebClient Bean实例
* 配置了连接超时和响应超时时间
*
* @return WebClient实例
*/
public static WebClient.Builder getWebClientBuilder() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 30000) // 连接超时
.responseTimeout(Duration.ofMillis(30000)); // 读取超时
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
;
}
public static RestClient.Builder getRestClientBuilder() {
return RestClient.builder()
.requestFactory(
new ReactorClientHttpRequestFactory(
HttpClient.create()
.responseTimeout(Duration.ofMinutes(30))
)
);
}
}

View File

@@ -5,28 +5,19 @@ import com.alibaba.cloud.ai.dashscope.api.DashScopeResponseFormat;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import com.qingqiu.interview.common.constants.ChatConstant;
import io.netty.channel.ChannelOption;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.ResponseFormat;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.client.ReactorClientHttpRequestFactory;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import static com.qingqiu.interview.common.utils.WebClientUtils.getRestClientBuilder;
import static com.qingqiu.interview.common.utils.WebClientUtils.getWebClientBuilder;
/**
* DashScope相关配置类
@@ -45,21 +36,11 @@ public class DashScopeConfig {
@Value("${spring.ai.dashscope.chat.options.model}")
private String dashScopeChatModelName;
@Value("${spring.ai.openai.chat.options.model}")
private String openAiChatModelName;
@Value("${spring.ai.openai.base-url}")
private String openAiBaseUrl;
@Value("${spring.ai.openai.api-key}")
private String openAiApiKey;
@Resource(name = ChatConstant.MYSQL_CHAT_MEMORY_BEAN_NAME)
private MessageWindowChatMemory mysqlChatMemory;
@Resource(name = ChatConstant.REDIS_CHAT_MEMORY_BEAN_NAME)
private MessageWindowChatMemory redisChatMemory;
/**
* 创建DashScopeApi Bean实例
* 配置了HTTP客户端连接参数包括连接超时和响应超时时间
@@ -100,42 +81,6 @@ public class DashScopeConfig {
.build();
}
/**
* 创建OpenAI聊天模型Bean实例主模型
* 配置了最大完成token数和响应格式为JSON Schema
*
* @return ChatModel实例
*/
@Bean(name = ChatConstant.OPEN_AI_CHAT_MODEL_BEAN_NAME)
@Primary
public ChatModel openAiChatModel() {
return OpenAiChatModel.builder()
.openAiApi(
OpenAiApi.builder()
.apiKey(openAiApiKey)
.baseUrl(openAiBaseUrl)
.webClientBuilder(getWebClientBuilder())
.restClientBuilder(getRestClientBuilder())
.build()
)
.defaultOptions(
OpenAiChatOptions.builder()
.model(openAiChatModelName)
.maxCompletionTokens(ChatConstant.MAX_COMPLETION_TOKENS)
.responseFormat(
ResponseFormat.builder()
.type(ResponseFormat.Type.JSON_SCHEMA)
.jsonSchema(
ResponseFormat.JsonSchema
.builder()
.build()
).build()
)
.build()
)
.build();
}
/**
* 创建默认的聊天客户端Bean实例使用DashScope模型
@@ -171,47 +116,5 @@ public class DashScopeConfig {
.build();
}
/**
* 创建基于MySQL存储聊天历史的OpenAI聊天客户端Bean实例
* 配置了消息内存管理和日志记录功能
*
* @return ChatClient实例
*/
@Bean(name = ChatConstant.OPEN_AI_CHAT_CLIENT_BEAN_NAME)
public ChatClient openAiChatClient() {
return ChatClient
.builder(openAiChatModel())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(mysqlChatMemory).build(),
new SimpleLoggerAdvisor()
)
.build();
}
/**
* 创建全局默认的WebClient Bean实例
* 配置了连接超时和响应超时时间
*
* @return WebClient实例
*/
public WebClient.Builder getWebClientBuilder() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 连接超时
.responseTimeout(Duration.ofMillis(10000)); // 读取超时
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
;
}
private RestClient.Builder getRestClientBuilder() {
return RestClient.builder()
.requestFactory(
new ReactorClientHttpRequestFactory(
HttpClient.create()
.responseTimeout(Duration.ofMinutes(5))
)
);
}
}

View File

@@ -0,0 +1,100 @@
package com.qingqiu.interview.config;
import com.qingqiu.interview.common.constants.ChatConstant;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.ResponseFormat;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import static com.qingqiu.interview.common.utils.WebClientUtils.getRestClientBuilder;
import static com.qingqiu.interview.common.utils.WebClientUtils.getWebClientBuilder;
/**
* @program: ai-interview
* @description: openai聊天配置
* @author: huangpeng
* @create: 2025-11-06 20:03
**/
@Configuration
public class OpenAiChatConfig {
@Value("${spring.ai.openai.chat.options.model}")
private String openAiChatModelName;
@Value("${spring.ai.openai.base-url}")
private String openAiBaseUrl;
@Value("${spring.ai.openai.api-key}")
private String openAiApiKey;
@Resource(name = ChatConstant.MYSQL_CHAT_MEMORY_BEAN_NAME)
private MessageWindowChatMemory mysqlChatMemory;
/**
* 创建OpenAI聊天模型Bean实例主模型
* 配置了最大完成token数和响应格式为JSON Schema
*
* @return ChatModel实例
*/
@Bean(name = ChatConstant.OPEN_AI_CHAT_MODEL_BEAN_NAME)
@Primary
public ChatModel openAiChatModel() {
return OpenAiChatModel.builder()
.openAiApi(
OpenAiApi.builder()
.apiKey(openAiApiKey)
.baseUrl(openAiBaseUrl)
.webClientBuilder(getWebClientBuilder())
.restClientBuilder(getRestClientBuilder())
.build()
)
.defaultOptions(
OpenAiChatOptions.builder()
.model(openAiChatModelName)
.maxCompletionTokens(ChatConstant.MAX_COMPLETION_TOKENS)
.responseFormat(
ResponseFormat.builder()
.type(ResponseFormat.Type.JSON_SCHEMA)
.jsonSchema(
ResponseFormat.JsonSchema
.builder()
.build()
).build()
)
.build()
)
.build();
}
/**
* 创建基于MySQL存储聊天历史的OpenAI聊天客户端Bean实例
* 配置了消息内存管理和日志记录功能
*
* @return ChatClient实例
*/
@Bean(name = ChatConstant.OPEN_AI_CHAT_CLIENT_BEAN_NAME)
public ChatClient openAiChatClient() {
return ChatClient
.builder(openAiChatModel())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(mysqlChatMemory).build(),
new SimpleLoggerAdvisor()
)
.build();
}
}

View File

@@ -13,6 +13,7 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
/**
@@ -122,4 +123,11 @@ public class InterviewController {
public R<InterviewReportResponse> getInterviewReportDetail(@PathVariable String sessionId) {
return R.success(interviewService.getInterviewReport(sessionId));
}
@PostMapping("/read-pdf")
public R<?> readPdf(@RequestParam MultipartFile file) throws IOException {
String readPdfFile = interviewService.readPdfFile(file);
log.info("resume content: {}", readPdfFile);
return R.success(readPdfFile);
}
}

View File

@@ -0,0 +1,22 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* @program: ai-interview
* @description: 题型分类ai返回
* @author: huangpeng
* @create: 2025-11-06 20:08
**/
public class QuestionClassificationAiRes {
public record Item(@JsonProperty("content") String content,
@JsonProperty("category") String category,
@JsonProperty("difficulty") String difficulty,
@JsonProperty("tags") String tags) {}
public record Wrapper(@JsonProperty("questions")List<Item> questions) {}
}

View File

@@ -0,0 +1,18 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import redis.clients.jedis.graph.Statistics;
import java.util.List;
/**
* @program: ai-interview
* @description: 题目去重AI返回结果
* @author: huangpeng
* @create: 2025-11-07 10:37
**/
public record QuestionDeduplicationAiRes(
@JsonProperty("questionIds") String questionIds
) {
}

View File

@@ -1,59 +1,15 @@
package com.qingqiu.interview.service;
import com.qingqiu.interview.mapper.InterviewSessionMapper;
import com.qingqiu.interview.mapper.QuestionMapper;
import com.qingqiu.interview.dto.DashboardStatsResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@Service
@RequiredArgsConstructor
public class DashboardService {
private final QuestionMapper questionMapper;
private final InterviewSessionMapper sessionMapper;
public DashboardStatsResponse getDashboardStats() {
DashboardStatsResponse stats = new DashboardStatsResponse();
// 1. 获取核心KPI
stats.setTotalQuestions(questionMapper.selectCount(null));
stats.setTotalInterviews(sessionMapper.selectCount(null));
// 2. 获取题库分类统计
stats.setQuestionCategoryStats(questionMapper.countByCategory());
// 3. 获取最近7天的面试统计并补全没有数据的日期
List<DashboardStatsResponse.DailyStat> recentStats = sessionMapper.countRecentInterviews(7);
stats.setRecentInterviewStats(fillMissingDates(recentStats, 7));
return stats;
}
/**
* 填充最近几天内没有面试数据的日期补0
*/
private List<DashboardStatsResponse.DailyStat> fillMissingDates(List<DashboardStatsResponse.DailyStat> existingStats, int days) {
Map<String, Long> statsMap = existingStats.stream()
.collect(Collectors.toMap(DashboardStatsResponse.DailyStat::getDate, DashboardStatsResponse.DailyStat::getCount));
* @program: ai-interview
* @description: 工作台接口
* @author: huangpeng
* @create: 2025-11-07 14:54
**/
public interface DashboardService {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
return IntStream.range(0, days)
.mapToObj(i -> LocalDate.now().minusDays(i))
.map(date -> {
String dateString = date.format(formatter);
long count = statsMap.getOrDefault(dateString, 0L);
return new DashboardStatsResponse.DailyStat(dateString, count);
})
.sorted((d1, d2) -> d1.getDate().compareTo(d2.getDate())) // 按日期升序排序
.collect(Collectors.toList());
}
DashboardStatsResponse getDashboardStats();
}

View File

@@ -54,8 +54,17 @@ public interface InterviewService extends IService<InterviewSession> {
/**
* 获取面试报告
*
* @param sessionId
* @return
*/
InterviewReportResponse getInterviewReport(String sessionId);
/**
* 读取pdf文件数据
*
* @param file 文件
* @return 文件内容
*/
String readPdfFile(MultipartFile file) throws IOException;
}

View File

@@ -1,147 +1,18 @@
package com.qingqiu.interview.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qingqiu.interview.common.constants.ChatConstant;
import com.qingqiu.interview.common.utils.UUIDUtils;
import com.qingqiu.interview.entity.Question;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import com.qingqiu.interview.entity.Question;
import com.qingqiu.interview.entity.ai.QuestionClassificationAiRes;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class QuestionClassificationService {
private final ObjectMapper objectMapper = new ObjectMapper();
private final ChatClient chatClient;
/**
* 使用AI对题目进行分类
*/
public List<Question> classifyQuestions(String rawContent) {
log.info("开始使用AI分类题目内容长度: {}", rawContent.length());
* @program: ai-interview
* @description: 题型分类
* @author: huangpeng
* @create: 2025-11-06 19:59
**/
public interface QuestionClassificationService {
String prompt = buildClassificationPrompt(rawContent);
String aiResponse = chatClient.prompt()
.user(prompt)
.call()
.content();
log.info("AI分类响应: {}", aiResponse);
assert aiResponse != null;
return parseAiResponse(aiResponse);
}
private String buildClassificationPrompt(String content) {
return """
请分析以下面试题内容将其分类并提取信息。请严格按照以下JSON格式返回结果
{
"questions": [
{
"content": "题目内容",
"category": "分类Java基础、Spring框架、数据库、算法、系统设计等",
"difficulty": "难度Easy、Medium、Hard",
"tags": "相关标签,用逗号分隔"
}
]
}
分类规则:
1. category应该是具体的技术领域Java基础、Spring框架、MySQL、Redis、算法与数据结构、系统设计、网络协议等
2. difficulty根据题目复杂度判断Easy基础概念、Medium实际应用、Hard深入原理或复杂场景
3. tags包含更细粒度的标签多线程、JVM、事务、索引等
4. 如果内容包含多个独立的题目,请分别提取
5. 只返回JSON不要其他解释文字
待分析内容:
""" + content;
}
private List<Question> parseAiResponse(String aiResponse) {
List<Question> questions = new ArrayList<>();
try {
// 清理响应移除可能的markdown标记
String cleanResponse = aiResponse.trim();
if (cleanResponse.startsWith("```json")) {
cleanResponse = cleanResponse.substring(7);
}
if (cleanResponse.endsWith("```")) {
cleanResponse = cleanResponse.substring(0, cleanResponse.length() - 3);
}
cleanResponse = cleanResponse.trim();
JsonNode rootNode = objectMapper.readTree(cleanResponse);
JsonNode questionsNode = rootNode.get("questions");
if (questionsNode != null && questionsNode.isArray()) {
for (JsonNode questionNode : questionsNode) {
Question question = new Question()
.setContent(getTextValue(questionNode, "content"))
.setCategoryName(getTextValue(questionNode, "category"))
.setDifficulty(getTextValue(questionNode, "difficulty"))
.setTags(getTextValue(questionNode, "tags"));
if (isValidQuestion(question)) {
questions.add(question);
}
}
}
log.info("成功解析出 {} 个题目", questions.size());
} catch (JsonProcessingException e) {
log.error("解析AI响应失败: {}", e.getMessage());
log.error("原始响应: {}", aiResponse);
// 降级处理如果AI返回格式不正确尝试简单分割
questions.addAll(fallbackParsing(aiResponse));
}
return questions;
}
private String getTextValue(JsonNode node, String fieldName) {
JsonNode fieldNode = node.get(fieldName);
return fieldNode != null ? fieldNode.asText("") : "";
}
private boolean isValidQuestion(Question question) {
return question.getContent() != null && !question.getContent().trim().isEmpty()
&& question.getCategoryName() != null && !question.getCategoryName().trim().isEmpty();
}
private List<Question> fallbackParsing(String content) {
log.warn("使用降级解析策略");
List<Question> questions = new ArrayList<>();
// 简单的降级策略:按行分割,每行作为一个题目
String[] lines = content.split("\n");
for (String line : lines) {
line = line.trim();
if (!line.isEmpty() && line.length() > 10) { // 过滤太短的内容
Question question = new Question()
.setContent(line)
.setCategoryName("未分类")
.setDifficulty("Medium")
.setTags("待分类");
questions.add(question);
}
}
return questions;
}
List<QuestionClassificationAiRes.Item> classifyQuestions(String rawContent);
}

View File

@@ -0,0 +1,61 @@
package com.qingqiu.interview.service.impl;
import com.qingqiu.interview.mapper.InterviewSessionMapper;
import com.qingqiu.interview.mapper.QuestionMapper;
import com.qingqiu.interview.dto.DashboardStatsResponse;
import com.qingqiu.interview.service.DashboardService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@Service
@RequiredArgsConstructor
public class DashboardServiceImpl implements DashboardService {
private final QuestionMapper questionMapper;
private final InterviewSessionMapper sessionMapper;
@Override
public DashboardStatsResponse getDashboardStats() {
DashboardStatsResponse stats = new DashboardStatsResponse();
// 1. 获取核心KPI
stats.setTotalQuestions(questionMapper.selectCount(null));
stats.setTotalInterviews(sessionMapper.selectCount(null));
// 2. 获取题库分类统计
stats.setQuestionCategoryStats(questionMapper.countByCategory());
// 3. 获取最近7天的面试统计并补全没有数据的日期
List<DashboardStatsResponse.DailyStat> recentStats = sessionMapper.countRecentInterviews(7);
stats.setRecentInterviewStats(fillMissingDates(recentStats, 7));
return stats;
}
/**
* 填充最近几天内没有面试数据的日期补0
*/
private List<DashboardStatsResponse.DailyStat> fillMissingDates(List<DashboardStatsResponse.DailyStat> existingStats, int days) {
Map<String, Long> statsMap = existingStats.stream()
.collect(Collectors.toMap(DashboardStatsResponse.DailyStat::getDate, DashboardStatsResponse.DailyStat::getCount));
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
return IntStream.range(0, days)
.mapToObj(i -> LocalDate.now().minusDays(i))
.map(date -> {
String dateString = date.format(formatter);
long count = statsMap.getOrDefault(dateString, 0L);
return new DashboardStatsResponse.DailyStat(dateString, count);
})
.sorted((d1, d2) -> d1.getDate().compareTo(d2.getDate())) // 按日期升序排序
.collect(Collectors.toList());
}
}

View File

@@ -60,6 +60,7 @@ public class InterviewAiServiceImpl implements InterviewAiService {
String jobRequirements,
String resumeContent,
int count) {
// TODO: 考虑删除这些注释掉的旧代码实现
// String skillsStr = String.join(", ", skills);
// String prompt = String.format(
// "你是一位专业的软件开发岗位技术面试官。" +
@@ -83,6 +84,7 @@ public class InterviewAiServiceImpl implements InterviewAiService {
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, sessionId))
.call()
.entity(QuestionAiRes.Wrapper.class);
// TODO: 考虑删除这些注释掉的旧代码实现
// String content = openAiChatClient
// .prompt(aiInterviewerPrompt)
// .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, sessionId))
@@ -120,6 +122,7 @@ public class InterviewAiServiceImpl implements InterviewAiService {
.entity(QuestionAiRes.Wrapper.class);
assert entity != null;
return entity.questions();
// TODO: 考虑删除这些注释掉的旧代码实现
// String skillsStr = String.join(", ", skills);
// // 2. 构建发送给AI的提示
// String prompt = String.format("""
@@ -152,6 +155,7 @@ public class InterviewAiServiceImpl implements InterviewAiService {
@Override
public EvaluateAiRes evaluateAnswer(String sessionId, String question, String userAnswer, List<InterviewQuestionProgress> context) {
// TODO: 考虑删除这些注释掉的旧代码实现
// 构建上下文历史
// String history = context.stream()
// .map(p -> String.format("Q: %s\nA: %s", p.getQuestionContent(), p.getUserAnswer()))
@@ -190,6 +194,7 @@ public class InterviewAiServiceImpl implements InterviewAiService {
@Override
public InterviewReportAiRes generateFinalReport(InterviewSession session, List<InterviewQuestionProgress> progressList) {
// TODO: 考虑删除这些注释掉的旧代码实现
// String transcript = progressList.stream()
// .map(p -> String.format("问题: %s\n回答: %s\nAI评分: %.1f\nAI反馈: %s\n",
// p.getQuestionContent(), p.getUserAnswer(), p.getScore(), p.getFeedback()))

View File

@@ -28,11 +28,13 @@ import java.io.IOException;
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);
// TODO: 检查这个空if语句是否需要实现逻辑或删除
// 判断是否使用本地题库
if (request.getModel().equals("local")) {

View File

@@ -2,7 +2,6 @@ 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;
@@ -70,7 +69,7 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
public InterviewSession startInterview(MultipartFile file, InterviewStartRequest dto) throws IOException {
// 1. 创建并保存会话主记录
String sessionId = UUID.randomUUID().toString().replace("-", "");
String resumeContent = parseResume(file);
String resumeContent = readPdfFile(file);
InterviewSession session = new InterviewSession();
session.setSessionId(sessionId);
@@ -400,7 +399,8 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
}
private String parseResume(MultipartFile resume) throws IOException {
@Override
public String readPdfFile(MultipartFile resume) throws IOException {
// 获取文件扩展名
String extName = FileNameUtil.extName(resume.getOriginalFilename());
// 1. 获取简历解析器

View File

@@ -0,0 +1,77 @@
package com.qingqiu.interview.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qingqiu.interview.entity.Question;
import com.qingqiu.interview.entity.ai.QuestionClassificationAiRes;
import com.qingqiu.interview.service.QuestionClassificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class QuestionClassificationServiceImpl implements QuestionClassificationService {
private final ChatClient chatClient;
/**
* 使用AI对题目进行分类
*/
@Override
public List<QuestionClassificationAiRes.Item> classifyQuestions(String rawContent) {
log.info("开始使用AI分类题目内容长度: {}", rawContent.length());
QuestionClassificationAiRes.Wrapper entity = chatClient.prompt()
.user(buildClassificationPrompt(rawContent))
.call()
.entity(QuestionClassificationAiRes.Wrapper.class);
assert entity != null;
return entity.questions();
}
private String buildClassificationPrompt(String content) {
PromptTemplate prompt = PromptTemplate.builder()
.renderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
.template(
"""
请分析以下面试题内容将其分类并提取信息。请严格按照以下JSON格式返回结果
{
"questions": [
{
"content": "题目内容",
"category": "分类Java基础、Spring框架、数据库、算法、系统设计等",
"difficulty": "难度Easy、Medium、Hard",
"tags": "相关标签,用逗号分隔"
}
]
}
分类规则:
1. category应该是具体的技术领域Java基础、Spring框架、MySQL、Redis、算法与数据结构、系统设计、网络协议等
2. difficulty根据题目复杂度判断Easy基础概念、Medium实际应用、Hard深入原理或复杂场景
3. tags包含更细粒度的标签多线程、JVM、事务、索引等
4. 如果内容包含多个独立的题目,请分别提取
5. 只返回JSON不要其他解释文字
待分析内容:
<content>
"""
)
.build();
return prompt.render(Map.of("content", content));
}
}

View File

@@ -1,28 +1,36 @@
package com.qingqiu.interview.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollectionUtil;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.fastjson2.JSONArray;
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.common.enums.LLMProvider;
import com.qingqiu.interview.common.constants.CommonConstant;
import com.qingqiu.interview.common.utils.TreeUtil;
import com.qingqiu.interview.common.enums.LLMProvider;
import com.qingqiu.interview.common.utils.TreeUtils;
import com.qingqiu.interview.dto.QuestionOptionsDTO;
import com.qingqiu.interview.dto.QuestionPageParams;
import com.qingqiu.interview.entity.Question;
import com.qingqiu.interview.entity.QuestionCategory;
import com.qingqiu.interview.entity.ai.QuestionClassificationAiRes;
import com.qingqiu.interview.entity.ai.QuestionDeduplicationAiRes;
import com.qingqiu.interview.mapper.QuestionMapper;
import com.qingqiu.interview.service.IQuestionCategoryService;
import com.qingqiu.interview.service.QuestionClassificationService;
import com.qingqiu.interview.service.QuestionService;
import com.qingqiu.interview.service.parser.DocumentParser;
import com.qingqiu.interview.vo.QuestionAndCategoryTreeListVO;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.template.TemplateRenderer;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -38,10 +46,12 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> implements QuestionService {
private final QuestionMapper questionMapper;
private final QuestionClassificationService classificationService;
private final QuestionClassificationServiceImpl classificationService;
private final List<DocumentParser> documentParserList; // This will be injected by Spring
private final IQuestionCategoryService questionCategoryService;
private final ChatClient chatClient;
/**
* 分页查询题库
*/
@@ -96,19 +106,19 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
.orElseThrow(() -> new IllegalArgumentException("不支持的文件类型: " + fileExtension));
String content = parser.parse(file.getInputStream());
List<Question> questionsFromAi = classificationService.classifyQuestions(content);
List<QuestionClassificationAiRes.Item> items = classificationService.classifyQuestions(content);
int newQuestionsCount = 0;
for (Question question : questionsFromAi) {
for (QuestionClassificationAiRes.Item item : items) {
try {
validateQuestion(question.getContent(), null);
questionMapper.insert(question);
validateQuestion(item.content(), null);
questionMapper.insert(BeanUtil.toBean(item, Question.class));
newQuestionsCount++;
} catch (IllegalArgumentException e) {
log.warn("跳过重复题目: {}", question.getContent());
log.warn("跳过重复题目: {}", item.content());
}
}
log.info("成功导入 {} 个新题目,跳过 {} 个重复题目。", newQuestionsCount, questionsFromAi.size() - newQuestionsCount);
log.info("成功导入 {} 个新题目,跳过 {} 个重复题目。", newQuestionsCount, items.size() - newQuestionsCount);
}
/**
@@ -117,30 +127,32 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
@Override
@Transactional(rollbackFor = Exception.class)
public void useAiCheckQuestionData() {
// // 查询数据库
// List<Question> questions = questionMapper.selectList(
// new LambdaQueryWrapper<Question>()
// .orderByDesc(Question::getCreatedTime)
// );
// // 组装prompt
// if (CollectionUtil.isEmpty(questions)) {
// return;
// }
// String prompt = getPrompt(questions);
// log.info("发送内容: {}", prompt);
// // 验证token上下文长度
// Integer promptTokens = llmService.getPromptTokens(prompt);
// log.info("当前prompt长度: {}", promptTokens);
// String chat = llmService.chat(prompt, LLMProvider.DEEPSEEK);
// // 调用AI
// log.info("AI返回内容: {}", chat);
// 查询数据库
List<Question> questions = questionMapper.selectList(
new LambdaQueryWrapper<Question>()
.orderByDesc(Question::getCreatedTime)
);
// 组装prompt
if (CollectionUtil.isEmpty(questions)) {
return;
}
String prompt = getPrompt(questions);
QuestionDeduplicationAiRes entity = chatClient.prompt().user(prompt).call().entity(QuestionDeduplicationAiRes.class);
assert entity != null;
// 调用AI
log.info("AI返回内容: {}", JSONObject.toJSONString(entity));
String s = entity.questionIds();
List<String> list = Arrays.asList(s.split(","));
// TODO: 检查这些注释代码是否可以删除
// JSONObject parse = JSONObject.parse(chat);
// JSONArray questionsIds = parse.getJSONArray("questions");
// List<Long> list = questionsIds.toList(Long.class);
// questionMapper.delete(
// new LambdaQueryWrapper<Question>()
// .notIn(Question::getId, list)
// );
questionMapper.delete(
new LambdaQueryWrapper<Question>()
.notIn(Question::getId, list)
);
}
@@ -155,25 +167,71 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
}
JSONObject jsonObject = new JSONObject();
jsonObject.put("data", jsonArray);
return String.format("""
你是一个数据清洗与去重专家。你的任务是从以下文本列表中,识别出语义相同或高度相似的条目,并为每一组相似条目筛选出一个最规范、最简洁的代表性版本。
【去重规则】
1. **语义核心优先**:忽略无关的修饰词、标点符号、后缀(如“:2”、大小写和空格。
2. **合并同类项**:将表达同一主题或问题的文本归为一组。
3. **选择标准**:从每一组中,选出那个最完整、最简洁、且没有多余符号(如序号、特殊后缀)的版本作为代表。如果两个版本质量相当,优先选择更短的那个。
4. **保留原意**:确保选出的代表版本没有改变原文本的核心含义。
请按照下述格式返回,已被剔除掉的数据无需返回
{
"questions": [1, 2, 3, .....]
}
分类规则:
return PromptTemplate.builder()
.renderer(
StTemplateRenderer.builder()
.startDelimiterToken('<')
.endDelimiterToken('>')
.build()
)
.template("""
请对以下题库JSON数据进行智能去重处理。
## 任务说明
识别并移除语义相似或表达意思基本相同的重复题目,只保留每个独特题目的一个版本。
## 语义相似度判断标准
1. 核心意思相同:即使表述不同,但考察的知识点和答案逻辑一致
2. 同义替换:使用同义词、近义词但意思不变的题目
3. 句式变换:主动被动语态转换、疑问词替换等句式变化
4. 冗余表述:增加了无关修饰词但核心内容相同的题目
## 处理规则
- 对语义相似的题目组,只保留其中一条数据
- 保留原则:选择表述最清晰、最完整的那条
- 如果难以判断保留ID较小或创建时间较早的那条
## 输出要求
1. 只返回JSON不要其他解释文字
2. 请严格按照API接口形式返回不要返回任何额外的文字内容包括'```json```'!!!!
3. 请严格按照网络接口的形式返回JSON数据
【请处理以下数据列表】:
%s
""", jsonObject.toJSONString());
{
"questionIds": "1, 2, 3" # 请返回保留数据的id
}
## 特殊说明
- 注意区分真正重复和只是题型相似的题目
- 对于选择题,要同时考虑题干和选项的语义相似度
- 保留题目版本的完整性
请处理以下JSON数据
<data>
""")
.build()
.render(Map.of("data", jsonObject.toJSONString()))
;
// TODO: 检查这些注释代码是否可以删除 - 这是旧的prompt模板实现
// return String.format("""
// 你是一个数据清洗与去重专家。你的任务是从以下文本列表中,识别出语义相同或高度相似的条目,并为每一组相似条目筛选出一个最规范、最简洁的代表性版本。
//
// 【去重规则】
// 1. **语义核心优先**:忽略无关的修饰词、标点符号、后缀(如“:2”、大小写和空格。
// 2. **合并同类项**:将表达同一主题或问题的文本归为一组。
// 3. **选择标准**:从每一组中,选出那个最完整、最简洁、且没有多余符号(如序号、特殊后缀)的版本作为代表。如果两个版本质量相当,优先选择更短的那个。
// 4. **保留原意**:确保选出的代表版本没有改变原文本的核心含义。
// 请返回数据的id已被剔除掉的数据无需返回格式如下
// {
// "questions": [1, 2, 3, .....]
// }
// 分类规则:
// 1. 只返回JSON不要其他解释文字
// 2. 请严格按照API接口形式返回不要返回任何额外的文字内容包括'```json```'!!!!
// 3. 请严格按照网络接口的形式返回JSON数据
// 【请处理以下数据列表】:
// %s
// """, jsonObject.toJSONString());
}
/**
@@ -209,7 +267,7 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
if (CollectionUtil.isNotEmpty(dto.getCategoryIds())) {
questionCategories = questionCategoryService.batchFindByAncestorIdsUnion(dto.getCategoryIds());
if (CollectionUtil.isNotEmpty(questionCategories)) {
treeList = TreeUtil.buildTree(
treeList = TreeUtils.buildTree(
questionCategories,
QuestionCategory::getId,
QuestionCategory::getParentId,

View File

@@ -37,6 +37,7 @@ spring:
min-idle: 0
max-wait: -1ms
# TODO: 考虑删除已注释的配置
# ai:
# openai:
# api-key: sk-faaa2a1b485442ccbf115ff1271a3480
@@ -44,6 +45,14 @@ spring:
# chat:
# options:
# model: deepseek-chat
logging:
level:
org:
springframework:
ai:
chat:
client:
advisor: debug
mybatis-plus:
configuration:
map-underscore-to-camel-case: true