4 Commits

Author SHA1 Message Date
b08a3988f7 添加需求文档 2026-01-22 19:30:42 +08:00
80dcb23bbc 使用spring-ai-alibaba重构项目 2025-11-24 13:37:37 +08:00
5fb4ed754c 使用spring-ai-alibaba重构项目 2025-10-22 19:53:26 +08:00
fac1346104 使用spring-ai-alibaba重构项目 2025-10-10 18:03:16 +08:00
54 changed files with 2357 additions and 1234 deletions

27
HELP.md
View File

@@ -1,27 +0,0 @@
# Getting Started
### Reference Documentation
For further reference, please consider the following sections:
* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html)
* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.10-SNAPSHOT/maven-plugin)
* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.10-SNAPSHOT/maven-plugin/build-image.html)
* [Spring Data JDBC](https://docs.spring.io/spring-boot/3.4.10-SNAPSHOT/reference/data/sql.html#data.sql.jdbc)
* [Spring Web](https://docs.spring.io/spring-boot/3.4.10-SNAPSHOT/reference/web/servlet.html)
### Guides
The following guides illustrate how to use some features concretely:
* [Using Spring Data JDBC](https://github.com/spring-projects/spring-data-examples/tree/master/jdbc/basics)
* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/)
* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
### Maven Parent overrides
Due to Maven's design, elements are inherited from the parent POM to the project POM.
While most of the inheritance is fine, it also inherits unwanted elements like `<license>` and `<developers>` from the parent.
To prevent this, the project POM contains empty overrides for these elements.
If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides.

332
docs/B端需求文档.md Normal file
View File

@@ -0,0 +1,332 @@
------
# 📘《AI 智能模拟面试系统》B 端管理后台PRD —— 完整版
------
# 0. 文档信息
| 项目 | 内容 |
| ------------ | ----------------------------------- |
| 产品名称 | AI 智能模拟面试系统B 端管理后台) |
| 文档版本 | v1.0 |
| 平台 | PC Web |
| 用户角色 | 超级管理员 / 内容运营 / 数据分析师 |
| 作者 | ChatGPT |
| 最后更新时间 | 2025.11 |
------
# 1. 产品定位
B 端用于支持 C 端内容与运维,包括:
- 题库管理
- 岗位管理
- 用户管理
- AI 模型管理
- 面试记录查看
- 内容配置banner、推荐
- 系统设置
- 数据分析与看板
------
# 2. B 端用户角色与权限
| 角色 | 权限 |
| ----------- | -------------------- |
| Super Admin | 全权限,含系统配置 |
| 内容管理员 | 岗位、题库管理 |
| 数据分析员 | 查看数据与记录 |
| 运营 | Banner、推荐内容配置 |
采用 **RBAC基于角色的权限控制**,权限点在后台可配置。
------
# 3. 功能需求(按模块)
------
# **3.1 管理员系统(权限体系)**
## 3.1.1 管理员账号管理
功能点:
- 新增管理员
- 编辑管理员
- 禁用账号
- 重置密码
- 设置角色
字段:
- 用户名
- 邮箱
- 角色
- 状态
- 创建时间
------
## 3.1.2 角色管理
- 创建角色
- 编辑角色
- 配置权限点checkbox
- 删除角色
------
## 3.1.3 权限点管理
权限点模块化:
| 模块 | 示例权限点 |
| -------- | ------------------ |
| 岗位管理 | 新增、编辑、删除 |
| 题库管理 | 新增题目、禁用题目 |
| 用户管理 | 查看用户、禁用用户 |
| 面试管理 | 查看所有面试记录 |
| 系统设置 | 配置 OSS、模型参数 |
------
# **3.2 岗位管理模块**
## 3.2.1 岗位列表
展示字段:
- 岗位名
- 技能标签
- 难度
- 使用次数
- 状态
## 3.2.2 岗位维护
可以:
- 新增岗位
- 编辑岗位
- 删除 / 禁用岗位
- 配置技能标签(多个)
- 配置难度15
------
# **3.3 题库管理模块**
## 3.3.1 题目列表
展示:
- 题目内容(前 30 字)
- 所属岗位
- 难度
- 类型(开放题、多选题等)
- 使用次数
- 创建人
- 状态(有效/无效)
## 3.3.2 新增题目
字段:
- 问题内容(文本)
- 示例答案
- 难度
- 标签(可多选)
- 所属岗位
- question_typeopen / case / multiple
支持「AI 自动生成题目」。
## 3.3.3 批量导入
Excel 模板字段:
- 问题内容
- 答案
- 岗位
- 标签
- 难度
------
# **3.4 面试记录管理模块**
## 3.4.1 面试记录列表
字段:
- 面试 ID
- 用户
- 岗位
- 得分
- 面试模式
- 创建时间
支持筛选:
- 按日期
- 按岗位
- 按用户
- 分数段
## 3.4.2 面试详情
可查看:
- 完整问答(题目、用户回答)
- 音频回放
- AI 评分
- AI 点评
- 报告内容
------
# **3.5 用户管理模块**
## 3.5.1 用户列表
字段:
- 用户 ID
- 手机号/微信身份
- 注册时间
- 最近活跃时间
- 状态(正常/封禁)
## 3.5.2 用户详情
包含:
- 基本信息
- 简历解析内容
- 面试记录
- 行为数据(使用次数、平均分)
可执行操作:
- 封禁用户
- 清空数据(仅开发可见开关)
------
# **3.6 内容配置模块**
## 3.6.1 Banner 配置
- 上传图片
- 配置跳转链接
- 配置排序
- 设置上线/下线
## 3.6.2 推荐内容
- 推荐岗位排列
- 推荐题目列表
------
# **3.7 系统设置模块**
## 3.7.1 小程序配置
- AppID
- Secret
## 3.7.2 OSS/存储配置
- AccessKey
- Secret
- Bucket 名称
## 3.7.3 鉴权与 Token
- Token 有效期配置
- 登录限制
## 3.7.4 模型配置(关键)
- 当前使用模型(例如 GPT、Qwen
- 模型版本号
- 评分模型权重(逻辑 / 表达 / 深度 / 专业)
- 灰度开关
------
# **3.8 数据分析模块Dashboard**
## 3.8.1 总览数据
- 今日新增用户
- 今日面试次数
- 总注册数
- 总面试数
## 3.8.2 趋势图
- 最近 30 天用户增长
- 最近 30 天面试次数
- 平均分趋势
## 3.8.3 岗位热度
- 岗位使用次数
- 平均得分
## 3.8.4 模型数据
- 模型调用成功率
- 评分稳定性分析
------
# 4. 页面的 IA信息架构
```
登录页
首页Dashboard
├─ 数据总览
├─ 趋势图
岗位管理
├─ 岗位列表
├─ 新建岗位
题库管理
├─ 题目列表
├─ 新建题目
├─ 批量导入
用户管理
├─ 用户列表
├─ 用户详情
面试管理
├─ 面试记录
├─ 面试详情
内容配置
├─ Banner
├─ 推荐岗位
系统设置
├─ 小程序设置
├─ 文件存储设置
├─ 模型设置
权限管理
├─ 管理员
├─ 角色
├─ 权限点
```
------
# 5. 非功能性要求B 端)
- 管理后台响应 < 300ms
- 并发支持 1000+
- 日志必须记录所有管理员操作
- 敏感操作需二次确认
- 文件需防盗链

266
docs/C端需求文档.md Normal file
View File

@@ -0,0 +1,266 @@
------
# 📘《AI 智能模拟面试系统》C 端用户端PRD —— 完整版
------
# 0. 文档信息
| 项目 | 内容 |
| ------------ | ------------------------------ |
| 产品名称 | AI 智能模拟面试系统C 端) |
| 文档版本 | v1.0 |
| 支持平台 | PC 网页、Mobile H5、微信小程序 |
| 使用人群 | 求职者,学生,工作者 |
| 作者 | ChatGPT产品经理角色 |
| 最后更新时间 | 2025.11 |
------
# 1. 产品概述
用户通过 C 端可进行:
- AI 模拟面试
- 模拟面试评分与复盘
- 岗位选择与题库练习
- 个人资料与简历管理
- 学习提升
C 端目标是帮助用户低成本、高效率提升面试能力。
------
# 2. 用户场景与核心价值
## 场景
1. 求职前需要练习面试,但没有面试官
2. 希望通过真实演练了解自身弱点
3. 想知道针对某岗位常见问题
4. 想收到专业点评和改进建议
5. 想通过复盘持续提升表达能力与专业度
## 核心价值
- 用 AI 快速模拟真实面试
- 获得专业化、结构化的评分评估
- 提供收敛的复盘内容与提升建议
- 实现个性化岗位匹配与题目推荐
------
# 3. 角色 & 权限
| 角色 | 权限 |
| -------- | ---------------------------------------- |
| 普通用户 | 注册、登录、创建面试、查看报告、编辑简历 |
| 游客 | 浏览岗位、部分题库、引导注册 |
------
# 4. 功能需求(按模块)
------
## 4.1 账号系统
### 4.1.1 登录注册
| 功能 | 说明 |
| ------------------ | -------------------------- |
| 手机号验证码登录 | 支持短信验证 |
| 微信登录(小程序) | 自动拉取头像昵称 |
| 邮箱/密码登录 | 可选开关 |
| Token 机制 | AccessToken + RefreshToken |
**错误提示:**
- 验证码不正确
- 手机格式错误
- 微信登录失败
------
## 4.2 用户中心
### 4.2.1 基本资料
用户可编辑:
| 字段 | 限制 |
| -------- | ------ |
| 头像 | ≤5MB |
| 昵称 | ≤20 字 |
| 技能标签 | ≤10 |
| 工作年限 | 020+ |
| 当前职位 | ≤50 字 |
### 4.2.2 简历管理
| 功能 | 描述 |
| ------------ | ---------------------- |
| 上传简历 | PDF/DOC ≤10MB |
| 自动解析 | 解析技能 → 项目 → 亮点 |
| 编辑解析结果 | 可调整 |
| 删除简历 | 支持 |
------
## 4.3 岗位模块
### 4.3.1 岗位列表
功能点:
- 按关键词搜索
- 筛选(技能标签、难度)
- 岗位推荐(基于用户简历)
- 按热度排序
### 4.3.2 岗位详情
包含:
- 岗位描述
- 技能要求
- 常见题型展示
- 推荐学习路径
- 「开始模拟面试」按钮
------
## 4.4 模拟面试(主流程)
### 4.4.1 创建面试
用户选择:
- 岗位
- 模式(文本 / 语音)
- 题量5/8/10
进入面试准备界面(提示麦克风权限)。
------
### 4.4.2 面试进行中
| 功能 | 描述 |
| -------- | ------------------------------ |
| AI 提问 | 每题 1 个主问题,可 12 个追问 |
| 文本回答 | 输入框 |
| 语音回答 | 录音上传ASR 转写 |
| 进度条 | x / N 题 |
| 跳过功能 | 可跳过 1 次 |
| 中断 | 用户主动退出或异常断开 |
### 界面区块
- 左侧:问题内容
- 中间:答题区
- 下方:进度 & 操作按钮
------
## 4.5 面试结果与复盘
### 4.5.1 面试列表
字段:
- 总分
- 岗位
- 时间
- 面试模式
- 标签(优秀、一般、需提升)
### 4.5.2 面试详情
展示:
- 每题内容与用户原回答
- 文本/音频回放
- 评分维度(逻辑、表达、深度、专业)
- AI 点评
------
### 4.5.3 复盘报告(核心)
报告包含以下结构:
| 模块 | 内容 |
| ------------ | ---------- |
| 综合评分 | 010 |
| 综合评语 | 200300 字 |
| 优势总结 | 35 条 |
| 待改进点 | 35 条 |
| 行为建议 | 37 条 |
| 推荐学习方向 | 动态生成 |
支持下载 / 分享卡片(图像形式)。
------
## 4.6 学习中心
内容:
- 岗位相关题库
- 常见行为面试题STAR 法则)
- 面试技巧教程
- 视频/图文内容(可选)
------
## 4.7 通用功能
### 4.7.1 文件上传
- 支持音频上传WAV/MP3
- 支持头像、简历、图片上传
### 4.7.2 消息中心
- 面试报告生成通知
- 系统公告
### 4.7.3 设置
- 修改手机号
- 注销账号
------
# 5. 页面结构(信息架构 IA
```
首页
├─ 推荐岗位
├─ 开始面试
用户中心
├─ 基本资料
├─ 简历管理
岗位
├─ 岗位列表
├─ 岗位详情
模拟面试
├─ 创建面试
├─ 面试问答页
面试记录
├─ 面试列表
├─ 面试详情
└─ 复盘报告
学习中心
├─ 题库练习
├─ 面试技巧
```
------
# 6. 非功能性要求C 端)
- 面试响应 ≤ 200ms
- 报告生成 ≤ 10 秒
- 音频上传失败重试机制

128
pom.xml
View File

@@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
<version>3.4.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.qingqiu</groupId>
@@ -13,6 +13,7 @@
<version>0.0.1-SNAPSHOT</version>
<name>AI-Interview</name>
<description>AI-Interview</description>
<!-- TODO: 考虑删除空的元数据元素 -->
<url/>
<licenses>
<license/>
@@ -28,6 +29,9 @@
</scm>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
<spring-ai-alibaba.version>1.0.0.2</spring-ai-alibaba.version>
<spring-boot.version>3.4.5</spring-boot.version>
</properties>
<dependencies>
<!-- MyBatis-Plus -->
@@ -53,26 +57,58 @@
<artifactId>spring-boot-starter-webflux</artifactId> <!-- 用于 WebClient -->
</dependency>
<!-- &lt;!&ndash; Spring AI Dependencies &ndash;&gt;-->
<!-- TODO: 考虑删除已注释的依赖 -->
<!-- <dependency>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-openai-spring-boot-starter</artifactId>-->
<!-- <exclusions>-->
<!-- <exclusion>-->
<!-- <groupId>com.alibaba.cloud.ai</groupId>-->
<!-- <artifactId>spring-ai-alibaba-autoconfigure</artifactId>-->
<!-- </exclusion>-->
<!-- <exclusion>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-core</artifactId>-->
<!-- </exclusion>-->
<!-- </exclusions>-->
<!-- <version>1.0.0-SNAPSHOT</version>-->
<!-- </dependency>-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dashscope-sdk-java</artifactId>
<version>2.21.5</version>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<!-- TODO: 检查内存相关依赖是否都需要 -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.2.0</version>
</dependency>
<!-- 集成redis依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- TODO: 考虑删除已注释的依赖 -->
<!-- <dependency>-->
<!-- <groupId>com.alibaba</groupId>-->
<!-- <artifactId>dashscope-sdk-java</artifactId>-->
<!-- <version>2.21.5</version>-->
<!-- </dependency>-->
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-all -->
<dependency>
<groupId>cn.hutool</groupId>
@@ -129,16 +165,28 @@
</dependency>
</dependencies>
<dependencyManagement>
<!-- <dependencies>-->
<!-- <dependency>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-bom</artifactId>-->
<!-- <version>1.0.0-M5</version> &lt;!&ndash; 或最新版本 &ndash;&gt;-->
<!-- <type>pom</type>-->
<!-- <scope>import</scope>-->
<!-- </dependency>-->
<!-- </dependencies>-->
<dependencies>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-bom</artifactId>
<version>${spring-ai-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-bom</artifactId>
@@ -147,16 +195,19 @@
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<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>
@@ -167,33 +218,6 @@
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</pluginRepository>
</pluginRepositories>
</project>

View File

@@ -1,22 +0,0 @@
package com.qingqiu.interview.ai.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.io.Serial;
import java.io.Serializable;
@Data
@Accessors(chain = true)
@AllArgsConstructor
@NoArgsConstructor
public class Message implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String role;
private String content;
}

View File

@@ -1,11 +0,0 @@
package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.common.enums.LLMProvider;
import com.qingqiu.interview.ai.service.AIClientService;
public interface AIClientFactory {
AIClientService createAIClient();
// 支持的提供商
LLMProvider getSupportedProvider();
}

View File

@@ -1,33 +0,0 @@
package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.common.enums.LLMProvider;
import com.qingqiu.interview.ai.service.AIClientService;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class AIClientManager {
private final Map<LLMProvider, AIClientFactory> factories;
public AIClientManager(List<AIClientFactory> strategies) {
this.factories = strategies.stream()
.collect(Collectors.toMap(
AIClientFactory::getSupportedProvider,
Function.identity()
));
}
public AIClientService getClient(LLMProvider provider) {
// String factoryName = aiType + "ClientFactory";
AIClientFactory factory = factories.get(provider);
if (factory == null) {
throw new IllegalArgumentException("不支持的AI type: " + provider);
}
return factory.createAIClient();
}
}

View File

@@ -1,20 +0,0 @@
package com.qingqiu.interview.ai.factory;
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;
import org.springframework.stereotype.Service;
@Service
public class DeepSeekClientFactory implements AIClientFactory{
@Override
public AIClientService createAIClient() {
return SpringApplicationContextUtil.getBean(DeepSeekClientServiceImpl.class);
}
@Override
public LLMProvider getSupportedProvider() {
return LLMProvider.DEEPSEEK;
}
}

View File

@@ -1,20 +0,0 @@
package com.qingqiu.interview.ai.factory;
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;
import org.springframework.stereotype.Service;
@Service
public class QwenClientFactory implements AIClientFactory{
@Override
public AIClientService createAIClient() {
return SpringApplicationContextUtil.getBean(QwenClientServiceImpl.class);
}
@Override
public LLMProvider getSupportedProvider() {
return LLMProvider.QWEN;
}
}

View File

@@ -1,13 +0,0 @@
package com.qingqiu.interview.ai.service;
import com.alibaba.dashscope.common.Message;
import java.util.List;
public abstract class AIClientService {
public abstract String chatCompletion(String prompt);
public String chatCompletion(List<Message> messages) {
return null;
}
}

View File

@@ -1,65 +0,0 @@
package com.qingqiu.interview.ai.service.impl;
import com.alibaba.dashscope.common.Message;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.qingqiu.interview.ai.service.AIClientService;
import com.qingqiu.interview.common.service.HttpService;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static com.qingqiu.interview.common.utils.AIUtils.createUserMessage;
/**
* deepseek 接入
*/
@Service
@RequiredArgsConstructor
public class DeepSeekClientServiceImpl extends AIClientService {
private final HttpService httpService;
@Value("${deepseek.api-url}")
private String apiUrl;
@Value("${deepseek.api-key}")
private String apiKey;
@Override
public String chatCompletion(String prompt) {
return chatCompletion(Collections.singletonList(createUserMessage(prompt)));
}
@Override
public String chatCompletion(List<Message> messages) {
JSONObject jsonObject = new JSONObject();
jsonObject.put("type", "json_object");
Map<String, Object> requestBody = Map.of(
"model", "deepseek-chat",
"messages", messages,
"max_tokens", 8192,
"response_format", Map.of("type", "json_object")
);
String res = httpService.postWithAuth(
apiUrl,
requestBody,
String.class,
"Bearer " + apiKey
).block();
if (StringUtils.isNotBlank(res)) {
JSONObject jsonRes = JSONObject.parse(res);
JSONArray choices = jsonRes.getJSONArray("choices");
JSONObject resContent = choices.getJSONObject(0);
JSONObject message = resContent.getJSONObject("message");
return message.getString("content");
}
return null;
}
}

View File

@@ -1,62 +0,0 @@
package com.qingqiu.interview.ai.service.impl;
import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationParam;
import com.alibaba.dashscope.aigc.generation.GenerationResult;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.ResponseFormat;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.qingqiu.interview.ai.service.AIClientService;
import com.qingqiu.interview.common.res.ResultCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import static com.qingqiu.interview.common.constants.QwenModelConstant.QWEN_PLUS_LATEST;
import static com.qingqiu.interview.common.utils.AIUtils.createUserMessage;
@Slf4j
@Service
@RequiredArgsConstructor
public class QwenClientServiceImpl extends AIClientService {
@Value("${dashscope.api-key}")
private String apiKey;
private final Generation generation;
@Override
public String chatCompletion(String prompt) {
return chatCompletion(Collections.singletonList(createUserMessage(prompt)));
}
@Override
public String chatCompletion(List<Message> messages) {
GenerationParam param = GenerationParam.builder()
.model(QWEN_PLUS_LATEST) // 可根据需要更换模型
.messages(messages)
.resultFormat(GenerationParam.ResultFormat.MESSAGE)
.responseFormat(ResponseFormat.builder().type(ResponseFormat.JSON_OBJECT).build())
.apiKey(apiKey)
.build();
GenerationResult result = null;
try {
result = generation.call(param);
return result.getOutput().getChoices().get(0).getMessage().getContent();
} catch (NoApiKeyException e) {
log.error("没有api key请先确认配置");
throw new com.qingqiu.interview.common.ex.ApiException(ResultCode.INTERNAL);
} catch (ApiException | InputRequiredException e) {
log.error("调用AI服务失败", e);
throw new com.qingqiu.interview.common.ex.ApiException(ResultCode.INTERNAL);
}
}
}

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

@@ -0,0 +1,44 @@
package com.qingqiu.interview.common.constants;
/**
* 聊天相关常量类
* 定义了聊天功能中使用的各种常量值
*/
public class ChatConstant {
/**
* MySQL聊天记录存储的Bean名称
*/
public static final String MYSQL_CHAT_MEMORY_BEAN_NAME = "mysql-chat-memory";
/**
* Redis聊天记录存储的Bean名称
*/
public static final String REDIS_CHAT_MEMORY_BEAN_NAME = "redis-chat-memory";
/**
* 聊天记录最大保存消息数
*/
public static final Integer MAX_MESSAGES = 100;
/**
* DashScope聊天模型的Bean名称
*/
public static final String DASH_SCOPE_CHAT_MODEL_BEAN_NAME = "dash-scope-chat-model";
public static final String OPEN_AI_CHAT_MODEL_BEAN_NAME = "open-ai-chat-model";
/**
* DashScope聊天客户端的Bean名称
*/
public static final String DASH_SCOPE_CHAT_CLIENT_BEAN_NAME = "dash-scope-chat-client";
/**
* OpenAI聊天客户端的Bean名称
*/
public static final String OPEN_AI_CHAT_CLIENT_BEAN_NAME = "open-ai-chat-client";
/**
* 最大补全token数量
*/
public static final Integer MAX_COMPLETION_TOKENS = 8192;
}

View File

@@ -1,41 +0,0 @@
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 {
public static Message createMessage(String role, String content) {
return Message.builder()
.role(role)
.content(content)
.build();
}
public static Message createUserMessage(String prompt) {
return createMessage(Role.USER.getValue(), prompt);
}
public static Message createAIMessage(String prompt) {
return createMessage(Role.ASSISTANT.getValue(), prompt);
}
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<Integer> integers = tokenizer.encodeOrdinary(prompt);
return integers.size();
}
}

View File

@@ -0,0 +1,430 @@
package com.qingqiu.interview.common.utils;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 提示模板工具类
*/
public class PromptTemplateUtils {
/**
* 获取提取技能的提示
*
* @param resumeContent 简历内容
* @return 提示
*/
public static Prompt getExtractSkillsPrompt(String resumeContent) {
SystemMessage systemMessage = new SystemMessage("""
你是一位资深的技术面试官,以严格和深入著称。
你需要从提供的简历内容中,提取出所有与职位相关的技能。
请按照以下JSON格式返回
{"skills": ["技能1", "技能2", "..."]}
""");
UserMessage userMessage = new UserMessage(resumeContent);
return new Prompt(List.of(userMessage, systemMessage));
}
/**
* 获取AI面试官的提示
*
* @param params 参数
* @return 提示
*/
public static Prompt getAiInterviewerPrompt(Map<String, Object> params) {
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate("""
你是一位经验丰富的软件开发技术面试官,具备以下特质:
## 专业能力
- 拥有10年以上软件开发和团队管理经验
- 熟悉各种主流技术栈和架构模式
- 擅长通过技术面试评估候选人的真实能力
- 能够设计多层次、递进式的面试问题
## 面试原则
1. **精准匹配**: 问题必须紧密结合岗位要求和候选人背景
2. **层次递进**: 从基础概念到深度应用,从理论到实践
3. **场景化考察**: 基于真实项目场景设计问题
4. **全面评估**: 涵盖技术深度、问题解决能力、系统思维
## 本岗位招聘要求
[{jobRequirements}]
## 输出要求
- 严格按照指定的JSON格式输出
- 不得包含任何JSON格式之外的内容
- 不得添加代码块标记(```json)或其他解释性文字
- 确保生成的问题数量与要求完全一致
""");
PromptTemplate userPromptTemplate = new PromptTemplate("""
请根据岗位招聘要求设计 {count} 道技术面试题。
## 候选人信息
### 技术栈
{skills}
### 简历内容
{resume}
## 面试题设计要求
### 覆盖维度
1. **基础理论** (20%): 核心概念、原理机制
2. **项目实践** (40%): 具体项目中的技术难点和解决方案
3. **系统设计** (20%): 架构思维、技术选型、性能优化
4. **问题解决** (20%): 调试能力、故障排查、代码优化
### 难度分布
- 基础题 (30%): 验证核心技能掌握情况
- 进阶题 (50%): 考察深度理解和实际应用
- 高阶题 (20%): 评估架构能力和创新思维
### 问题类型
- **概念阐述**: "请解释...""什么是..."
- **场景分析**: "在你的XX项目中...""如果遇到XX问题..."
- **方案设计**: "如何设计...""请给出..."
- **比较选择**: "XX和YY的区别...""为什么选择..."
## 输出格式
必须严格按照以下JSON格式输出不得有任何偏差
{jsonRes}
请立即开始生成面试题:
""");
String s = """
{
"questions": [
{
"id": "ai-gen-1",
"content": "问题内容..."
}
]
}
""";
params.put("jsonRes", s);
return new Prompt(List.of(userPromptTemplate.createMessage(params), systemPromptTemplate.createMessage(params)));
}
public static Prompt getLocalInterviewPrompt(Map<String, Object> params) {
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate("""
你是一位资深的技术面试专家。
## 你的专业背景
- 10年+ 软件开发和技术管理经验
- 熟悉前端、后端、全栈、嵌入式、移动端、DevOps、大数据、AI等各技术领域
- 精通各种技术栈的深度和广度评估
- 擅长根据岗位特点和候选人背景进行精准匹配
## 通用岗位分类及评估重点
### 后端开发岗位
- 重点框架熟练度、数据库设计、API设计、性能优化、并发处理
- 核心技能:编程语言基础、框架应用、系统设计、问题排查
### 前端开发岗位
- 重点UI/UX实现、性能优化、跨浏览器兼容、现代框架使用
- 核心技能HTML/CSS/JS、框架应用、工程化、用户体验
### 全栈开发岗位
- 重点前后端技术栈、系统架构、DevOps流程
- 核心技能:多技术栈掌握、系统整合、项目管理
### 架构师岗位
- 重点:系统设计、技术选型、性能调优、团队技术规划
- 核心技能:架构设计、技术决策、团队领导、业务理解
## 筛选策略框架
### 匹配维度权重
1. **技术栈匹配度** (35%): 候选人技能与岗位要求的重合度
2. **项目经验相关性** (30%): 过往项目与岗位场景的匹配度
3. **技能深度评估** (20%): 根据经验年限判断技能掌握深度
4. **发展潜力考量** (15%): 学习能力和技术视野的考察
## 本岗位招聘要求
[{jobRequirements}]
## 输出规范
- 必须输出标准JSON格式{jsonRes}
- 不得包含任何解释、注释或代码块标记
- 确保所选题目ID真实存在于题库中
- 保证题目数量与要求完全一致
""");
PromptTemplate userPromptTemplate = new PromptTemplate("""
请根据岗位招聘要求,从题库中筛选筛选 {count} 道技术面试题。
## 候选人档案
### 技术栈
{skills}
### 简历内容
{resume}
## 筛选要求
请根据以上信息,结合你的专业框架,从题库中筛选出最能评估该候选人是否适合此岗位的面试题。
### 重点考虑因素
1. 岗位核心技术要求与候选人技能的匹配度
2. 候选人项目经验与岗位应用场景的关联性
3. 题目难度与候选人经验水平的适配性
4. 题目类型的多样性(理论+实践+设计)
"""
);
params.put("jsonRes",
"""
{
"questions": [
{
"id": "1",
"content": "问题内容..."
},
{
"id": "3",
"content": "问题内容..."
}
...
]
}
"""
);
return new Prompt(List.of(
userPromptTemplate.createMessage(params),
systemPromptTemplate.createMessage(params)
));
}
public static Prompt getEvaluatePrompt(Map<String, Object> params) {
// SystemMessage systemMessage = new SystemMessage("""
// 你是一位经验丰富的高级技术面试官,以公正、严谨、深入的评估风格著称。
// ## 你的专业特质
// - 拥有15年+ 技术开发和面试经验
// - 善于通过追问挖掘候选人的真实技术水平
// - 能够准确识别标准答案、实际经验和深度理解的区别
// - 注重考察解决问题的思路和实际应用能力
//
// """);
PromptTemplate promptTemplate = new PromptTemplate("""
请对候选人的回答进行专业评估。
## 评估维度及权重
### 技术准确性 (30%)
- 概念理解的正确性
- 技术细节的准确程度
- 是否存在明显错误或误解
### 深度与广度 (25%)
- 知识的深入程度
- 相关技术的关联理解
- 是否能举一反三
### 实践经验 (25%)
- 是否有真实项目经验支撑
- 能否结合具体场景说明
- 对技术选型和权衡的理解
### 表达能力 (20%)
- 逻辑清晰度
- 表达的完整性
- 专业术语使用的准确性
## 评分标准
### 优秀 (85-100分)
- 回答准确、深入、有见解
- 能结合实际项目经验
- 表达清晰、逻辑严密
- 展现出深度思考和实践能力
### 良好 (70-84分)
- 回答基本正确,有一定深度
- 有实际应用经验
- 表达较为清晰
- 个别地方可能需要补充
### 及格 (60-69分)
- 回答基本正确但较浅显
- 缺乏深入理解或实践经验
- 表达尚可但不够完整
- 需要进一步考察
### 不及格 (0-59分)
- 回答错误或严重不完整
- 基础概念理解有误
- 表达混乱或逻辑不清
- 明显缺乏相关经验
## 追问策略
### 何时追问
1. **概念模糊**: 候选人给出的概念定义不够准确或完整
2. **缺少细节**: 回答过于宽泛,缺乏具体的技术细节
3. **经验质疑**: 怀疑候选人是否有真实的项目经验
4. **深度探索**: 基础回答正确,想考察更深层次的理解
### 追问类型
1. **澄清追问**: "你刚才提到XX能具体解释一下吗"
2. **场景追问**: "在实际项目中你是如何处理XX问题的"
3. **对比追问**: "XX和YY有什么区别你会如何选择"
4. **深度追问**: "如果遇到XX情况你会如何优化"
### 追问限制
- 单个问题最多追问3次
- 追问应该递进深入,不重复
- 追问后必须给出综合评估
- 避免过度纠缠细节,影响整体面试节奏
## 当前问题
{question}
## 候选人回答
{candidateAnswer}
## 评估任务
### 请分析以下方面
1. **技术准确性**: 回答是否正确,有无技术错误
2. **完整性**: 是否涵盖了问题的关键要点
3. **深度**: 是否展现了深入的理解和思考
4. **实践性**: 是否结合了实际项目经验
5. **表达质量**: 逻辑是否清晰,表达是否准确
### 追问决策逻辑
- 如果回答存在明显不足,制定一个精准的追问来深入考察
- 如果已经追问过2次本次必须结束追问continueAsking: false
- 追问应该针对性强,避免过于宽泛
- 优先考察核心技术能力,避免偏离主题
### AI标准答案要求
请提供一个简洁、准确的技术答案作为参考,突出关键要点。
## 输出格式要求
严格按照以下JSON格式输出确保字段完整且格式正确
{jsonRes}
开始评估:
""");
params.put(
"jsonRes",
"""
{
"feedback": "对回答的具体评价,指出优点和不足",
"suggestions": "具体的改进建议和学习方向",
"aiAnswer": "简洁准确的标准答案要点",
"score": 75.5,
"continueAsking": true,
"followUpQuestion": "具体的追问问题(如果不追问则为空字符串)"
}
"""
);
return new Prompt(List.of(
promptTemplate.createMessage(params)
));
}
/**
* 获取最终报告
*
* @param params 参数
* @return 提示
*/
public static Prompt getFinalReportPrompt(Map<String, Object> params) {
// SystemMessage systemMessage = new SystemMessage("""
// 你是一位资深的技术面试官和HR专家具备丰富的候选人评估经验。你的职责是
//
// ## 核心职能:
// - 基于面试数据进行客观、全面的技术能力评估
// - 提供标准化的面试结果分析和建议
// - 支持招聘决策制定
//
// ## 评估原则:
// 1. **客观性**:基于实际表现数据,避免主观推测
// 2. **全面性**:覆盖技术能力、沟通表达、问题解决等维度
// 3. **标准化**:使用统一的评分体系和输出格式
// 4. **实用性**:提供可执行的改进建议和明确的录用建议
//
// ## 输出要求:
// 严格按照JSON格式输出不得包含任何markdown标记或额外解释
//
// {
// "overallScore": <1-100整数>,
// "overallFeedback": "<综合评价客观描述候选人整体表现150-200字>",
// "technicalAssessment": {
// "Java基础": "掌握良好,对集合框架理解深入。",
// "Spring框架": "熟悉基本使用,但对底层原理理解不足。",
// "数据库": "能够编写常规SQL但在索引优化方面知识欠缺。",
// "<技术领域1>": "<该领域的具体评估>",
// ...
// },
// "strengthsAndWeaknesses": {
// "strengths": ["<具体优势1>", "<具体优势2>", "<具体优势3>"],
// "weaknesses": ["<具体不足1>", "<具体不足2>", "<具体不足3>"]
// },
// "suggestions": [
// "<具体可执行的改进建议1>",
// "<具体可执行的改进建议2>",
// "<具体可执行的改进建议3>",
// "<具体可执行的改进建议4>",
// "<具体可执行的改进建议5>"
// ],
// "hiringRecommendation": "<强烈推荐|推荐|待考虑|不推荐>",
// "hiringReason": "<录用建议的具体理由50-80字>"
// }
//
// ## 评分标准:
// - 90-100分技术优秀表达清晰思维敏捷超出岗位要求
// - 80-89分技术良好基本满足岗位要求有培养潜力
// - 70-79分技术一般需要指导和培养
// - 60-69分技术较弱存在明显知识盲区
// - 60分以下技术不足不符合岗位要求
// """);
PromptTemplate promptTemplate = new PromptTemplate("""
请根据以下候选人信息和面试记录,生成标准化的面试评估报告:
## 核心职能:
- 基于面试数据进行客观、全面的技术能力评估
- 提供标准化的面试结果分析和建议
- 支持招聘决策制定
## 评估原则:
1. **客观性**:基于实际表现数据,避免主观推测
2. **全面性**:覆盖技术能力、沟通表达、问题解决等维度
3. **标准化**:使用统一的评分体系和输出格式
4. **实用性**:提供可执行的改进建议和明确的录用建议
## 输出要求:
严格按照JSON格式输出不得包含任何markdown标记或额外解释
{jsonRes}
## 评分标准:
- 90-100分技术优秀表达清晰思维敏捷超出岗位要求
- 80-89分技术良好基本满足岗位要求有培养潜力
- 70-79分技术一般需要指导和培养
- 60-69分技术较弱存在明显知识盲区
- 60分以下技术不足不符合岗位要求
## 候选人简历信息:
{resume}
## 完整面试记录:
{history}
请开始评估并输出JSON格式的报告。
""");
params.put("jsonRes", """
{
"overallScore": <1-100整数>,
"overallFeedback": "<综合评价客观描述候选人整体表现150-200字>",
"technicalAssessment": {
"Java基础": "掌握良好,对集合框架理解深入。",
"Spring框架": "熟悉基本使用,但对底层原理理解不足。",
"数据库": "能够编写常规SQL但在索引优化方面知识欠缺。",
"<技术领域1>": "<该领域的具体评估>",
...
},
"strengthsAndWeaknesses": {
"strengths": ["<具体优势1>", "<具体优势2>", "<具体优势3>"],
"weaknesses": ["<具体不足1>", "<具体不足2>", "<具体不足3>"]
},
"suggestions": [
"<具体可执行的改进建议1>",
"<具体可执行的改进建议2>",
"<具体可执行的改进建议3>",
"<具体可执行的改进建议4>",
"<具体可执行的改进建议5>"
],
"hiringRecommendation": "<强烈推荐|推荐|待考虑|不推荐>",
"hiringReason": "<录用建议的具体理由50-80字>"
}
""");
return new Prompt(List.of(
promptTemplate.createMessage(params)
));
}
}

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,8 @@
package com.qingqiu.interview.common.utils;
public class UUIDUtils {
public static String getUUID() {
return java.util.UUID.randomUUID().toString().replace("-", "");
}
}

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

@@ -0,0 +1,73 @@
package com.qingqiu.interview.config;
import com.alibaba.cloud.ai.memory.jdbc.MysqlChatMemoryRepository;
import com.alibaba.cloud.ai.memory.redis.RedisChatMemoryRepository;
import com.qingqiu.interview.common.constants.ChatConstant;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
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.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import static com.qingqiu.interview.common.constants.ChatConstant.MAX_MESSAGES;
/**
* 聊天记忆相关配置
*/
@Configuration
public class ChatMemoryConfig {
@Value("${spring.ai.memory.redis.host}")
private String redisHost;
@Value("${spring.ai.memory.redis.port}")
private int redisPort;
@Value("${spring.ai.memory.redis.password}")
private String redisPassword;
@Value("${spring.ai.memory.redis.timeout}")
private int redisTimeout;
@Resource
private DataSource dataSource;
@Bean
public MysqlChatMemoryRepository mysqlChatMemoryRepository() {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
return MysqlChatMemoryRepository.mysqlBuilder()
.jdbcTemplate(jdbcTemplate)
.build();
}
@Primary
@Bean(name = ChatConstant.MYSQL_CHAT_MEMORY_BEAN_NAME)
public MessageWindowChatMemory mysqlChatMemory(MysqlChatMemoryRepository mysqlChatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(mysqlChatMemoryRepository)
.maxMessages(MAX_MESSAGES)
.build();
}
@Bean
public RedisChatMemoryRepository redisChatMemoryRepository() {
return RedisChatMemoryRepository.builder()
.host(redisHost)
.port(redisPort)
// 若没有设置密码则注释该项
.password(redisPassword)
.timeout(redisTimeout)
.build();
}
@Bean(name = ChatConstant.REDIS_CHAT_MEMORY_BEAN_NAME)
public MessageWindowChatMemory redisChatMemory(RedisChatMemoryRepository redisChatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(redisChatMemoryRepository)
.maxMessages(MAX_MESSAGES)
.build();
}
}

View File

@@ -0,0 +1,22 @@
package com.qingqiu.interview.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").
allowedOriginPatterns("*") //允许跨域的域名,可以用*表示允许任何域名使用
// allowedOrigins("*"). //在Springboot2.4对应Spring5.3后在设置allowCredentials(true)的基础上不能直接使用通配符设置allowedOrigins而是需要指定特定的URL。如果需要设置通配符需要通过allowedOriginPatterns指定
.allowedMethods("GET", "POST", "DELETE", "PUT") //允许任何方法post、get等
.allowedHeaders("*") //允许任何请求头
.allowCredentials(true) //带上cookie信息
.exposedHeaders(HttpHeaders.SET_COOKIE).maxAge(3600L); //maxAge(3600)表明在3600秒内不需要再发送预检验请求可以缓存该结果
}
}

View File

@@ -1,14 +1,120 @@
package com.qingqiu.interview.config;
import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
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 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.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;
/**
* DashScope相关配置类
* 主要配置阿里云百炼平台(DashScope)和OpenAI的大模型服务
* 包括API客户端、聊天模型和聊天客户端的配置
*
* @author qingqiu
*/
@Configuration
public class DashScopeConfig {
// 从配置文件中读取DashScope API密钥
@Value("${spring.ai.dashscope.api-key}")
private String dashScopeApiKey;
@Value("${spring.ai.dashscope.chat.options.model}")
private String dashScopeChatModelName;
@Resource(name = ChatConstant.MYSQL_CHAT_MEMORY_BEAN_NAME)
private MessageWindowChatMemory mysqlChatMemory;
/**
* 创建DashScopeApi Bean实例
* 配置了HTTP客户端连接参数包括连接超时和响应超时时间
*
* @return DashScopeApi实例
*/
@Bean
public Generation generation() {
return new Generation();
public DashScopeApi dashScopeApi() {
return DashScopeApi.builder()
.apiKey(dashScopeApiKey)
.restClientBuilder(getRestClientBuilder())
.webClientBuilder(getWebClientBuilder())
.build();
}
/**
* 创建DashScope聊天模型Bean实例
* 配置了最大token数、使用的模型以及返回格式为JSON对象
*
* @return ChatModel实例
*/
@Bean(name = ChatConstant.DASH_SCOPE_CHAT_MODEL_BEAN_NAME)
public ChatModel dashScopeChatModel() {
return DashScopeChatModel.builder()
.dashScopeApi(dashScopeApi())
.defaultOptions(
DashScopeChatOptions.builder()
.withMaxToken(ChatConstant.MAX_COMPLETION_TOKENS)
.withModel(dashScopeChatModelName)
.withResponseFormat(
DashScopeResponseFormat.builder()
.type(DashScopeResponseFormat.Type.JSON_OBJECT)
.build()
)
.build()
)
.build();
}
/**
* 创建默认的聊天客户端Bean实例使用DashScope模型
* 添加了日志记录功能
*
* @return ChatClient实例
*/
@Bean
@Primary
public ChatClient chatClient() {
return ChatClient
.builder(dashScopeChatModel())
.defaultAdvisors(
new SimpleLoggerAdvisor()
)
.build();
}
/**
* 创建基于MySQL存储聊天历史的DashScope聊天客户端Bean实例
* 配置了消息内存管理和日志记录功能
*
* @return ChatClient实例
*/
@Bean(name = ChatConstant.DASH_SCOPE_CHAT_CLIENT_BEAN_NAME)
public ChatClient dashScopeChatClient() {
return ChatClient
.builder(dashScopeChatModel())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(mysqlChatMemory).build(),
new SimpleLoggerAdvisor()
)
.build();
}
}

View File

@@ -0,0 +1,57 @@
package com.qingqiu.interview.config;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONReader;
import com.alibaba.fastjson2.JSONWriter;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
public class Fastjson2RedisSerializer<T> implements RedisSerializer<T> {
// 默认编码
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
// 泛型类型,用于反序列化
private final Class<T> clazz;
public Fastjson2RedisSerializer(Class<T> clazz) {
super();
this.clazz = clazz;
}
/**
* 序列化:将对象转换为字节数组
*/
@Override
public byte[] serialize(T t) throws SerializationException {
if (t == null) {
return new byte[0];
}
try {
// 使用 Fastjson2 序列化对象,并写入字节数组
// 配置写入特性WriteClassName 确保反序列化时能识别类型,如果不需要,可以移除。
return JSON.toJSONBytes(t, JSONWriter.Feature.WriteClassName);
} catch (Exception ex) {
throw new SerializationException("Could not serialize object with Fastjson2", ex);
}
}
/**
* 反序列化:将字节数组转换为对象
*/
@Override
public T deserialize(byte[] bytes) throws SerializationException {
if (bytes == null || bytes.length <= 0) {
return null;
}
try {
// 使用 Fastjson2 反序列化字节数组
// 配置读取特性SupportAutoType确保可以正确读取带有类名信息的JSON
return JSON.parseObject(bytes, clazz, JSONReader.Feature.SupportAutoType);
} catch (Exception ex) {
throw new SerializationException("Could not deserialize object with Fastjson2", ex);
}
}
}

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

@@ -0,0 +1,32 @@
package com.qingqiu.interview.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
// 使用Fastjson2RedisSerializer来序列化和反序列化redis的value值
Fastjson2RedisSerializer<Object> fastjson2RedisSerializer = new Fastjson2RedisSerializer<>(Object.class);
template.setValueSerializer(fastjson2RedisSerializer);
template.setHashValueSerializer(fastjson2RedisSerializer);
template.afterPropertiesSet();
return template;
}
}

View File

@@ -1,13 +1,14 @@
package com.qingqiu.interview.controller;
import com.alibaba.dashscope.common.Role;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.qingqiu.interview.common.res.R;
import com.qingqiu.interview.entity.AiSessionLog;
import com.qingqiu.interview.service.IAiSessionLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.web.bind.annotation.GetMapping;
@@ -38,7 +39,7 @@ public class AiSessionLogController {
return R.success(service.list(
new LambdaQueryWrapper<AiSessionLog>()
.eq(AiSessionLog::getToken, sessionId)
.ne(AiSessionLog::getRole, Role.SYSTEM.getValue())
.ne(AiSessionLog::getRole, MessageType.SYSTEM.getValue())
));
}

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

@@ -3,12 +3,10 @@ package com.qingqiu.interview.controller;
import com.qingqiu.interview.common.res.R;
import com.qingqiu.interview.dto.QuestionProgressPageParams;
import com.qingqiu.interview.entity.InterviewQuestionProgress;
import com.qingqiu.interview.service.IInterviewQuestionProgressService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
/**
* <p>
@@ -27,6 +25,7 @@ public class InterviewQuestionProgressController {
/**
* 面试问题进度列表
*
* @param params 查询参数
* @return data
*/
@@ -35,4 +34,16 @@ public class InterviewQuestionProgressController {
return R.success(service.pageList(params));
}
/**
* 获取面试问题进度详情
*
* @param id id
* @return data
*/
@GetMapping("/{id}")
public R<InterviewQuestionProgress> getDetail(@PathVariable Long id) {
return R.success(service.getById(id));
}
}

View File

@@ -25,5 +25,10 @@ public class InterviewStartRequest {
/** 生成的面试题目数量 */
private Integer totalQuestions = 10;
/**
* 岗位要求
*/
private String jobRequirements;
// 简历文件通过MultipartFile单独传递
}

View File

@@ -31,6 +31,9 @@ public class InterviewSession implements Serializable {
@TableField("resume_content")
private String resumeContent;
@TableField("job_requirements")
private String jobRequirements;
@TableField("extracted_skills")
private String extractedSkills;
@TableField("interview_type")

View File

@@ -0,0 +1,11 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
public record EvaluateAiRes(@JsonProperty("feedback") String feedback,
@JsonProperty("suggestions") String suggestions,
@JsonProperty("aiAnswer") String aiAnswer,
@JsonProperty("score") double score,
@JsonProperty("continueAsking") boolean continueAsking,
@JsonProperty("followUpQuestion") String followUpQuestion) {
}

View File

@@ -0,0 +1,10 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record ExtractSkillAiRes(
@JsonProperty("skills") List<String> skills
) {
}

View File

@@ -0,0 +1,27 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
public record InterviewReportAiRes(
@JsonProperty("overallScore") String overallScore,
@JsonProperty("overallFeedback") String overallFeedback,
@JsonProperty("technicalAssessment") Map<String, String> technicalAssessment,
@JsonProperty("strengthsAndWeaknesses") StrengthsAndWeaknesses strengthsAndWeaknesses,
@JsonProperty("suggestions") List<String> suggestions,
@JsonProperty("hiringRecommendation") String hiringRecommendation,
@JsonProperty("hiringReason") String hiringReason
) {
/**
* 内部 Record对应 "strengthsAndWeaknesses" 对象。
*/
public record StrengthsAndWeaknesses(
@JsonProperty("strengths") List<String> strengths,
@JsonProperty("weaknesses") List<String> weaknesses
) {}
}

View File

@@ -0,0 +1,26 @@
package com.qingqiu.interview.entity.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class QuestionAiRes {
/**
* 内部实体类:对应 JSON 数组中的每个元素。
* { "id": "ai-gen-1", "content": "问题内容..." }
*/
public record Question(
@JsonProperty("id") String id,
@JsonProperty("content") String content
) {}
/**
* 外部实体类:对应整个 JSON 响应的顶层结构。
* { "questions": [...] }
*/
public record Wrapper(
@JsonProperty("questions") List<Question> questions
) {}
}

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,29 +0,0 @@
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;
/**
* <h1></h1>
*
* @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);
}

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;
/**
* @program: ai-interview
* @description: 工作台接口
* @author: huangpeng
* @create: 2025-11-07 14:54
**/
public interface DashboardService {
@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));
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

@@ -1,15 +1,17 @@
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 com.qingqiu.interview.entity.ai.EvaluateAiRes;
import com.qingqiu.interview.entity.ai.InterviewReportAiRes;
import com.qingqiu.interview.entity.ai.QuestionAiRes;
import java.util.List;
/**
* <h1>
* 面试接入AI的接口
* 面试接入AI的接口
* </h1>
*
* @author qingqiu
@@ -23,38 +25,38 @@ public interface InterviewAiService {
* @param resumeContent 简历文本
* @return 包含技能列表的JSON对象
*/
JSONObject extractSkillsFromResume(String resumeContent);
List<String> extractSkillsFromResume(String resumeContent);
/**
* 根据技能动态生成面试题目
*
* @param skills 技能列表
* @param skills 技能列表
* @param resumeContent 简历内容
* @param count 需要生成的题目数量
* @param count 需要生成的题目数量
* @return 包含问题列表的JSON对象
*/
JSONObject generateQuestionsOfAi(String sessionId, List<String> skills, String resumeContent, int count);
List<QuestionAiRes.Question> generateQuestionsOfAi(String sessionId, List<String> skills, String jobRequirements, String resumeContent, int count);
JSONObject generateQuestionOfLocal(String sessionId, List<Question> questions, List<String> skills, String resumeContent, int count);
List<QuestionAiRes.Question> generateQuestionOfLocal(String sessionId, List<Question> questions, List<String> skills, String jobRequirements, String resumeContent, int count);
/**
* 评估用户的回答
*
* @param question 问题内容
* @param userAnswer 用户的回答
* @param context 可选的上下文(之前的问答历史)
* @param question 问题内容
* @param userAnswer 用户的回答
* @param context 可选的上下文(之前的问答历史)
* @return 包含评估结果的JSON对象
*/
JSONObject evaluateAnswer(String sessionId, String question, String userAnswer, List<InterviewQuestionProgress> context);
EvaluateAiRes evaluateAnswer(String sessionId, String question, String userAnswer, List<InterviewQuestionProgress> context);
/**
* 生成最终的面试评估报告
*
* @param session 面试会话信息
* @param progressList 整个面试的问答记录
* @param session 面试会话信息
* @param progressList 整个面试的问答记录
* @return 包含最终报告的JSON对象
*/
JSONObject generateFinalReport(InterviewSession session, List<InterviewQuestionProgress> progressList);
InterviewReportAiRes generateFinalReport(InterviewSession session, List<InterviewQuestionProgress> progressList);
String generateFirstQuestion(String sessionId, String candidateName, String questionContent);

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,138 +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.entity.Question;
import com.qingqiu.interview.service.llm.LlmService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 {
/**
* @program: ai-interview
* @description: 题型分类
* @author: huangpeng
* @create: 2025-11-06 19:59
**/
public interface QuestionClassificationService {
private final LlmService llmService;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 使用AI对题目进行分类
*/
public List<Question> classifyQuestions(String rawContent) {
log.info("开始使用AI分类题目内容长度: {}", rawContent.length());
String prompt = buildClassificationPrompt(rawContent);
String aiResponse = llmService.chat(prompt);
log.info("AI分类响应: {}", aiResponse);
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

@@ -1,95 +0,0 @@
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.ai.factory.AIClientManager;
import com.qingqiu.interview.annotation.AiChatLog;
import com.qingqiu.interview.common.enums.LLMProvider;
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;
/**
* <h1></h1>
*
* @author qingqiu
* @date 2025/9/18 12:56
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {
private final AIClientManager aiClientManager;
private final IAiSessionLogService aiSessionLogService;
@Override
@AiChatLog
public ChatVO createChat(ChatDTO dto) {
LLMProvider llmProvider = LLMProvider.fromCode(dto.getAiModel());
List<Message> messages = new ArrayList<>();
AtomicInteger tokens = new AtomicInteger();
// 如果会话id不为空 则从数据库中获取会话记录
if (dto.getSessionId() != null) {
List<AiSessionLog> list = aiSessionLogService.list(
new LambdaQueryWrapper<AiSessionLog>()
.eq(AiSessionLog::getToken, dto.getSessionId())
.eq(AiSessionLog::getDataType, dto.getDataType())
.orderByAsc(AiSessionLog::getCreatedTime)
);
if (CollectionUtil.isNotEmpty(list)) {
messages.addAll(list.stream().map(data -> {
tokens.getAndAdd(AIUtils.getPromptTokens(data.getContent()));
return AIUtils.createMessage(data.getRole(), data.getContent());
}).toList());
}
}
messages.add(AIUtils.createMessage(dto.getRole(), dto.getContent()));
List<Message> 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 "";
}
}

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

@@ -1,24 +1,29 @@
package com.qingqiu.interview.service.impl;
import com.alibaba.dashscope.common.Role;
import cn.hutool.core.map.MapUtil;
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.common.constants.ChatConstant;
import com.qingqiu.interview.common.utils.PromptTemplateUtils;
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.entity.ai.EvaluateAiRes;
import com.qingqiu.interview.entity.ai.ExtractSkillAiRes;
import com.qingqiu.interview.entity.ai.InterviewReportAiRes;
import com.qingqiu.interview.entity.ai.QuestionAiRes;
import com.qingqiu.interview.service.InterviewAiService;
import com.qingqiu.interview.vo.ChatVO;
import lombok.RequiredArgsConstructor;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.jetbrains.annotations.NotNull;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
import java.util.Map;
import java.util.UUID;
/**
* <h1></h1>
@@ -28,114 +33,168 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Service
@RequiredArgsConstructor(onConstructor_ = {@Autowired, @Lazy})
public class InterviewAiServiceImpl implements InterviewAiService {
private final ChatService chatService;
@Resource
private ChatClient chatClient;
@Resource(name = ChatConstant.DASH_SCOPE_CHAT_CLIENT_BEAN_NAME)
private ChatClient dashScopeChatClient;
@Resource(name = ChatConstant.OPEN_AI_CHAT_CLIENT_BEAN_NAME)
private ChatClient openAiChatClient;
@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.getValue())
.setDataType(CommonConstant.ONE);
ChatVO chatVO = chatService.createChat(chatDTO);
return JSONObject.parse(chatVO.getContent());
public List<String> extractSkillsFromResume(String resumeContent) {
ExtractSkillAiRes entity = chatClient
.prompt(PromptTemplateUtils.getExtractSkillsPrompt(resumeContent))
.call()
.entity(ExtractSkillAiRes.class);
assert entity != null;
return entity.skills();
}
@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.getValue())
.setDataType(CommonConstant.ONE);
ChatVO chatVO = chatService.createChat(chatDTO);
return JSON.parseObject(chatVO.getContent());
public List<QuestionAiRes.Question> generateQuestionsOfAi(String sessionId,
List<String> skills,
String jobRequirements,
String resumeContent,
int count) {
// TODO: 考虑删除这些注释掉的旧代码实现
// 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
// );
Map<String, Object> params = MapUtil.<String, Object>builder()
.put("count", count)
.put("jobRequirements", jobRequirements)
.put("skills", JSONObject.toJSONString(skills))
.put("resume", resumeContent)
.build();
Prompt aiInterviewerPrompt = PromptTemplateUtils.getAiInterviewerPrompt(params);
QuestionAiRes.Wrapper entity = openAiChatClient
.prompt(aiInterviewerPrompt)
.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))
// .call()
//
// .content()
// ;
// ChatDTO chatDTO = new ChatDTO()
// .setSessionId(sessionId)
// .setContent(prompt)
// .setRole(Role.SYSTEM.getValue())
// .setDataType(CommonConstant.ONE);
// ChatVO chatVO = chatService.createChat(chatDTO);
return entity.questions();
}
@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解析会报错
public List<QuestionAiRes.Question> generateQuestionOfLocal(String sessionId,
List<Question> questions,
List<String> skills,
String jobRequirements,
String resumeContent,
int count) {
Map<String, Object> params = MapUtil.<String, Object>builder()
.put("count", count)
.put("jobRequirements", jobRequirements)
.put("skills", JSONObject.toJSONString(skills))
.put("resume", resumeContent)
.build();
【候选人技术栈】:
%s
【候选人简历】:
[%s]
【题库JSON】:
%s
""", count, skillsStr, resumeContent, JSONObject.toJSONString(questions));
ChatDTO chatDTO = new ChatDTO()
.setSessionId(sessionId)
.setContent(prompt)
.setRole(Role.SYSTEM.getValue())
.setDataType(CommonConstant.ONE);
ChatVO chatVO = chatService.createChat(chatDTO);
return JSON.parseObject(chatVO.getContent());
Prompt aiInterviewerPrompt = PromptTemplateUtils.getLocalInterviewPrompt(params);
QuestionAiRes.Wrapper entity = openAiChatClient.prompt(aiInterviewerPrompt)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, sessionId))
.call()
.entity(QuestionAiRes.Wrapper.class);
assert entity != null;
return entity.questions();
// TODO: 考虑删除这些注释掉的旧代码实现
// 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.getValue())
// .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) {
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()))
.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" +
"5. 追问最好有限制不要无限制的向下追问注意追问是支线而非主线追问至多3个问题之后必须切回主线\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()
.setSessionId(sessionId)
.setContent(prompt)
.setRole(Role.SYSTEM.getValue())
.setDataType(CommonConstant.ONE);
ChatVO chatVO = chatService.createChat(chatDTO);
return JSON.parseObject(chatVO.getContent());
// 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" +
// "5. 追问最好有限制不要无限制的向下追问注意追问是支线而非主线追问至多3个问题之后必须切回主线\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()
// .setSessionId(sessionId)
// .setContent(prompt)
// .setRole(Role.SYSTEM.getValue())
// .setDataType(CommonConstant.ONE);
// ChatVO chatVO = chatService.createChat(chatDTO);
Map<String, Object> params = MapUtil.<String, Object>builder()
.put("question", question)
.put("candidateAnswer", userAnswer)
.build();
Prompt prompt = PromptTemplateUtils.getEvaluatePrompt(params);
return openAiChatClient
.prompt(prompt)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, sessionId))
.call()
.entity(EvaluateAiRes.class);
}
@Override
public JSONObject generateFinalReport(InterviewSession session, List<InterviewQuestionProgress> progressList) {
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()))
@@ -148,23 +207,26 @@ public class InterviewAiServiceImpl implements InterviewAiService {
// "{\"overallScore\": 88.0, \"summary\": \"...\", \"strengths\": [\"...\"], \"weaknesses\": [\"...\"]}\n\n" +
// "候选人姓名:" + session.getCandidateName() + "\n" +
// "面试完整记录:\n" + transcript;
String prompt = buildFinalReportPrompt(session, progressList);
ChatDTO chatDTO = new ChatDTO()
.setRole(Role.SYSTEM.getValue())
.setDataType(CommonConstant.ONE)
.setContent(prompt);
ChatVO chatVO = chatService.createChat(chatDTO);
return JSON.parseObject(chatVO.getContent());
// ChatDTO chatDTO = new ChatDTO()
// .setRole(Role.SYSTEM.getValue())
// .setDataType(CommonConstant.ONE)
// .setContent(prompt);
// ChatVO chatVO = chatService.createChat(chatDTO);
Map<String, Object> params = getFinalReportParams(session, progressList);
Prompt prompt = PromptTemplateUtils.getFinalReportPrompt(params);
return openAiChatClient.prompt(prompt)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, UUID.randomUUID().toString().replace("-", "")))
.call()
.entity(InterviewReportAiRes.class);
}
private String buildFinalReportPrompt(InterviewSession session, List<InterviewQuestionProgress> progressList) {
@NotNull
private static Map<String, Object> getFinalReportParams(InterviewSession session, List<InterviewQuestionProgress> progressList) {
StringBuilder historyBuilder = new StringBuilder();
for (InterviewQuestionProgress progress : progressList) {
historyBuilder.append(
String.format("\n【问题】: %s\n【回答】: %s\n【AI单题反馈】: %s\n【AI单题建议】: %s\n【AI单题评分】: %s/5.0\n",
progress.getQuestionContent(),
progress.getQuestionContent(),
progress.getUserAnswer(),
progress.getFeedback(),
progress.getSuggestions(),
@@ -172,6 +234,27 @@ public class InterviewAiServiceImpl implements InterviewAiService {
)
);
}
return MapUtil.<String, Object>builder()
.put("resume", session.getResumeContent())
.put("history", historyBuilder.toString())
.build();
}
/*
private String buildFinalReportPrompt(InterviewSession session, List<InterviewQuestionProgress> progressList) {
StringBuilder historyBuilder = new StringBuilder();
for (InterviewQuestionProgress progress : progressList) {
historyBuilder.append(
String.format("\n【问题】: %s\n【回答】: %s\n【AI单题反馈】: %s\n【AI单题建议】: %s\n【AI单题评分】: %s/5.0\n",
progress.getQuestionContent(),
progress.getUserAnswer(),
progress.getFeedback(),
progress.getSuggestions(),
progress.getScore()
)
);
}
return String.format("""
你是一位资深的HR和技术总监。请根据以下候选人的简历、完整的面试问答历史和AI对每一题的初步评估给出一份全面、专业、有深度的最终面试报告。
@@ -206,22 +289,36 @@ public class InterviewAiServiceImpl implements InterviewAiService {
%s
""", session.getResumeContent(), historyBuilder.toString());
}
*/
@Override
public String generateFirstQuestion(String sessionId, String candidateName, String questionContent) {
// ChatDTO chatDTO = new ChatDTO()
// .setSessionId(sessionId)
// .setRole(Role.SYSTEM.getValue())
// .setDataType(CommonConstant.ONE)
// .setContent(prompt);
// ChatVO chatVO = chatService.createChat(chatDTO);
// return chatVO.getContent();
String prompt = String.format("""
你是一位专业的技术面试官。现在要开始面试,候选人是 %s。
\s
第一个问题是:%s
\s
请以友好但专业的语气提出这个问题,可以适当添加一些引导性的话语。
""", candidateName, questionContent);
ChatDTO chatDTO = new ChatDTO()
.setSessionId(sessionId)
.setRole(Role.SYSTEM.getValue())
.setDataType(CommonConstant.ONE)
.setContent(prompt);
ChatVO chatVO = chatService.createChat(chatDTO);
return chatVO.getContent();
严格按照JSON格式输出不得包含任何markdown标记或额外解释
请返回JSON格式的数据:\s
{
"content": "xxx"
}
\s""", candidateName, questionContent);
String content = openAiChatClient.prompt()
.user(prompt)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, sessionId))
.call()
.content();
return JSON.parseObject(content).getString("content");
}
}

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

@@ -37,9 +37,10 @@ public class InterviewQuestionProgressServiceImpl extends ServiceImpl<InterviewQ
InterviewQuestionProgress.Status.COMPLETED.name()
)
)
.orderByDesc(InterviewQuestionProgress::getCreatedTime)
.orderByAsc(InterviewQuestionProgress::getStatus)
.orderByDesc(InterviewQuestionProgress::getUpdatedTime)
.orderByDesc(InterviewQuestionProgress::getCreatedTime)
);
}

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;
@@ -12,6 +11,9 @@ import com.qingqiu.interview.dto.InterviewReportResponse;
import com.qingqiu.interview.dto.InterviewStartRequest;
import com.qingqiu.interview.dto.SubmitAnswerDTO;
import com.qingqiu.interview.entity.*;
import com.qingqiu.interview.entity.ai.EvaluateAiRes;
import com.qingqiu.interview.entity.ai.InterviewReportAiRes;
import com.qingqiu.interview.entity.ai.QuestionAiRes;
import com.qingqiu.interview.mapper.InterviewEvaluationMapper;
import com.qingqiu.interview.mapper.InterviewMessageMapper;
import com.qingqiu.interview.mapper.InterviewSessionMapper;
@@ -32,10 +34,9 @@ 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.Objects;
import java.util.UUID;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
@@ -61,12 +62,14 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
private final DocumentParserManager documentParserManager;
private final Map<String, Integer> flowedQuestions = new ConcurrentHashMap<>();
@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);
String resumeContent = readPdfFile(file);
InterviewSession session = new InterviewSession();
session.setSessionId(sessionId);
@@ -76,13 +79,14 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
session.setStatus(InterviewSession.Status.ACTIVE.name());
session.setTotalQuestions(dto.getTotalQuestions());
session.setModel(dto.getModel());
session.setJobRequirements(dto.getJobRequirements());
this.baseMapper.insert(session); // 先插入以获取ID
// 2. 调用AI服务从简历提取技能
JSONObject skillsJson = aiService.extractSkillsFromResume(resumeContent);
List<String> skills = aiService.extractSkillsFromResume(resumeContent);
// ---> 解析AI返回的JSON数据获取技能列表 <---
List<String> skills = skillsJson.getList("skills", String.class);
session.setExtractedSkills(skillsJson.toJSONString());
session.setExtractedSkills(JSONObject.toJSONString(skills));
// 3. 准备面试问题(本地 + AI生成
if (dto.getModel().equals("local")) {
@@ -94,11 +98,15 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
// 4. 更新会话信息
this.baseMapper.updateById(session);
InterviewQuestionProgress nextQuestion = progressService.getNextQuestion(sessionId);
aiService.generateFirstQuestion(session.getSessionId(), session.getCandidateName(), nextQuestion.getQuestionContent());
String aiRes = aiService.generateFirstQuestion(session.getSessionId(),
session.getCandidateName(),
nextQuestion.getQuestionContent()
);
saveMessage(sessionId,
InterviewMessage.MessageType.QUESTION.name(),
InterviewMessage.Sender.AI.name(),
nextQuestion.getQuestionContent(),
// nextQuestion.getQuestionContent(),
aiRes,
nextQuestion.getId()
);
return session;
@@ -107,25 +115,23 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
private void aiGenerateQuestions(InterviewSession session, List<String> skills) {
List<InterviewQuestionProgress> progressList = new ArrayList<>();
JSONObject aiQuestionsJson = aiService.generateQuestionsOfAi(
List<QuestionAiRes.Question> aiQuestionRes = aiService.generateQuestionsOfAi(
session.getSessionId(),
skills,
session.getJobRequirements(),
session.getResumeContent(),
session.getTotalQuestions()
);
// ---> 解析AI返回的JSON数据获取问题列表 <---
JSONArray questions = aiQuestionsJson.getJSONArray("questions");
if (questions != null) {
questions.forEach(item -> {
JSONObject q = (JSONObject) item;
if (CollectionUtil.isNotEmpty(aiQuestionRes)) {
for (QuestionAiRes.Question aiQuestionRe : aiQuestionRes) {
InterviewQuestionProgress progress = new InterviewQuestionProgress();
progress.setSessionId(session.getSessionId());
progress.setQuestionId(0L); // AI生成的问题没有本地ID
// ---> 解析单个问题内容 <---
progress.setQuestionContent(q.getString("content"));
progress.setQuestionContent(aiQuestionRe.content());
progress.setStatus(InterviewQuestionProgress.Status.DEFAULT.name());
progressList.add(progress);
});
}
}
// 批量保存问题进度
if (CollectionUtil.isNotEmpty(progressList)) {
@@ -158,19 +164,19 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
);
}
// ai调用返回的内容进行提取
JSONObject jsonObject = aiService.generateQuestionOfLocal(
List<QuestionAiRes.Question> questions = aiService.generateQuestionOfLocal(
session.getSessionId(),
localQuestionDataList,
skills,
session.getJobRequirements(),
session.getResumeContent(),
session.getTotalQuestions()
);
JSONArray questionIds = jsonObject.getJSONArray("question_ids");
List<Long> list = questionIds.toList(Long.class);
Set<String> resQuestionIds = questions.stream().map(QuestionAiRes.Question::id).collect(Collectors.toSet());
// 查询返回的内容 并将其保存为问题进度的相关数据
List<Question> questionList = questionService.list(
new LambdaQueryWrapper<Question>()
.in(Question::getId, list)
.in(Question::getId, resQuestionIds)
);
List<InterviewQuestionProgress> progressList = new ArrayList<>();
questionList.forEach(q -> {
@@ -256,7 +262,8 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
.eq(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.COMPLETED.name())
.orderByAsc(InterviewQuestionProgress::getId)
);
JSONObject evalResult = aiService.evaluateAnswer(
Integer flowedQuestionCount = this.flowedQuestions.getOrDefault(currentProgress.getSessionId(), 0);
EvaluateAiRes evaluateAiRes = aiService.evaluateAnswer(
currentProgress.getSessionId(),
currentProgress.getQuestionContent(),
dto.getAnswer(),
@@ -264,40 +271,45 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
);
// 3. ---> 解析AI返回的JSON评估结果并存入数据库 <---
currentProgress.setFeedback(evalResult.getString("feedback"));
currentProgress.setSuggestions(evalResult.getString("suggestions"));
currentProgress.setAiAnswer(evalResult.getString("aiAnswer"));
currentProgress.setScore(evalResult.getBigDecimal("score"));
currentProgress.setFeedback(evaluateAiRes.feedback());
currentProgress.setSuggestions(evaluateAiRes.suggestions());
currentProgress.setAiAnswer(evaluateAiRes.aiAnswer());
currentProgress.setScore(BigDecimal.valueOf(evaluateAiRes.score()));
currentProgress.setStatus(InterviewQuestionProgress.Status.COMPLETED.name());
progressService.updateById(currentProgress);
// 4. 将单题评估结果存入 evaluation 表用于分析
saveEvaluationRecord(currentProgress, evalResult);
saveEvaluationRecord(currentProgress, evaluateAiRes);
// 5. ---> 解析AI的是否追问判断并处理追问逻辑 <---
if (evalResult.getBooleanValue("continueAsking", false)) {
if (evaluateAiRes.continueAsking()) {
// 创建一个新的、状态为ACTIVE的追问问题
InterviewQuestionProgress followUp = new InterviewQuestionProgress();
followUp.setSessionId(currentProgress.getSessionId());
followUp.setQuestionId(0L); // 追问问题没有本地ID
followUp.setQuestionContent(evalResult.getString("followUpQuestion"));
followUp.setQuestionContent(evaluateAiRes.followUpQuestion());
followUp.setStatus(InterviewQuestionProgress.Status.ACTIVE.name()); // 直接设为激活状态,作为下一个问题
progressService.save(followUp);
// 记录追问题目数量
flowedQuestionCount++;
this.flowedQuestions.put(currentProgress.getSessionId(), flowedQuestionCount);
return followUp; // 将这个新的追问问题返回给前端
}
// 清空追问题目数量
this.flowedQuestions.put(currentProgress.getSessionId(), 0);
return currentProgress;
}
private void saveEvaluationRecord(InterviewQuestionProgress progress, JSONObject evalResult) {
private void saveEvaluationRecord(InterviewQuestionProgress progress, EvaluateAiRes evaluateAiRes) {
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"));
evaluation.setAiFeedback(evaluateAiRes.feedback());
evaluation.setScore(BigDecimal.valueOf(evaluateAiRes.score()));
evaluationMapper.insert(evaluation);
}
@@ -319,12 +331,12 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
}
// 2. 调用AI服务生成最终报告
JSONObject finalReportJson = aiService.generateFinalReport(session, completedProgresses);
InterviewReportAiRes interviewReportAiRes = aiService.generateFinalReport(session, completedProgresses);
// 3. ---> 解析AI返回的最终报告JSON更新会话状态 <---
session.setStatus(InterviewSession.Status.COMPLETED.name());
session.setScore(finalReportJson.getBigDecimal("overallScore"));
session.setFinalReport(finalReportJson.toJSONString());
session.setScore(new BigDecimal(interviewReportAiRes.overallScore()));
session.setFinalReport(JSONObject.toJSONString(interviewReportAiRes));
this.baseMapper.updateById(session);
return session;
@@ -387,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. 获取简历解析器
@@ -398,7 +411,7 @@ public class InterviewServiceImpl extends ServiceImpl<InterviewSessionMapper, In
private InterviewMessage saveMessage(String sessionId, String messageType, String sender,
String content, Long questionId) {
String content, Long questionId) {
int nextOrder = messageMapper.selectMaxOrderBySessionId(sessionId) + 1;
InterviewMessage message = new InterviewMessage()
.setSessionId(sessionId)

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,29 +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.llm.LlmService;
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;
@@ -39,11 +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 LlmService llmService;
private final IQuestionCategoryService questionCategoryService;
private final ChatClient chatClient;
/**
* 分页查询题库
*/
@@ -98,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);
}
/**
@@ -129,16 +137,18 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
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);
QuestionDeduplicationAiRes entity = chatClient.prompt().user(prompt).call().entity(QuestionDeduplicationAiRes.class);
assert entity != null;
// 调用AI
log.info("AI返回内容: {}", chat);
JSONObject parse = JSONObject.parse(chat);
JSONArray questionsIds = parse.getJSONArray("questions");
List<Long> list = questionsIds.toList(Long.class);
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)
@@ -157,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, .....]
}
分类规则:
1. 只返回JSON不要其他解释文字
2. 请严格按照API接口形式返回不要返回任何额外的文字内容包括'```json```'!!!!
3. 请严格按照网络接口的形式返回JSON数据
【请处理以下数据列表】:
%s
""", jsonObject.toJSONString());
return PromptTemplate.builder()
.renderer(
StTemplateRenderer.builder()
.startDelimiterToken('<')
.endDelimiterToken('>')
.build()
)
.template("""
请对以下题库JSON数据进行智能去重处理。
## 任务说明
识别并移除语义相似或表达意思基本相同的重复题目,只保留每个独特题目的一个版本。
## 语义相似度判断标准
1. 核心意思相同:即使表述不同,但考察的知识点和答案逻辑一致
2. 同义替换:使用同义词、近义词但意思不变的题目
3. 句式变换:主动被动语态转换、疑问词替换等句式变化
4. 冗余表述:增加了无关修饰词但核心内容相同的题目
## 处理规则
- 对语义相似的题目组,只保留其中一条数据
- 保留原则:选择表述最清晰、最完整的那条
- 如果难以判断保留ID较小或创建时间较早的那条
## 输出要求
1. 只返回JSON不要其他解释文字
2. 请严格按照API接口形式返回不要返回任何额外的文字内容包括'```json```'!!!!
3. 请严格按照网络接口的形式返回JSON数据
{
"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());
}
/**
@@ -211,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

@@ -1,35 +0,0 @@
package com.qingqiu.interview.service.llm;
import com.qingqiu.interview.common.enums.LLMProvider;
public interface LlmService {
/**
* 与模型进行单轮对话
* @param prompt 提示词
* @return ai回复
*/
String chat(String prompt);
String chat(String prompt, LLMProvider provider);
/**
* 与模型进行多轮对话
* @param prompt 提示词
* @param token 会话token
* @return ai回复
*/
String chat(String prompt, String token);
/**
* 与模型进行多轮对话 指定模型
* @param prompt 提示词
* @param model 模型名称
* @param token 会话token
* @return ai回复
*/
String chat(String prompt, String token, LLMProvider provider);
Integer getPromptTokens(String prompt);
}

View File

@@ -1,191 +0,0 @@
package com.qingqiu.interview.service.llm.qwen;
import cn.hutool.core.collection.CollectionUtil;
import com.alibaba.dashscope.aigc.generation.Generation;
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 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.entity.AiSessionLog;
import com.qingqiu.interview.mapper.AiSessionLogMapper;
import com.qingqiu.interview.service.llm.LlmService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import static com.qingqiu.interview.common.utils.AIUtils.createMessage;
@Slf4j
@Service("qwenService")
@RequiredArgsConstructor
public class QwenService implements LlmService {
private final Generation generation;
private final AiSessionLogMapper aiSessionLogMapper;
@Value("${dashscope.api-key}")
private String apiKey;
private final AIClientManager aiClientManager;
@Override
public String chat(String prompt) {
// log.info("开始调用API....");
// long l = System.currentTimeMillis();
return chat(prompt, LLMProvider.DEEPSEEK);
// GenerationParam param = GenerationParam.builder()
// .model(DEEPSEEK_3) // 可根据需要更换模型
// .messages(Collections.singletonList(createMessage(Role.USER.getValue(), prompt)))
// .resultFormat(GenerationParam.ResultFormat.MESSAGE)
// .apiKey(apiKey)
// .build();
//
// GenerationResult result = null;
// try {
// result = generation.call(param);
// log.info("调用成功,耗时: {} ms", System.currentTimeMillis() - l);
// log.debug("响应结果: {}", result.getOutput().getChoices().get(0).getMessage().getContent());
// return result.getOutput().getChoices().get(0).getMessage().getContent();
// } catch (ApiException | InputRequiredException e) {
// throw new RuntimeException("调用AI服务失败", e);
// } catch (NoApiKeyException e) {
// throw new RuntimeException("请检查API密钥是否正确", e);
// }
}
@Override
public String chat(String prompt, LLMProvider provider) {
return aiClientManager.getClient(provider).chatCompletion(prompt);
}
@Override
public String chat(String prompt, String token) {
return chat(prompt, token, LLMProvider.DEEPSEEK);
// // 调用AI模型
// try {
// log.info("开始调用API....");
// long l = System.currentTimeMillis();
// GenerationParam param = GenerationParam.builder()
// .model(DEEPSEEK_3_1) // 可根据需要更换模型
// .messages(messages)
// .resultFormat(GenerationParam.ResultFormat.MESSAGE)
// .apiKey(apiKey)
// .build();
//
// GenerationResult result = generation.call(param);
// log.info("调用成功,耗时: {} ms", System.currentTimeMillis() - l);
// String aiResponse = result.getOutput().getChoices().get(0).getMessage().getContent();
// log.debug("响应结果: {}", aiResponse);
// // 存储用户提问
// AiSessionLog userLog = new AiSessionLog();
// userLog.setToken(token);
// userLog.setRole(Role.USER.getValue());
// userLog.setContent(prompt);
// aiSessionLogMapper.insert(userLog);
//
// // 存储AI回复
// AiSessionLog aiLog = new AiSessionLog();
// aiLog.setToken(token);
// aiLog.setRole(Role.ASSISTANT.getValue());
// aiLog.setContent(aiResponse);
// aiSessionLogMapper.insert(aiLog);
//
// return aiResponse;
// } catch (ApiException | NoApiKeyException | InputRequiredException e) {
// throw new RuntimeException("调用AI服务失败", e);
// }
}
@Override
public String chat(String prompt, String token, LLMProvider provider) {
// 根据token查询会话记录
List<AiSessionLog> aiSessionLogs = aiSessionLogMapper.selectList(
new LambdaQueryWrapper<AiSessionLog>()
.eq(AiSessionLog::getToken, token)
.orderByDesc(AiSessionLog::getCreatedTime)
);
// 构造发给ai的消息
List<Message> messages = new ArrayList<>();
if (CollectionUtil.isNotEmpty(aiSessionLogs)) {
// 预估tokens
StringBuilder sb = new StringBuilder();
for (AiSessionLog aiSessionLog : aiSessionLogs) {
sb.append(aiSessionLog.getContent());
}
// 加上本次对话内容
sb.append(prompt);
Integer promptTokens = getPromptTokens(sb.toString());
// 如果token大于了模型上限则执行丢弃操作
int size = aiSessionLogs.size();
log.info("当前会话id: {}, tokens: {}", token, promptTokens);
// 假设模型上限为30000个token根据实际模型调整
int maxTokens = 100000;
if (promptTokens > maxTokens) {
// 需要丢弃30%的会话记录(按时间倒序,丢弃最旧的)
int discardCount = (int) (size * 0.3);
// 从当前会话记录列表中移除旧的会话记录,而不是删除数据库中的记录
for (int i = 0; i < discardCount; i++) {
aiSessionLogs.remove(aiSessionLogs.size() - 1);
}
}
// 移除旧记录后再按时间正序排序(最旧的在前面,最新的在后面)
aiSessionLogs = aiSessionLogs.stream()
.sorted((log1, log2) -> log1.getCreatedTime().compareTo(log2.getCreatedTime()))
.collect(Collectors.toList());
for (AiSessionLog aiSessionLog : aiSessionLogs) {
messages.add(
createMessage(aiSessionLog.getRole(), aiSessionLog.getContent())
);
}
}
messages.add(
createMessage(Role.USER.getValue(), prompt)
);
String aiResponse = aiClientManager.getClient(provider).chatCompletion(messages);
// 存储用户提问
AiSessionLog userLog = new AiSessionLog();
userLog.setToken(token);
userLog.setRole(Role.USER.getValue());
userLog.setContent(prompt);
aiSessionLogMapper.insert(userLog);
// 存储AI回复
AiSessionLog aiLog = new AiSessionLog();
aiLog.setToken(token);
aiLog.setRole(Role.ASSISTANT.getValue());
aiLog.setContent(aiResponse);
aiSessionLogMapper.insert(aiLog);
return aiResponse;
}
/**
* 获取prompt的token数
*
* @param prompt 输入
* @return tokens
*/
@Override
public Integer getPromptTokens(String prompt) {
Tokenizer tokenizer = TokenizerFactory.qwen();
List<Integer> integers = tokenizer.encodeOrdinary(prompt);
return integers.size();
}
}

View File

@@ -1,14 +1,43 @@
dashscope:
api-key: sk-58d6fed688c54e8db02e6a7ffbfc7a5f
deepseek:
api-url: https://api.deepseek.com/chat/completions
api-key: sk-faaa2a1b485442ccbf115ff1271a3480
spring:
datasource:
url: jdbc:mysql://gz-cynosdbmysql-grp-5ai5zw7r.sql.tencentcdb.com:24944/ai_interview?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8
username: qingqiu
password: 020979hP
driver-class-name: com.mysql.cj.jdbc.Driver
ai:
openai:
base-url: https://api.ruyun.fun
api-key: ${RUYUN_API_KEY}
chat:
options:
model: gemini-2.5-flash-nothinking
dashscope:
api-key: ${DASHSCOPE_API_KEY}
read-timeout: 600
chat:
options:
model: qwen3-max
memory:
redis:
host: 127.0.0.1
port: 6379
password: 123456
timeout: 6000
data:
redis:
host: localhost
port: 6379
password: 123456
database: 0
timeout: 6000
jedis:
pool:
max-active: 16
max-idle: 8
min-idle: 0
max-wait: -1ms
# TODO: 考虑删除已注释的配置
# ai:
# openai:
# api-key: sk-faaa2a1b485442ccbf115ff1271a3480
@@ -16,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