feat: complete aihr mvp mobile workflow

This commit is contained in:
2026-07-03 16:43:37 +08:00
parent 0126b0c661
commit f869d66c90
46 changed files with 5879 additions and 219 deletions
+4
View File
@@ -1,4 +1,5 @@
.DS_Store
.env.local
.data/
.serena/
.playwright-cli/
@@ -11,3 +12,6 @@ backend/**/.flattened-pom.xml
frontend/node_modules/
frontend/dist/
frontend/.vite/
mobile/node_modules/
mobile/dist/
.tmp/
+21 -10
View File
@@ -2,12 +2,13 @@
## 项目定位
这是“物业AI人力资源系统”一期 Demo 工程。目标是用 RuoYi-Vue-Plus + plus-ui 先跑通本地账号登录和可演示业务壳,再实现 AI HR 的最小英雄路径。
这是“物业AI人力资源系统”一期 MVP 版工程。目标是用 RuoYi-Vue-Plus + plus-ui 跑通本地账号登录、可演示业务路径和 AI HR 的最小英雄路径。
## 工程边界
- `backend/` 是 `dromara/RuoYi-Vue-Plus` 的 `5.X` 分支检出。
- `frontend/` 是 `CrazyLionCat/plus-ui` 的 `5.X` 分支检出。
- `frontend/` 是 `CrazyLionCat/plus-ui` 的 `5.X` 分支检出,只承载管理端。
- `mobile/` 是独立移动端工程,承载员工端、候选人端、主管端;当前是 Vue/Vite H5,三端首页按 `docs/prototypes/*端-首页.png` 实现,后续确定小程序技术栈后再迁移到 uni-app/Taro。
- 本项目自己的本地编排放在根目录:`docker-compose.dev.yml`、`scripts/`;运行说明在 `docs/DEV_SETUP.md`。
- 除非确实要接业务模块,不要改上游框架默认配置;本地端口和开关优先放根目录脚本里覆盖。
@@ -18,8 +19,10 @@
- MinIO:API `127.0.0.1:9000`,Console `127.0.0.1:9001`,账号 `ruoyi / ruoyi123`,bucket `ruoyi`。
- Qdrant:REST `127.0.0.1:6333`,默认 collection `aihr_knowledge`;可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。
- 资料导入根目录:默认 `./.data/import`,由 `scripts/dev-backend.sh` 传入 `aihr.import.root`;也可用 `AIHR_IMPORT_ROOT` 覆盖。
- 本地私密配置:根目录 `.env.local` 已被 `.gitignore` 忽略,`scripts/dev-backend.sh` 会自动加载;阿里云短信密钥只放这里或外部环境变量,不写入 `application-*.yml`。
- 后端:`./scripts/dev-backend.sh`,端口 `8080`。
- 前端:`./scripts/dev-frontend.sh`,端口 `5173`。
- 管理端前端:`./scripts/dev-frontend.sh`,端口 `5173`。
- 移动端前端:`./scripts/dev-mobile.sh`,端口 `5174`。
- 重置数据:`./scripts/reset-dev-db.sh`,会删除并重建本地 `ry-vue`。
导入 SQL 必须带 `--default-character-set=utf8mb4`,否则中文昵称可能按错误字符集导入并触发字段长度问题。
@@ -27,6 +30,7 @@
## 验证口径
- 前端首页:`http://127.0.0.1:5173/`
- 移动端首页:`http://127.0.0.1:5174/h5/user`
- 租户接口:`http://127.0.0.1:5173/dev-api/auth/tenant/list`
- 验证码接口:`http://127.0.0.1:5173/dev-api/auth/code`
- 默认登录:租户 `000000`,管理员 `admin / admin123`。
@@ -36,7 +40,7 @@
- 页面标题在 `frontend/.env.development` 与 `frontend/.env.production`。
- 侧栏 logo 在 `frontend/src/assets/logo/logo.png`。
## Demo 前端边界
## MVP 管理端前端边界
- 首页:`frontend/src/views/index.vue`,路由 `/index`。
- AI 面试:`frontend/src/views/recruit/interview.vue`,路由 `/recruit/interview`。
@@ -44,22 +48,29 @@
- 案例沉淀:`frontend/src/views/knowledge/cases.vue`,路由 `/knowledge/cases`。
- SOP 知识库:`frontend/src/views/knowledge/sop.vue`,路由 `/knowledge/sop`。
- 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`。
- Demo 阶段侧栏只保留“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”。过滤逻辑在 `frontend/src/layout/components/Sidebar/index.vue`,不要在未明确要求时恢复若依默认全量菜单。
- 模型配置:`frontend/src/views/system/model/index.vue`,路由 `/system/model`。
- MVP 阶段侧栏只保留“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”。过滤逻辑在 `frontend/src/layout/components/Sidebar/index.vue`,不要在未明确要求时恢复若依默认全量菜单。
- 移动端三端首页:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,分别对应用户/员工端、候选人端、主管端;兼容 `/h5/employee`、`/h5/admin`,但移动端不要把主管端等同后台系统管理员。
- 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。
- 移动端手机号登录:验证码发送走 `GET /resource/sms/code?phonenumber=...`,登录走 `POST /auth/mobile/sms-login`;短信发送复用 `sms.blends.config1` 阿里云配置。手机号不存在时自动注册 `app_user`,备注为“移动端短信自动注册”。模板 ID 用 `AIHR_SMS_LOGIN_TEMPLATE_ID`,AccessKey/Secret/签名用 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`,本地放 `.env.local`。
- 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;`mode=mobile` 完成后写入 `aihr_practice_session`,并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。
- 当前业务页已用 seed 数据跑通可演示闭环;后端 API 对接先看 `docs/API_INTEGRATION.md`。
- 后端业务代码不要塞进上游 `ruoyi-demo`;自有 API 放在 `backend/ruoyi-modules/ruoyi-aihr`,再接入 `ruoyi-admin`。
- AI 面试页、三角色对练页、案例沉淀页、SOP 知识库页已是 API 优先 + 本地 fallback;SOP 知识库优先查 `aihr_knowledge_fragment` 的 MySQL Fulltext,并在 vector 模型和 Qdrant 可用时做混合召回;支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传解析入库。
- SOP 上传接口是 `POST /api/knowledge/doc/upload`,表单字段为 `file` 和 `category`;当前限制 100MB 内,支持 txt/md/PDF/Word/Excel/PPT,先写 MinIO/`sys_oss` 再绑定 `aihr_knowledge_attach.oss_id`,同一知识库同名文件会替换旧 fragment。若数据库启用 `category=vector` 的模型配置,会同步调用 OpenAI-compatible `/embeddings`,写入 `embedding_json` 并尽力 upsert 到 Qdrant;Qdrant 不可用时保留 MySQL Fulltext/seed fallback。
- AI 面试页、三角色对练页、案例沉淀页、SOP 知识库页和移动端三端首页已是 API 优先 + 本地 fallback;SOP 知识库优先查 `aihr_knowledge_fragment` 的 MySQL Fulltext,并在 vector 模型和 Qdrant 可用时做混合召回;支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 及图片 `.jpg/.jpeg/.png/.gif/.webp/.bmp` 上传解析入库。
- SOP 上传接口是 `POST /api/knowledge/doc/upload`,表单字段为 `file` 和 `category`;当前限制 100MB 内,支持 txt/md/PDF/Word/Excel/PPT 及图片,先写 MinIO/`sys_oss` 再绑定 `aihr_knowledge_attach.oss_id`,同一知识库同名文件会替换旧 fragment。若数据库启用 `category=vector` 的模型配置,会同步调用 OpenAI-compatible `/embeddings`,写入 `embedding_json` 并尽力 upsert 到 Qdrant;配置缺失或外部 embedding 失败才用 `local-hash-v1` 兜底,Qdrant 不可用时保留 MySQL Fulltext/seed fallback。
- 图片视觉 OCR:上传图片时经 `visionRuntime()` 找启用模型(优先 `aihr_model_config.category='vision'`,无则回退 `category='chat'`),把图片编码成 base64 data URL 调 OpenAI-compatible `/chat/completions` 提取文字再切分入库;无视觉模型返回空正文、fragment 为 0,不报错。资料处理页“图片 OCR”开关读 `imageOcrEnabled = visionRuntime().isPresent()`,未配置时灰显“即将支持”。不依赖 Tesseract。
- 向量库状态接口是 `GET /api/knowledge/doc/vector-index-status`,重建接口是 `POST /api/knowledge/doc/rebuild-vector-index`;外部 embedding 成功但 Qdrant collection 维度不一致时不要静默降级,走状态提示和重建。
- 资料处理接口是 `GET /api/knowledge/processing/overview`,直接聚合 `aihr_knowledge_attach`、`aihr_knowledge_fragment` 和 `sys_oss.ext1`;`POST /api/knowledge/doc/import-local` 保留同步导入,`POST /api/knowledge/doc/import-local-task` + `GET /api/knowledge/doc/import-tasks` 用于页面启动后台目录导入和轮询进度。目录只能是 `aihr.import.root` 下的相对路径,逐文件复用上传解析链路。
- seed service 阶段已完成;后续按 `docs/RUOYI_AI_INCREMENTAL_MIGRATION.md` 分片迁移 `ruoyi-ai` 的知识库、文档解析、RAG、chat 能力。
- 迁移时不要整包搬 `ruoyi-chat`;优先在 `ruoyi-aihr` 内吸收必要表、service、loader 和检索接口,并保留 seed fallback。
- 已落地的 seed 演示流不要退回静态壳:AI 面试、三角色对练、案例沉淀、SOP 知识库。
- 本项目 AI 表前缀用 `aihr_*`:知识库是 `aihr_knowledge_info/attach/fragment`,模型配置是 `aihr_model_provider/config`。
- 已落地的 seed 演示流不要退回静态壳:AI 面试、三角色对练、案例沉淀、SOP 知识库、移动端三端首页。
- 本项目 AI 表前缀用 `aihr_*`:知识库是 `aihr_knowledge_info/attach/fragment`,模型配置是 `aihr_model_provider/config`,移动端训练记录是 `aihr_practice_session`。
## 业务文档
- 总入口:`README.md`
- 文档索引:`docs/README.md`
- Demo 范围和止损线:`docs/AI人力资源系统一期Demo作战清单.md`
- MVP 范围和止损线:`docs/AI人力资源系统一期MVP版作战清单.md`
- 后端 API 对接:`docs/API_INTEGRATION.md`
- 业务需求:`docs/物业AI人力资源系统业务需求文档BRD.md`
- 开发规格:`docs/物业AI人力资源系统开发规格TechSpec.md`
+17 -12
View File
@@ -1,18 +1,21 @@
# 物业AI人力资源系统
基于 RuoYi-Vue-Plus 后端与 plus-ui 前端的一期 Demo 工程。当前阶段目标是保住本地可运行骨架,并围绕招聘面试、三角色对练、案例沉淀、SOP 知识库落出可点击的 AI HR 英雄路径。
基于 RuoYi-Vue-Plus 后端与 plus-ui 前端的一期 MVP 版工程。当前阶段目标是保住本地可运行骨架,并围绕招聘面试、三角色对练、案例沉淀、SOP 知识库落出可点击的 AI HR 英雄路径。
## 当前状态
- 后端:`backend`,来自 `dromara/RuoYi-Vue-Plus` 的 `5.X` 分支。
- 前端:`frontend`,来自 `CrazyLionCat/plus-ui` 的 `5.X` 分支。
- 管理端前端:`frontend`,来自 `CrazyLionCat/plus-ui` 的 `5.X` 分支。
- 移动端前端:`mobile`,独立 Vue/Vite 工程,承接员工端、候选人端、主管端。
- 本地依赖:MySQL + Redis + MinIO + Qdrant 由 `docker-compose.dev.yml` 启动。
- 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。
- 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。
- 前端 Demo:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。
- 英雄路径:AI 面试出题、三角色对练、案例沉淀已接后端 seed API;SOP 知识库已接 MySQL Fulltext + Qdrant 混合召回,支持 txt/md/PDF/Word/Excel/PPT 上传到 MinIO 后解析成 fragment,并可按数据库 vector 模型配置写入 embedding 和 Qdrant 向量索引。
- 管理端 MVP:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。
- 移动端 MVP:三端首页已拆到独立工程 `mobile/`,按 `docs/prototypes/员工端-首页.png`、`候选人端-首页.png`、`主管端-首页.png` 实现;路由为 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,并兼容 `/h5/employee`、`/h5/admin`;已接手机号短信登录、`GET /api/aihr/mobile/home/{role}` 首页 API,员工端“开始训练”可完成两轮三角色对练,记录落 `aihr_practice_session` 并刷新主管端待复盘列表、复盘详情、标记已复盘状态和员工能力画像,前端保留本地 fallback。
- 英雄路径:AI 面试出题、三角色对练、案例沉淀已接后端 seed API;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回,支持 txt/md/PDF/Word/Excel/PPT 上传到 MinIO 后解析成 fragment,并可按数据库 vector 模型配置写入 embedding 和 Qdrant 向量索引;模型配置页可查看向量库维度/点数并一键重建,配置缺失或外部 embedding 失败时用本地 `local-hash-v1` 兜底。
- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务并轮询进度。
- 侧栏:Demo 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。
- 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。
- 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。
## 快速启动
@@ -20,15 +23,16 @@
./scripts/dev.sh
```
首次启动或需要重建本地 Demo 数据库时执行 `./scripts/dev.sh --reset`。详细端口、默认账号和验证项见 [本地运行手册](docs/DEV_SETUP.md)。
首次启动或需要重建本地 MVP 数据库时执行 `./scripts/dev.sh --reset`。详细端口、默认账号和验证项见 [本地运行手册](docs/DEV_SETUP.md)。
## 目录
| 路径 | 用途 |
|---|---|
| `backend/` | RuoYi-Vue-Plus 后端基座 |
| `frontend/` | plus-ui 前端基座 |
| `scripts/` | 本地 Demo 启动与数据重置脚本 |
| `frontend/` | plus-ui 管理端前端基座 |
| `mobile/` | 员工端、候选人端、主管端独立移动端工程 |
| `scripts/` | 本地 MVP 启动与数据重置脚本 |
| `docker-compose.dev.yml` | 本地 MySQL / Redis / MinIO / Qdrant |
| `docs/` | 项目文档、旧方案参考、Word 原件 |
@@ -38,13 +42,14 @@
|---|---|
| [文档目录](docs/README.md) | docs 区完整索引 |
| [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 |
| [Demo 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | 四条 seed 流演示脚本、录屏兜底和验收口径 |
| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、接口接入顺序、四条 seed API 切片 |
| [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 |
| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、接口接入顺序、移动端训练记录和 SOP 上传解析接口 |
| [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 |
| [一期 Demo 作战清单](docs/AI人力资源系统一期Demo作战清单.md) | Demo 范围、排期、验收切割线 |
| [一期 MVP 版作战清单](docs/AI人力资源系统一期MVP版作战清单.md) | MVP 范围、排期、验收切割线 |
| [业务需求 BRD](docs/物业AI人力资源系统业务需求文档BRD.md) | 业务范围、角色、边界和验收 |
| [开发规格 TechSpec](docs/物业AI人力资源系统开发规格TechSpec.md) | 模块、数据模型、路由、API 和实现切分 |
| [移动端设计 QA](design-qa.md) | 三端首页高保真实现的截图对照记录 |
## 下一步
五个关键页面已接入后端 API,并在前端保留失败回退。下一步按 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) 继续接图片 OCR/视觉分析和 RAG 引用回答;当前已落知识库表、住宅 SOP MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析、embedding 写入、模型供应商/模型配置、资料处理状态页和后台目录导入任务,不整包搬 `ruoyi-chat`。
五个管理端关键页面和移动端三端首页已接入后端 API,并在前端保留失败回退。下一步按 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) 继续接 RAG 引用回答与最小 chat;当前已落知识库表、移动端训练记录表 `aihr_practice_session`、员工训练历史、主管复盘列表、复盘详情、标记已复盘、能力画像、住宅 SOP MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析、图片视觉 OCR(配置 vision/chat 模型后上传 jpg/png 等自动识别文字)、embedding 写入、向量库状态/重建、模型供应商/模型配置、资料处理状态页和后台目录导入任务,不整包搬 `ruoyi-chat`。
@@ -7,13 +7,17 @@ import cn.hutool.core.codec.Base64;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import me.zhyd.oauth.utils.AuthStateUtils;
import org.dromara.common.core.constant.RegexConstants;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginBody;
import org.dromara.common.core.domain.model.RegisterBody;
@@ -67,6 +71,9 @@ import java.util.concurrent.TimeUnit;
@RequestMapping("/auth")
public class AuthController {
private static final String MOBILE_CLIENT_ID = "428a8310cd442757ae699df5d894f051";
private static final String MOBILE_GRANT_TYPE = "sms";
private final SocialProperties socialProperties;
private final SysLoginService loginService;
private final SysRegisterService registerService;
@@ -114,6 +121,31 @@ public class AuthController {
return R.ok(loginVo);
}
/**
* 移动端短信登录,复用 app 客户端和 sms 授权策略。
*/
@PostMapping("/mobile/sms-login")
public R<LoginVo> mobileSmsLogin(@Validated @RequestBody MobileSmsLoginBody body) {
SysClientVo client = clientService.queryByClientId(MOBILE_CLIENT_ID);
if (ObjectUtil.isNull(client) || !StringUtils.contains(client.getGrantType(), MOBILE_GRANT_TYPE)) {
log.info("移动端客户端id: {} 认证类型:{} 异常!.", MOBILE_CLIENT_ID, MOBILE_GRANT_TYPE);
return R.fail(MessageUtils.message("auth.grant.type.error"));
} else if (!SystemConstants.NORMAL.equals(client.getStatus())) {
return R.fail(MessageUtils.message("auth.grant.type.blocked"));
}
String tenantId = StringUtils.blankToDefault(body.tenantId(), TenantConstants.DEFAULT_TENANT_ID);
loginService.checkTenant(tenantId);
Map<String, String> loginBody = new HashMap<>();
loginBody.put("clientId", MOBILE_CLIENT_ID);
loginBody.put("grantType", MOBILE_GRANT_TYPE);
loginBody.put("tenantId", tenantId);
loginBody.put("phonenumber", body.phonenumber());
loginBody.put("smsCode", body.smsCode());
return R.ok(IAuthStrategy.login(JsonUtils.toJsonString(loginBody), client, MOBILE_GRANT_TYPE));
}
/**
* 获取跳转URL
*
@@ -240,4 +272,14 @@ public class AuthController {
return R.ok(result);
}
public record MobileSmsLoginBody(
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}")
String phonenumber,
@NotBlank(message = "{sms.code.not.blank}")
String smsCode,
String tenantId
) {
}
}
@@ -7,10 +7,12 @@ import cn.hutool.captcha.generator.RandomGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.RegexConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SpringUtils;
@@ -26,6 +28,7 @@ import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.core.factory.SmsFactory;
import org.dromara.web.domain.vo.CaptchaVo;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
@@ -52,6 +55,9 @@ public class CaptchaController {
private final CaptchaProperties captchaProperties;
private final MailProperties mailProperties;
@Value("${aihr.sms.login-template-id:}")
private String smsLoginTemplateId;
/**
* 短信验证码
*
@@ -59,20 +65,23 @@ public class CaptchaController {
*/
@RateLimiter(key = "#phonenumber", time = 60, count = 1)
@GetMapping("/resource/sms/code")
public R<Void> smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) {
public R<Void> smsCode(
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}") String phonenumber) {
if (StringUtils.isBlank(smsLoginTemplateId)) {
return R.fail("短信模板未配置:请设置 aihr.sms.login-template-id");
}
String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber;
String code = RandomUtil.randomNumbers(4);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
// 验证码模板id 自行处理 (查数据库或写死均可)
String templateId = "";
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
SmsResponse smsResponse = smsBlend.sendMessage(phonenumber, templateId, map);
SmsResponse smsResponse = smsBlend.sendMessage(phonenumber, smsLoginTemplateId, map);
if (!smsResponse.isSuccess()) {
log.error("验证码短信发送异常 => {}", smsResponse);
return R.fail(smsResponse.getData().toString());
return R.fail(String.valueOf(smsResponse.getData()));
}
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
return R.ok();
}
@@ -12,6 +12,7 @@ import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.domain.model.SmsLoginBody;
import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
@@ -51,8 +52,8 @@ public class SmsAuthStrategy implements IAuthStrategy {
String phonenumber = loginBody.getPhonenumber();
String smsCode = loginBody.getSmsCode();
LoginUser loginUser = TenantHelper.dynamic(tenantId, () -> {
SysUserVo user = loadUserByPhonenumber(phonenumber);
loginService.checkLogin(LoginType.SMS, tenantId, user.getUserName(), () -> !validateSmsCode(tenantId, phonenumber, smsCode));
loginService.checkLogin(LoginType.SMS, tenantId, phonenumber, () -> !validateSmsCode(tenantId, phonenumber, smsCode));
SysUserVo user = loadOrRegisterUserByPhonenumber(tenantId, phonenumber);
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
return loginService.buildLoginUser(user);
});
@@ -87,11 +88,20 @@ public class SmsAuthStrategy implements IAuthStrategy {
return code.equals(smsCode);
}
private SysUserVo loadUserByPhonenumber(String phonenumber) {
private SysUserVo loadOrRegisterUserByPhonenumber(String tenantId, String phonenumber) {
SysUserVo user = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getPhonenumber, phonenumber));
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", phonenumber);
throw new UserException("user.not.exists", phonenumber);
SysUser sysUser = new SysUser();
sysUser.setTenantId(tenantId);
sysUser.setUserName(phonenumber);
sysUser.setNickName("手机用户" + phonenumber.substring(7));
sysUser.setUserType(UserType.APP_USER.getUserType());
sysUser.setPhonenumber(phonenumber);
sysUser.setStatus(SystemConstants.NORMAL);
sysUser.setRemark("移动端短信自动注册");
userMapper.insert(sysUser);
log.info("短信登录自动注册用户:{}", phonenumber);
user = userMapper.selectVoById(sysUser.getUserId());
} else if (SystemConstants.DISABLE.equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", phonenumber);
throw new UserException("user.blocked", phonenumber);
@@ -155,6 +155,9 @@ mail:
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
aihr:
sms:
login-template-id: ${AIHR_SMS_LOGIN_TEMPLATE_ID:}
sms:
# 配置源类型用于标定配置来源(interface,yaml)
config-type: yaml
@@ -172,11 +175,11 @@ sms:
# 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: alibaba
# 有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。
access-key-id: 您的accessKey
access-key-id: ${ALIYUN_SMS_ACCESS_KEY_ID:您的accessKey}
# 称为accessSecret有些称之为apiSecret
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
access-key-secret: ${ALIYUN_SMS_ACCESS_KEY_SECRET:您的accessKeySecret}
signature: ${ALIYUN_SMS_SIGN_NAME:您的短信签名}
sdk-app-id: ${ALIYUN_SMS_SDK_APP_ID:您的sdkAppId}
config2:
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: tencent
@@ -158,6 +158,9 @@ mail:
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
aihr:
sms:
login-template-id: ${AIHR_SMS_LOGIN_TEMPLATE_ID:}
sms:
# 配置源类型用于标定配置来源(interface,yaml)
config-type: yaml
@@ -175,11 +178,11 @@ sms:
# 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: alibaba
# 有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。
access-key-id: 您的accessKey
access-key-id: ${ALIYUN_SMS_ACCESS_KEY_ID:您的accessKey}
# 称为accessSecret有些称之为apiSecret
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
access-key-secret: ${ALIYUN_SMS_ACCESS_KEY_SECRET:您的accessKeySecret}
signature: ${ALIYUN_SMS_SIGN_NAME:您的短信签名}
sdk-app-id: ${ALIYUN_SMS_SDK_APP_ID:您的sdkAppId}
config2:
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: tencent
+1 -1
View File
@@ -12,7 +12,7 @@
<artifactId>ruoyi-aihr</artifactId>
<description>
物业 AI 人力资源 Demo 业务模块
物业 AI 人力资源 MVP 业务模块
</description>
<dependencies>
@@ -0,0 +1,61 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Mobile H5 home APIs.
*/
@SaIgnore
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/aihr/mobile")
public class AihrMobileController {
private final AihrMobileSeedService mobileSeedService;
@GetMapping("/home/{role}")
public R<HomeResponse> home(@PathVariable String role) {
return R.ok(mobileSeedService.home(role));
}
@GetMapping("/practice/history")
public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.practiceHistory(extPartyId));
}
@GetMapping("/practice/reviews")
public R<List<RecordResponse>> practiceReviews() {
return R.ok(mobileSeedService.practiceReviews());
}
@GetMapping("/practice/reviews/{id}")
public R<ReviewDetailResponse> practiceReview(@PathVariable Long id) {
ReviewDetailResponse detail = mobileSeedService.practiceReview(id);
return detail == null ? R.fail("复盘记录不存在") : R.ok(detail);
}
@PostMapping("/practice/reviews/{id}/reviewed")
public R<Void> markReviewed(@PathVariable Long id) {
return mobileSeedService.markReviewed(id) ? R.ok() : R.fail("复盘记录不存在");
}
@GetMapping("/profile")
public R<CompetencyResponse> profile(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.profile(extPartyId));
}
}
@@ -8,6 +8,8 @@ import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
@@ -51,6 +53,21 @@ public class AihrSopController {
return R.ok(sopSeedService.startLocalImportTask(request));
}
@PostMapping("/doc/vectorize-missing")
public R<VectorizeResponse> vectorizeMissing() {
return R.ok(sopSeedService.vectorizeMissing());
}
@GetMapping("/doc/vector-index-status")
public R<VectorIndexStatusResponse> vectorIndexStatus() {
return R.ok(sopSeedService.vectorIndexStatus());
}
@PostMapping("/doc/rebuild-vector-index")
public R<VectorizeResponse> rebuildVectorIndex() {
return R.ok(sopSeedService.rebuildVectorIndex());
}
@GetMapping("/doc/import-tasks")
public R<List<LocalImportTaskResponse>> localImportTasks() {
return R.ok(sopSeedService.localImportTasks());
@@ -56,6 +56,28 @@ public final class AihrPracticeDto {
public record DimensionResponse(String label, Object value, String note) {
}
public record RecordResponse(String time, String trainee, String scene, Integer score, String status, String summary) {
public record RecordResponse(Long id, String sessionId, String time, String trainee, String scene, Integer score, String status, String summary) {
}
public record DialogueResponse(String role, String label, String text) {
}
public record ReviewDetailResponse(
Long id,
String sessionId,
String time,
String trainee,
String scene,
Integer score,
String status,
String summary,
String mentorRewrite,
String aiComment,
List<DimensionResponse> scoreItems,
List<DialogueResponse> dialogue
) {
}
public record CompetencyResponse(Integer score, Integer completed, Integer pendingReview, List<DimensionResponse> dimensions) {
}
}
@@ -13,6 +13,24 @@ public final class AihrSopDto {
public record UploadResponse(String docId, Long ossId, String fileName, String category, Integer fragments, String summary, List<String> tags, List<SnippetResponse> snippets) {
}
public record VectorizeResponse(Integer documents, Integer fragments, String model) {
}
public record VectorIndexStatusResponse(
String modelName,
Integer modelDimension,
String collection,
Integer qdrantDimension,
Long qdrantPoints,
Integer fragments,
Integer embeddedFragments,
String embeddingModel,
Integer embeddingDimension,
Boolean matched,
String message
) {
}
public record LocalImportRequest(String directory, String category, Integer limit) {
}
@@ -0,0 +1,108 @@
package org.dromara.aihr.domain.vo;
import java.util.List;
/**
* Mobile H5 seed DTOs.
*/
public final class AihrMobileDto {
private AihrMobileDto() {
}
public record HomeResponse(
String role,
String roleLabel,
String avatarTone,
String greeting,
String location,
String toolTitle,
String recommend,
PrimaryTaskResponse primary,
ReviewTaskResponse review,
List<ToolResponse> tools,
List<MetricResponse> metrics,
List<TabResponse> tabs,
TeamOverviewResponse team,
List<TodoResponse> todos,
SuggestionResponse suggestion
) {
}
public record PrimaryTaskResponse(
String badge,
String title,
String desc,
String progressText,
Integer progress,
String cta,
String icon
) {
}
public record ReviewTaskResponse(
String title,
String desc
) {
}
public record ToolResponse(
String icon,
String title,
String desc,
String tone
) {
}
public record MetricResponse(
String icon,
String title,
String value,
String desc,
String tone,
Integer progress
) {
}
public record TabResponse(
String label,
String icon,
Boolean active
) {
}
public record TeamOverviewResponse(
String title,
String progressText,
Integer progress,
String completionLabel,
String detailCta,
List<MiniStatResponse> stats
) {
}
public record MiniStatResponse(
String icon,
String title,
String value,
String tone
) {
}
public record TodoResponse(
String icon,
String title,
String desc,
Integer count,
String tone
) {
}
public record SuggestionResponse(
String icon,
String title,
String desc,
String tone
) {
}
}
@@ -0,0 +1,222 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.MetricResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.MiniStatResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.PrimaryTaskResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.ReviewTaskResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.SuggestionResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.TabResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.TeamOverviewResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.TodoResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.ToolResponse;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Seed data for mobile MVP home pages.
*/
@Service
public class AihrMobileSeedService {
private static final int TEAM_SIZE = 15;
private final AihrPracticeSeedService practiceSeedService;
public AihrMobileSeedService(AihrPracticeSeedService practiceSeedService) {
this.practiceSeedService = practiceSeedService;
}
public HomeResponse home(String role) {
return switch (normalize(role)) {
case "candidate" -> candidateHome();
case "supervisor" -> supervisorHome();
default -> userHome();
};
}
public List<RecordResponse> practiceHistory(String extPartyId) {
return practiceSeedService.mobileHistory(extPartyId, 5);
}
public List<RecordResponse> practiceReviews() {
return practiceSeedService.pendingReviewRecords(5);
}
public ReviewDetailResponse practiceReview(Long id) {
return practiceSeedService.reviewDetail(id);
}
public boolean markReviewed(Long id) {
return practiceSeedService.markReviewed(id);
}
public CompetencyResponse profile(String extPartyId) {
return practiceSeedService.competency(extPartyId);
}
private static String normalize(String role) {
if (role == null) {
return "user";
}
return switch (role.toLowerCase()) {
case "candidate" -> "candidate";
case "supervisor", "manager", "admin" -> "supervisor";
default -> "user";
};
}
private HomeResponse userHome() {
return workerHome("user", "员工端", "avatar-woman", "早上好,王敏", "星河湾一期 · 客服管家");
}
private static HomeResponse candidateHome() {
return new HomeResponse(
"candidate",
"候选人端",
"avatar-candidate",
"早上好,李娜",
"星河湾一期 · 客服管家候选人",
"面试准备",
"本周推荐:投诉接待基础",
new PrimaryTaskResponse(
"面试任务",
"AI结构化面试",
"8分钟问答 · 完成后生成面试记录",
"0/1",
22,
"开始面试",
"briefcase"
),
new ReviewTaskResponse(
"岗位SOP预习",
"今日需完成 · 客服接待基础流程"
),
List.of(
new ToolResponse("mic", "面试练习", "模拟问答\n熟悉岗位", "teal"),
new ToolResponse("book-search", "查岗位SOP", "快速了解\n标准流程", "blue"),
new ToolResponse("folder", "案例学习", "典型场景\n提前预习", "orange"),
new ToolResponse("upload", "补充资料", "提交证书\n与经历", "purple")
),
List.of(
new MetricResponse("bars", "进度", "2/4", "候选任务", "teal", 50),
new MetricResponse("target", "匹配度", "86", "岗位画像分", "blue", 86),
new MetricResponse("clipboard", "待补充", "1", "资料项", "orange", 35)
),
List.of(
new TabResponse("首页", "home", true),
new TabResponse("面试", "briefcase", false),
new TabResponse("知识", "book", false),
new TabResponse("我的", "user", false)
),
null,
List.of(),
null
);
}
private HomeResponse workerHome(String role, String roleLabel, String avatarTone, String greeting, String location) {
int completed = practiceSeedService.mobileCompletedCount();
int pendingReview = 2 + practiceSeedService.mobilePendingReviewCount();
return new HomeResponse(
role,
roleLabel,
avatarTone,
greeting,
location,
"学习与工具",
"本周推荐:催费沟通专项",
new PrimaryTaskResponse(
"重点训练",
"投诉接待三角色对练",
"3分钟语音练习 · 完成后自动评分",
"1/1",
100,
"开始训练",
"headset"
),
new ReviewTaskResponse(
"岗前SOP复习",
"今日需完成 · 住宅投诉处理流程"
),
List.of(
new ToolResponse("mic", "每日一练", "每日3分钟\n提升服务能力", "teal"),
new ToolResponse("book-search", "查SOP", "随时查找\n标准流程", "blue"),
new ToolResponse("folder", "案例学习", "优秀案例\n对标提升", "orange"),
new ToolResponse("pen", "提交素材", "记录工作场景\n供审核入库", "purple")
),
List.of(
new MetricResponse("bars", "完成", (6 + completed) + "/10", "今日任务进度", "teal", Math.min(100, 60 + completed * 10)),
new MetricResponse("target", "能力", String.valueOf(Math.min(95, 82 + completed * 2)), "综合能力分", "blue", Math.min(95, 82 + completed * 2)),
new MetricResponse("clipboard", "待复盘", String.valueOf(pendingReview), "待复盘任务", "orange", Math.min(100, 48 + completed * 10))
),
List.of(
new TabResponse("首页", "home", true),
new TabResponse("训练", "graduation", false),
new TabResponse("知识", "book", false),
new TabResponse("我的", "user", false)
),
null,
List.of(),
null
);
}
private HomeResponse supervisorHome() {
int mobileCompleted = practiceSeedService.mobileCompletedCount();
int completed = Math.min(TEAM_SIZE, 12 + mobileCompleted);
int pending = Math.max(0, TEAM_SIZE - completed);
int pendingReview = 3 + practiceSeedService.mobilePendingReviewCount();
int completionRate = Math.round(completed * 100F / TEAM_SIZE);
return new HomeResponse(
"supervisor",
"主管端",
"avatar-man",
"早上好,李主管",
"星河湾一期 · 项目主管",
"快捷操作",
null,
null,
null,
List.of(
new ToolResponse("presentation", "指派训练", "为员工分配训练任务", "teal"),
new ToolResponse("target", "团队短板", "查看短板分布\n与改进建议", "blue"),
new ToolResponse("upload", "提交案例素材", "上传案例素材\n供审核与沉淀", "orange"),
new ToolResponse("book-search", "查SOP", "快速搜索制度与\n标准流程", "purple")
),
List.of(),
List.of(
new TabResponse("首页", "home", true),
new TabResponse("团队", "users", false),
new TabResponse("案例", "folder", false),
new TabResponse("我的", "user", false)
),
new TeamOverviewResponse(
"客服团队训练进度",
completed + "人已完成 · " + pending + "人待跟进",
completionRate,
"完训率",
"查看详情",
List.of(
new MiniStatResponse("users", "完训率", completionRate + "%", "teal"),
new MiniStatResponse("bars", "平均能力", String.valueOf(Math.min(90, 78 + mobileCompleted)), "teal"),
new MiniStatResponse("warning", "高频短板", "3", "orange")
)
),
List.of(
new TodoResponse("presentation", "待复盘对练", "查看对练记录,辅导与点评", pendingReview, "teal"),
new TodoResponse("folder", "待提交本周案例", "提交优质案例素材,供审核入库", 1, "orange"),
new TodoResponse("clipboard", "待指派专项", "为低分或薄弱项员工指派训练专项", 2, "blue")
),
new SuggestionResponse(
"bulb",
"建议:给催费沟通低分员工指派专项",
"基于团队短板分析,建议开展「催费沟通技巧」专项训练",
"amber"
)
);
}
}
@@ -295,7 +295,7 @@ public class AihrModelSeedService {
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合 Demo 演示,简洁、可执行。" : systemPrompt);
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合 MVP 演示,简洁、可执行。" : systemPrompt);
ObjectNode user = messages.addObject();
user.put("role", "user");
@@ -1,19 +1,34 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.DialogueResponse;
import org.dromara.aihr.domain.AihrPracticeDto.DimensionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* 三角色对练 seed 编排器。P0 先稳定前后端契约,后续再替换为会话表、ASR 和 LLM 评分。
@@ -21,17 +36,31 @@ import java.util.Map;
@Service
public class AihrPracticeSeedService {
private static final String TENANT_ID = "000000";
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private final Map<String, ScenarioSeed> scenarios = buildScenarios();
private final ConcurrentMap<String, ActiveSession> activeSessions = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper;
private final JdbcTemplate jdbcTemplate;
private volatile boolean practiceTableReady;
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate) {
this.objectMapper = objectMapper;
this.jdbcTemplate = jdbcTemplate;
}
public StartResponse start(StartRequest request) {
ScenarioSeed scenario = resolveScenario(request == null ? null : request.scenarioId(), null);
RoundSeed firstRound = scenario.rounds().get(0);
String sessionId = "seed-" + scenario.id() + "-" + System.currentTimeMillis();
String trainee = resolveTrainee(request, scenario);
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), isMobile(request), LocalDateTime.now(), new ArrayList<>()));
return new StartResponse(
sessionId,
scenario.id(),
scenario.name(),
scenario.trainee(),
trainee,
firstRound.customer(),
firstRound.emotion(),
scenario.trustStart(),
@@ -43,6 +72,7 @@ public class AihrPracticeSeedService {
public TurnResponse turn(TurnRequest request) {
ScenarioSeed scenario = resolveScenario(null, request == null ? null : request.sessionId());
int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size());
rememberTraineeReply(request, roundIndex);
RoundSeed currentRound = scenario.rounds().get(roundIndex);
int nextRoundIndex = roundIndex + 1;
boolean finished = nextRoundIndex >= scenario.rounds().size();
@@ -60,17 +90,426 @@ public class AihrPracticeSeedService {
}
public FinishResponse finish(FinishRequest request) {
ScenarioSeed scenario = resolveScenario(null, request == null ? null : request.sessionId());
String sessionId = request == null ? null : request.sessionId();
ActiveSession activeSession = sessionId == null ? null : activeSessions.remove(sessionId);
ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId);
String trainee = activeSession == null ? scenario.trainee() : activeSession.trainee();
RecordResponse record = new RecordResponse(null, sessionId, formatNow(), trainee, scenario.name(), scenario.total(), "待复盘", scenario.summary());
savePracticeRecord(sessionId, activeSession, scenario, record);
return new FinishResponse(
scenario.total(),
scenario.scores(),
scenario.rewrite(),
scenario.summary(),
List.of(scenario.trustStart(), scenario.rounds().get(0).trust(), scenario.trustEnd()),
List.of(new RecordResponse("07-02 10:42", scenario.trainee(), scenario.name(), scenario.total(), "已完成", scenario.summary()))
List.of(record)
);
}
public int mobileCompletedCount() {
return countMobileRecords("");
}
public int mobilePendingReviewCount() {
return countPendingReview("");
}
public List<RecordResponse> mobileHistory(String extPartyId, int limit) {
ensurePracticeTable();
int safeLimit = normalizeLimit(limit);
if (isBlank(extPartyId)) {
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
ORDER BY finished_time DESC, id DESC
LIMIT ?
""", this::mapRecord, TENANT_ID, safeLimit);
}
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND ext_party_id = ?
ORDER BY finished_time DESC, id DESC
LIMIT ?
""", this::mapRecord, TENANT_ID, extPartyId.trim(), safeLimit);
}
public List<RecordResponse> pendingReviewRecords(int limit) {
ensurePracticeTable();
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'
ORDER BY finished_time DESC, id DESC
LIMIT ?
""", this::mapRecord, TENANT_ID, normalizeLimit(limit));
}
public ReviewDetailResponse reviewDetail(Long id) {
ensurePracticeTable();
if (id == null) {
return null;
}
List<ReviewDetailResponse> rows = jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_id, scenario_name, total_score, status,
summary, mentor_rewrite, ai_comment, dim_compliance, dim_emotion, dim_communication, dim_marketing, dialogue_json
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
""", this::mapReviewDetail, TENANT_ID, id);
return rows.isEmpty() ? null : rows.get(0);
}
public boolean markReviewed(Long id) {
ensurePracticeTable();
if (id == null) {
return false;
}
return jdbcTemplate.update("""
UPDATE aihr_practice_session
SET status = '已复盘', update_time = ?
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
""", Timestamp.valueOf(LocalDateTime.now()), TENANT_ID, id) > 0;
}
public CompetencyResponse competency(String extPartyId) {
ensurePracticeTable();
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
List<CompetencySnapshot> rows = jdbcTemplate.query("""
SELECT
COUNT(*) completed,
COALESCE(ROUND(AVG(total_score)), 82) score,
SUM(CASE WHEN status = '待复盘' THEN 1 ELSE 0 END) pending_review,
COALESCE(ROUND(AVG(dim_compliance)), 82) compliance,
COALESCE(ROUND(AVG(dim_communication)), 82) communication,
COALESCE(ROUND(AVG(dim_emotion)), 82) emotion
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND (? = '' OR ext_party_id = ?)
""", (rs, rowNum) -> new CompetencySnapshot(
rs.getInt("completed"),
rs.getInt("score"),
rs.getInt("pending_review"),
rs.getInt("compliance"),
rs.getInt("communication"),
rs.getInt("emotion")
), TENANT_ID, party, party);
CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 82, 0, 82, 82, 82) : rows.get(0);
return new CompetencyResponse(
snapshot.score(),
snapshot.completed(),
snapshot.pendingReview(),
List.of(
new DimensionResponse("合规", snapshot.compliance(), "SOP关键点覆盖"),
new DimensionResponse("沟通", snapshot.communication(), "承诺与表达清晰度"),
new DimensionResponse("情绪", snapshot.emotion(), "安抚与承接能力")
)
);
}
private boolean isMobile(StartRequest request) {
return request != null && "mobile".equalsIgnoreCase(request.mode());
}
private String resolveExtPartyId(StartRequest request, String trainee) {
if (request == null || isBlank(request.extPartyId())) {
return trainee;
}
return request.extPartyId().trim();
}
private String resolveTrainee(StartRequest request, ScenarioSeed scenario) {
if (!isMobile(request) || request.extPartyId() == null || request.extPartyId().isBlank()) {
return scenario.trainee();
}
String extPartyId = request.extPartyId().trim();
if (extPartyId.matches("\\d{11}")) {
return "手机用户" + extPartyId.substring(7);
}
return extPartyId;
}
private String formatNow() {
return LocalDateTime.now().format(TIME_FORMATTER);
}
private void savePracticeRecord(String sessionId, ActiveSession activeSession, ScenarioSeed scenario, RecordResponse record) {
ensurePracticeTable();
LocalDateTime now = LocalDateTime.now();
LocalDateTime started = activeSession == null ? now : activeSession.startedAt();
String id = isBlank(sessionId) ? "seed-" + scenario.id() + "-" + System.currentTimeMillis() : sessionId;
boolean mobile = activeSession != null && activeSession.mobile();
jdbcTemplate.update("""
INSERT INTO aihr_practice_session
(tenant_id, session_id, ext_party_id, trainee_name, scenario_id, scenario_name, mode,
total_score, dim_compliance, dim_emotion, dim_communication, dim_marketing,
mentor_rewrite, ai_comment, summary, dialogue_json, trust_curve, status, started_time, finished_time, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_score = VALUES(total_score),
dim_compliance = VALUES(dim_compliance),
dim_emotion = VALUES(dim_emotion),
dim_communication = VALUES(dim_communication),
dim_marketing = VALUES(dim_marketing),
mentor_rewrite = VALUES(mentor_rewrite),
ai_comment = VALUES(ai_comment),
summary = VALUES(summary),
dialogue_json = VALUES(dialogue_json),
trust_curve = VALUES(trust_curve),
status = VALUES(status),
finished_time = VALUES(finished_time),
update_time = VALUES(update_time)
""",
TENANT_ID,
id,
activeSession == null ? record.trainee() : activeSession.extPartyId(),
record.trainee(),
scenario.id(),
scenario.name(),
mobile ? "mobile" : "text",
scenario.total(),
scoreValue(scenario, "合规"),
scoreValue(scenario, "情绪"),
scoreValue(scenario, "沟通"),
scoreValue(scenario, "营销"),
scenario.rewrite(),
scenario.summary(),
record.summary(),
dialogueJson(activeSession, scenario),
scenario.trustStart() + "," + scenario.rounds().get(0).trust() + "," + scenario.trustEnd(),
record.status(),
Timestamp.valueOf(started),
Timestamp.valueOf(now),
Timestamp.valueOf(now),
Timestamp.valueOf(now)
);
}
private int countMobileRecords(String extPartyId) {
ensurePracticeTable();
if (isBlank(extPartyId)) {
return count("""
SELECT COUNT(*)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
""", TENANT_ID);
}
return count("""
SELECT COUNT(*)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND ext_party_id = ?
""", TENANT_ID, extPartyId.trim());
}
private int countPendingReview(String extPartyId) {
ensurePracticeTable();
if (isBlank(extPartyId)) {
return count("""
SELECT COUNT(*)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'
""", TENANT_ID);
}
return count("""
SELECT COUNT(*)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘' AND ext_party_id = ?
""", TENANT_ID, extPartyId.trim());
}
private int count(String sql, Object... args) {
Integer value = jdbcTemplate.queryForObject(sql, Integer.class, args);
return value == null ? 0 : value;
}
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
Timestamp finishedTime = rs.getTimestamp("finished_time");
String time = finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMATTER);
return new RecordResponse(
rs.getLong("id"),
rs.getString("session_id"),
time,
rs.getString("trainee_name"),
rs.getString("scenario_name"),
rs.getInt("total_score"),
rs.getString("status"),
rs.getString("summary")
);
}
private ReviewDetailResponse mapReviewDetail(ResultSet rs, int rowNum) throws SQLException {
Timestamp finishedTime = rs.getTimestamp("finished_time");
String time = finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMATTER);
ScenarioSeed scenario = resolveScenario(rs.getString("scenario_id"), rs.getString("session_id"));
return new ReviewDetailResponse(
rs.getLong("id"),
rs.getString("session_id"),
time,
rs.getString("trainee_name"),
rs.getString("scenario_name"),
rs.getInt("total_score"),
rs.getString("status"),
rs.getString("summary"),
rs.getString("mentor_rewrite"),
rs.getString("ai_comment"),
scoreItems(rs),
readDialogue(rs.getString("dialogue_json"), scenario)
);
}
private List<DimensionResponse> scoreItems(ResultSet rs) throws SQLException {
List<DimensionResponse> items = new ArrayList<>();
addScoreItem(items, "合规", rs.getObject("dim_compliance"), "SOP关键点覆盖");
addScoreItem(items, "沟通", rs.getObject("dim_communication"), "承诺与表达清晰度");
addScoreItem(items, "情绪", rs.getObject("dim_emotion"), "安抚与承接能力");
addScoreItem(items, "营销", rs.getObject("dim_marketing"), "增值转化意识");
return items;
}
private void addScoreItem(List<DimensionResponse> items, String label, Object value, String note) {
if (value instanceof Number number) {
items.add(new DimensionResponse(label, number.intValue(), note));
}
}
private void rememberTraineeReply(TurnRequest request, int roundIndex) {
if (request == null || isBlank(request.sessionId()) || isBlank(request.traineeText())) {
return;
}
ActiveSession session = activeSessions.get(request.sessionId());
if (session == null) {
return;
}
List<String> replies = session.traineeReplies();
synchronized (replies) {
while (replies.size() <= roundIndex) {
replies.add("");
}
replies.set(roundIndex, request.traineeText().trim());
}
}
private String dialogueJson(ActiveSession activeSession, ScenarioSeed scenario) {
try {
return objectMapper.writeValueAsString(dialogueTurns(activeSession, scenario));
} catch (JsonProcessingException ignored) {
return "[]";
}
}
private List<DialogueResponse> readDialogue(String json, ScenarioSeed scenario) {
if (!isBlank(json)) {
try {
List<DialogueResponse> result = new ArrayList<>();
JsonNode root = objectMapper.readTree(json);
if (root.isArray()) {
for (JsonNode item : root) {
result.add(new DialogueResponse(
item.path("role").asText(),
item.path("label").asText(),
item.path("text").asText()
));
}
if (!result.isEmpty()) {
return result;
}
}
} catch (JsonProcessingException ignored) {
// Fall through to seed dialogue.
}
}
return dialogueTurns(null, scenario);
}
private List<DialogueResponse> dialogueTurns(ActiveSession activeSession, ScenarioSeed scenario) {
List<DialogueResponse> turns = new ArrayList<>();
List<String> replies = activeSession == null ? List.of() : activeSession.traineeReplies();
for (int i = 0; i < scenario.rounds().size(); i++) {
RoundSeed round = scenario.rounds().get(i);
turns.add(new DialogueResponse("customer", "AI业主", round.customer()));
if (i < replies.size() && !isBlank(replies.get(i))) {
turns.add(new DialogueResponse("trainee", "员工话术", replies.get(i)));
}
turns.add(new DialogueResponse("coach", "AI教练", round.coachHint()));
}
return turns;
}
private Integer scoreValue(ScenarioSeed scenario, String label) {
for (DimensionResponse score : scenario.scores()) {
if (label.equals(score.label()) && score.value() instanceof Number) {
return ((Number) score.value()).intValue();
}
}
return null;
}
private int normalizeLimit(int limit) {
if (limit <= 0) {
return 5;
}
return Math.min(limit, 20);
}
private void ensurePracticeTable() {
if (practiceTableReady) {
return;
}
synchronized (this) {
if (practiceTableReady) {
return;
}
// ponytail: create-if-missing keeps current dev DB usable without forcing reset; replace with migration when schema tooling exists.
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
`ext_party_id` varchar(100) DEFAULT NULL COMMENT '外部主体或手机号',
`trainee_name` varchar(100) DEFAULT NULL COMMENT '学员名称',
`scenario_id` varchar(80) DEFAULT NULL COMMENT '场景ID',
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
`mode` varchar(30) DEFAULT 'text' COMMENT '训练模式',
`total_score` int DEFAULT NULL COMMENT '总分',
`dim_compliance` int DEFAULT NULL COMMENT '合规分',
`dim_emotion` int DEFAULT NULL COMMENT '情绪分',
`dim_communication` int DEFAULT NULL COMMENT '沟通分',
`dim_marketing` int DEFAULT NULL COMMENT '营销分',
`mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写',
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
`trust_curve` varchar(100) DEFAULT NULL COMMENT '信任曲线',
`status` varchar(30) DEFAULT '待复盘' COMMENT '状态',
`started_time` datetime DEFAULT NULL COMMENT '开始时间',
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_session` (`tenant_id`, `session_id`),
KEY `idx_aihr_practice_party` (`tenant_id`, `ext_party_id`, `finished_time`),
KEY `idx_aihr_practice_review` (`tenant_id`, `mode`, `status`, `finished_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练记录';
""");
ensurePracticeColumn("dialogue_json", "ALTER TABLE aihr_practice_session ADD COLUMN `dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON' AFTER `summary`");
practiceTableReady = true;
}
}
private void ensurePracticeColumn(String column, String ddl) {
Integer exists = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_practice_session' AND COLUMN_NAME = ?
""", Integer.class, column);
if (exists == null || exists == 0) {
jdbcTemplate.execute(ddl);
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private ScenarioSeed resolveScenario(String scenarioId, String sessionId) {
if (scenarioId != null && scenarios.containsKey(scenarioId)) {
return scenarios.get(scenarioId);
@@ -185,4 +624,10 @@ public class AihrPracticeSeedService {
private record RoundSeed(String customer, String coachHint, Integer emotion, Integer trust) {
}
private record CompetencySnapshot(Integer completed, Integer score, Integer pendingReview, Integer compliance, Integer communication, Integer emotion) {
}
private record ActiveSession(String scenarioId, String trainee, String extPartyId, boolean mobile, LocalDateTime startedAt, List<String> traineeReplies) {
}
}
@@ -21,6 +21,8 @@ import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.AutoDetectParser;
@@ -54,6 +56,7 @@ import java.sql.Timestamp;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashSet;
@@ -76,6 +79,9 @@ public class AihrSopSeedService {
private static final String TENANT_ID = "000000";
private static final String AUTO_CATEGORY = "__auto__";
private static final String LOCAL_EMBEDDING_MODEL = "local-hash-v1";
private static final String LOCAL_EMBEDDING_BASE_URL = "local";
private static final int LOCAL_EMBEDDING_DIMENSION = 1536;
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final Map<String, SopSeed> seeds = buildSeeds();
@@ -94,9 +100,11 @@ public class AihrSopSeedService {
}
public SearchResponse search(SearchRequest request) {
SopSeed seed = seeds.getOrDefault(request == null ? null : request.category(), seeds.get("投诉处理 SOP"));
String requestedCategory = request == null ? "" : request.category();
SopSeed seed = seeds.getOrDefault(requestedCategory, seeds.get("投诉处理 SOP"));
String category = isBlank(requestedCategory) ? seed.category() : requestedCategory;
String queryText = request == null || request.queryText() == null || request.queryText().isBlank() ? seed.question() : request.queryText();
SearchResponse dbResponse = dbSearch(seed.category(), queryText, request == null ? null : request.limit());
SearchResponse dbResponse = dbSearch(category, queryText, request == null ? null : request.limit());
if (dbResponse != null && !dbResponse.snippets().isEmpty()) {
return dbResponse;
}
@@ -127,6 +135,83 @@ public class AihrSopSeedService {
return saveDocument(fileName, category, () -> ossService.upload(file), () -> readContent(file, fileName), () -> fileFingerprint(file));
}
public VectorizeResponse vectorizeMissing() {
List<VectorizeTarget> targets = jdbcTemplate.query("""
select f.knowledge_id,
i.name as category,
f.doc_id,
count(*) as fragments
from aihr_knowledge_fragment f
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
where f.tenant_id = ?
and (f.embedding_json is null or f.embedding_json = '')
group by f.knowledge_id, i.name, f.doc_id
order by max(f.update_time) desc
limit 200
""", (rs, rowNum) -> new VectorizeTarget(
rs.getLong("knowledge_id"),
rs.getString("category"),
rs.getString("doc_id")
), TENANT_ID);
int documents = 0;
int fragments = 0;
String model = "";
for (VectorizeTarget target : targets) {
List<String> rows = jdbcTemplate.queryForList("""
select content
from aihr_knowledge_fragment
where tenant_id = ? and knowledge_id = ? and doc_id = ?
order by idx asc
""", String.class, TENANT_ID, target.knowledgeId(), target.docId());
EmbeddingBatch batch = embedFragments(target.knowledgeId(), target.category(), target.docId(), rows);
if (batch.count() > 0) {
documents++;
fragments += batch.count();
model = batch.modelName();
}
}
return new VectorizeResponse(documents, fragments, firstNonBlank(model, LOCAL_EMBEDDING_MODEL));
}
public VectorIndexStatusResponse vectorIndexStatus() {
EmbeddingRuntime runtime = embeddingRuntimes().get(0);
VectorDbStats db = vectorDbStats();
QdrantStats qdrant = qdrantStats();
int expectedDimension = runtime.dimension();
boolean matched = expectedDimension > 0
&& qdrant.dimension() != null
&& qdrant.dimension() == expectedDimension
&& (db.embeddedFragments() == 0 || Objects.equals(db.embeddingDimension(), expectedDimension))
&& (qdrant.points() == null || qdrant.points() == db.embeddedFragments());
String message = matched
? "向量库与当前模型一致"
: firstNonBlank(qdrant.message(), "向量库与当前模型不一致,建议重建");
return new VectorIndexStatusResponse(
runtime.modelName(),
expectedDimension,
qdrantCollection(),
qdrant.dimension(),
qdrant.points(),
db.fragments(),
db.embeddedFragments(),
db.embeddingModel(),
db.embeddingDimension(),
matched,
message
);
}
public VectorizeResponse rebuildVectorIndex() {
jdbcTemplate.update("""
update aihr_knowledge_fragment
set embedding_json = null, embedding_model = null, embedding_time = null, update_time = now()
where tenant_id = ?
""", TENANT_ID);
deleteQdrantCollection();
return vectorizeMissing();
}
public LocalImportResponse importLocalDocs(LocalImportRequest request) {
LocalImportPlan plan = localImportPlan(request);
LocalImportRun run = executeLocalImport(plan, null);
@@ -525,6 +610,79 @@ public class AihrSopSeedService {
}
}
/** 优先查 category='vision',无则回退 category='chat'(大多数多模态模型通用)。 */
private Optional<ChatRuntime> visionRuntime() {
try {
List<ChatRuntime> rows = jdbcTemplate.query("""
select c.model_name,
coalesce(nullif(c.api_host, ''), nullif(p.api_host, '')) as resolved_api_host,
coalesce(nullif(c.api_key, ''), nullif(p.api_key, '')) as resolved_api_key
from aihr_model_config c
left join aihr_model_provider p
on p.tenant_id = c.tenant_id and p.provider_code = c.provider_code
where c.tenant_id = ?
and c.category in ('vision', 'chat')
and c.enabled = 1
and (p.status is null or p.status = '0')
order by case c.category when 'vision' then 0 else 1 end,
case when c.model_show = 'Y' then 0 else 1 end, c.id asc
limit 1
""", (rs, rowNum) -> new ChatRuntime(
rs.getString("model_name"),
rs.getString("resolved_api_host"),
rs.getString("resolved_api_key")
), TENANT_ID);
return rows.stream()
.filter(runtime -> !isBlank(runtime.modelName()) && !isBlank(runtime.baseUrl()))
.findFirst();
} catch (DataAccessException e) {
return Optional.empty();
}
}
private String callVisionOcr(ChatRuntime runtime, byte[] imageBytes, String mimeType) throws Exception {
String dataUrl = "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(imageBytes);
ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName());
body.put("temperature", 0);
body.put("max_tokens", 4096);
ArrayNode messages = body.putArray("messages");
ObjectNode user = messages.addObject();
user.put("role", "user");
ArrayNode content = user.putArray("content");
ObjectNode textPart = content.addObject();
textPart.put("type", "text");
textPart.put("text", "请识别图片中的所有文字内容,保持原有格式和段落结构,直接输出识别到的文字,不要添加任何解释说明。如果图片不含可识别文字,返回空字符串。");
ObjectNode imagePart = content.addObject();
imagePart.put("type", "image_url");
imagePart.putObject("image_url").put("url", dataUrl).put("detail", "high");
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
.timeout(Duration.ofSeconds(120))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
if (!isBlank(runtime.apiKey())) {
builder.header("Authorization", "Bearer " + runtime.apiKey());
}
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build()
.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (!ok(response.statusCode())) {
throw new IllegalStateException("vision OCR HTTP " + response.statusCode());
}
JsonNode choices = objectMapper.readTree(response.body()).path("choices");
if (!choices.isArray() || choices.isEmpty()) {
throw new IllegalStateException("vision OCR response missing choices");
}
return choices.get(0).path("message").path("content").asText();
}
private Optional<DuplicateHit> duplicateHit(String fileName, DocumentFingerprint fingerprint) {
return duplicateHits(fileName, fingerprint).stream().findFirst();
}
@@ -683,7 +841,7 @@ public class AihrSopSeedService {
.map(this::processingEvent)
.toList();
ProcessingRulesResponse rules = new ProcessingRulesResponse(true, false, true, true, failed);
ProcessingRulesResponse rules = new ProcessingRulesResponse(true, visionRuntime().isPresent(), true, true, failed);
return new ProcessingOverviewResponse(metrics, categories, tasks, pipeline, events, rules);
}
@@ -1077,30 +1235,44 @@ public class AihrSopSeedService {
return "success";
}
private void embedFragments(long knowledgeId, String category, String docId, List<String> fragments) {
Optional<EmbeddingRuntime> runtime = embeddingRuntime();
if (runtime.isEmpty() || fragments.isEmpty()) {
return;
private EmbeddingBatch embedFragments(long knowledgeId, String category, String docId, List<String> fragments) {
if (fragments.isEmpty()) {
return new EmbeddingBatch("", 0);
}
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
List<String> embeddings;
try {
List<String> embeddings = callEmbeddings(runtime.get(), fragments);
embeddings = callEmbeddings(runtime, fragments);
} catch (Exception ignored) {
// ponytail: vector config is optional; try the next runtime before falling back to fulltext.
continue;
}
if (embeddings.size() != fragments.size()) {
continue;
}
for (int i = 0; i < embeddings.size(); i++) {
jdbcTemplate.update("""
update aihr_knowledge_fragment
set embedding_json = ?, embedding_model = ?, embedding_time = now(), update_time = now()
where tenant_id = ? and knowledge_id = ? and doc_id = ? and idx = ?
""", embeddings.get(i), runtime.get().modelName(), TENANT_ID, knowledgeId, docId, i + 1);
""", embeddings.get(i), runtime.modelName(), TENANT_ID, knowledgeId, docId, i + 1);
}
upsertQdrant(knowledgeId, category, docId, fragments, embeddings, runtime.get().modelName());
try {
upsertQdrant(knowledgeId, category, docId, fragments, embeddings, runtime.modelName());
} catch (Exception ignored) {
// ponytail: embedding is opportunistic; upload/search should survive bad vector config.
// ponytail: keep MySQL embeddings; the status panel tells users when Qdrant needs rebuild.
}
return new EmbeddingBatch(runtime.modelName(), embeddings.size());
}
return new EmbeddingBatch("", 0);
}
private Optional<EmbeddingRuntime> embeddingRuntime() {
private List<EmbeddingRuntime> embeddingRuntimes() {
List<EmbeddingRuntime> runtimes = new ArrayList<>();
try {
List<EmbeddingRuntime> rows = jdbcTemplate.query("""
runtimes.addAll(jdbcTemplate.query("""
select c.model_name,
coalesce(c.model_dimension, 0) as model_dimension,
coalesce(nullif(c.api_host, ''), nullif(p.api_host, '')) as resolved_api_host,
coalesce(nullif(c.api_key, ''), nullif(p.api_key, '')) as resolved_api_key
from aihr_model_config c
@@ -1111,21 +1283,27 @@ public class AihrSopSeedService {
and c.enabled = 1
and (p.status is null or p.status = '0')
order by c.id asc
limit 1
limit 3
""", (rs, rowNum) -> new EmbeddingRuntime(
rs.getString("model_name"),
rs.getString("resolved_api_host"),
rs.getString("resolved_api_key")
), TENANT_ID);
return rows.stream()
rs.getString("resolved_api_key"),
rs.getInt("model_dimension")
), TENANT_ID).stream()
.filter(runtime -> configured(runtime.baseUrl(), runtime.modelName(), runtime.apiKey()))
.findFirst();
} catch (DataAccessException e) {
return Optional.empty();
.toList());
} catch (DataAccessException ignored) {
// fall through to local fallback
}
runtimes.add(new EmbeddingRuntime(LOCAL_EMBEDDING_MODEL, LOCAL_EMBEDDING_BASE_URL, "", LOCAL_EMBEDDING_DIMENSION));
return runtimes;
}
private List<String> callEmbeddings(EmbeddingRuntime runtime, List<String> fragments) throws Exception {
if (LOCAL_EMBEDDING_BASE_URL.equals(runtime.baseUrl())) {
return localEmbeddings(fragments);
}
ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName());
ArrayNode input = body.putArray("input");
@@ -1159,13 +1337,55 @@ public class AihrSopSeedService {
return embeddings.size() == fragments.size() ? embeddings : List.of();
}
private List<String> localEmbeddings(List<String> fragments) {
return fragments.stream()
.map(this::localEmbedding)
.toList();
}
private String localEmbedding(String text) {
double[] vector = new double[LOCAL_EMBEDDING_DIMENSION];
String cleaned = Optional.ofNullable(text).orElse("")
.toLowerCase()
.replaceAll("[^\\p{IsHan}\\p{Alnum}]+", " ")
.trim();
for (String term : cleaned.split("\\s+")) {
if (term.isBlank()) {
continue;
}
addLocalEmbeddingTerm(vector, term);
for (int i = 0; i + 2 <= term.length(); i++) {
addLocalEmbeddingTerm(vector, term.substring(i, i + 2));
}
}
double norm = 0;
for (double value : vector) {
norm += value * value;
}
norm = Math.sqrt(norm);
if (norm == 0) {
vector[0] = 1;
norm = 1;
}
ArrayNode array = objectMapper.createArrayNode();
for (double value : vector) {
array.add(Math.round((value / norm) * 1_000_000d) / 1_000_000d);
}
return array.toString();
}
private static void addLocalEmbeddingTerm(double[] vector, String term) {
int hash = term.hashCode();
int index = Math.floorMod(hash, vector.length);
vector[index] += (hash & 1) == 0 ? 1 : -1;
}
private SearchResponse dbSearch(String category, String queryText, Integer limit) {
try {
int limitValue = limitValue(limit);
List<KnowledgeHit> hits = rrf(fulltextHits(category, queryText, limitValue), vectorHits(category, queryText, limitValue), limitValue);
if (hits.isEmpty()) {
hits = likeHits(category, queryText, limitValue);
}
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue);
List<KnowledgeHit> hits = rrf(textHits, vectorHits(category, queryText, limitValue), limitValue);
if (hits.isEmpty()) {
return null;
}
@@ -1176,25 +1396,25 @@ public class AihrSopSeedService {
}
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit) {
Optional<EmbeddingRuntime> runtime = embeddingRuntime();
if (runtime.isEmpty()) {
return List.of();
}
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
try {
List<String> embeddings = callEmbeddings(runtime.get(), List.of(queryText));
List<String> embeddings = callEmbeddings(runtime, List.of(queryText));
if (embeddings.isEmpty()) {
return List.of();
continue;
}
List<VectorMatch> matches = queryQdrant(category, embeddings.get(0), limit);
List<KnowledgeHit> hits = new ArrayList<>();
for (VectorMatch match : matches) {
hydrateQdrantHit(match).ifPresent(hits::add);
}
if (!hits.isEmpty()) {
return hits;
} catch (Exception ignored) {
// ponytail: vector recall is additive; MySQL fulltext remains the demo-safe path.
return List.of();
}
} catch (Exception ignored) {
// ponytail: vector recall is additive; try the next runtime before fulltext fallback.
}
}
return List.of();
}
private List<KnowledgeHit> fulltextHits(String category, String queryText, int limit) {
@@ -1226,23 +1446,40 @@ public class AihrSopSeedService {
}
private List<KnowledgeHit> likeHits(String category, String queryText, int limit) {
return jdbcTemplate.query("""
List<String> terms = searchTerms(queryText);
if (terms.isEmpty()) {
return List.of();
}
List<String> scoreParts = new ArrayList<>();
List<String> whereParts = new ArrayList<>();
for (int i = 0; i < terms.size(); i++) {
scoreParts.add("case when f.content like ? escape '\\\\' then 1 else 0 end");
whereParts.add("f.content like ? escape '\\\\'");
}
String sql = """
select coalesce(a.name, i.name) as title,
i.name as category,
coalesce(i.description, '') as description,
f.doc_id,
f.content,
f.idx,
0.1 as score
(%s) as score
from aihr_knowledge_fragment f
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id
where f.tenant_id = ?
and i.name = ?
and f.content like ?
order by f.idx asc
and (%s)
order by score desc, f.idx asc
limit ?
""", (rs, rowNum) -> new KnowledgeHit(
""".formatted(String.join(" + ", scoreParts), String.join(" or ", whereParts));
List<Object> args = new ArrayList<>();
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
args.add(TENANT_ID);
args.add(category);
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
args.add(limit);
return jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
rs.getString("title"),
rs.getString("category"),
rs.getString("description"),
@@ -1250,7 +1487,7 @@ public class AihrSopSeedService {
rs.getString("content"),
rs.getInt("idx"),
rs.getDouble("score")
), TENANT_ID, category, "%" + queryText + "%", limit);
), args.toArray());
}
private Optional<KnowledgeHit> hydrateQdrantHit(VectorMatch match) {
@@ -1333,6 +1570,37 @@ public class AihrSopSeedService {
return Math.max(1, Math.min(limit, 10));
}
private static List<String> searchTerms(String queryText) {
String cleaned = Optional.ofNullable(queryText).orElse("")
.toLowerCase()
.replaceAll("[^\\p{IsHan}\\p{Alnum}]+", " ")
.trim();
if (cleaned.isEmpty()) {
return List.of();
}
LinkedHashSet<String> terms = new LinkedHashSet<>();
for (String token : cleaned.split("\\s+")) {
if (token.length() >= 2 && token.length() <= 16) {
terms.add(token);
}
for (int i = 0; i + 2 <= token.length() && terms.size() < 12; i++) {
terms.add(token.substring(i, i + 2));
}
if (terms.size() >= 12) {
break;
}
}
return terms.stream().limit(12).toList();
}
private static String likePattern(String term) {
String escaped = term
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
return "%" + escaped + "%";
}
private static String scoreText(double score) {
return score <= 0 ? "片段" : String.format("%.2f", score);
}
@@ -1490,9 +1758,69 @@ public class AihrSopSeedService {
return matches;
}
private VectorDbStats vectorDbStats() {
try {
return jdbcTemplate.queryForObject("""
select count(*) as fragments,
sum(case when embedding_json is not null and embedding_json <> '' then 1 else 0 end) as embedded_fragments,
min(embedding_model) as min_model,
max(embedding_model) as max_model,
min(json_length(embedding_json)) as min_dimension,
max(json_length(embedding_json)) as max_dimension
from aihr_knowledge_fragment
where tenant_id = ?
""", (rs, rowNum) -> {
String minModel = rs.getString("min_model");
String maxModel = rs.getString("max_model");
int minDimension = rs.getInt("min_dimension");
int maxDimension = rs.getInt("max_dimension");
String model = Objects.equals(minModel, maxModel) ? firstNonBlank(minModel, "") : "mixed";
Integer dimension = minDimension > 0 && minDimension == maxDimension ? minDimension : null;
return new VectorDbStats(
rs.getInt("fragments"),
rs.getInt("embedded_fragments"),
model,
dimension
);
}, TENANT_ID);
} catch (DataAccessException e) {
return new VectorDbStats(0, 0, "", null);
}
}
private QdrantStats qdrantStats() {
try {
HttpResponse<String> response = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
if (response.statusCode() == 404) {
return new QdrantStats(null, 0L, "Qdrant collection 不存在");
}
if (!ok(response.statusCode())) {
return new QdrantStats(null, null, "Qdrant 状态读取失败");
}
JsonNode result = objectMapper.readTree(response.body()).path("result");
Integer dimension = result.path("config").path("params").path("vectors").path("size").isInt()
? result.path("config").path("params").path("vectors").path("size").asInt()
: null;
long points = result.path("points_count").asLong(0);
return new QdrantStats(dimension, points, "");
} catch (Exception e) {
return new QdrantStats(null, null, "Qdrant 不可用");
}
}
private void ensureQdrantCollection(int vectorSize) throws Exception {
HttpResponse<String> current = qdrantRequest("GET", "/collections/" + qdrantCollection(), null);
if (ok(current.statusCode())) {
int currentSize = objectMapper.readTree(current.body())
.path("result")
.path("config")
.path("params")
.path("vectors")
.path("size")
.asInt(0);
if (currentSize > 0 && currentSize != vectorSize) {
throw new IllegalStateException("qdrant dimension mismatch " + currentSize + " != " + vectorSize);
}
return;
}
if (current.statusCode() != 404) {
@@ -1510,6 +1838,20 @@ public class AihrSopSeedService {
}
}
private void deleteQdrantCollection() {
try {
HttpResponse<String> response = qdrantRequest("DELETE", "/collections/" + qdrantCollection(), null);
if (response.statusCode() == 404) {
return;
}
if (!ok(response.statusCode())) {
throw new IllegalStateException("qdrant delete collection HTTP " + response.statusCode());
}
} catch (Exception ignored) {
// ponytail: rebuild still refreshes MySQL embeddings; status shows if Qdrant stayed stale.
}
}
private ObjectNode qdrantFilter(Long knowledgeId, String docId, String category) {
ObjectNode filter = objectMapper.createObjectNode();
ArrayNode must = filter.putArray("must");
@@ -1563,10 +1905,23 @@ public class AihrSopSeedService {
.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
private static String readContent(MultipartFile file, String fileName) {
private String readContent(MultipartFile file, String fileName) {
if (textFile(fileName)) {
return normalizeMarkdown(readUtf8(file));
}
if (imageFile(fileName)) {
Optional<ChatRuntime> runtime = visionRuntime();
if (runtime.isPresent()) {
try {
byte[] bytes = file.getBytes();
String text = callVisionOcr(runtime.get(), bytes, guessMimeType(fileName));
return normalizeExtractedText(text);
} catch (Exception e) {
return "";
}
}
return "";
}
try (InputStream input = file.getInputStream()) {
BodyContentHandler handler = new BodyContentHandler(-1);
Metadata metadata = new Metadata();
@@ -1578,10 +1933,23 @@ public class AihrSopSeedService {
}
}
private static String readContent(Path file, String fileName) {
private String readContent(Path file, String fileName) {
if (textFile(fileName)) {
return normalizeMarkdown(readUtf8(file));
}
if (imageFile(fileName)) {
Optional<ChatRuntime> runtime = visionRuntime();
if (runtime.isPresent()) {
try {
byte[] bytes = Files.readAllBytes(file);
String text = callVisionOcr(runtime.get(), bytes, guessMimeType(fileName));
return normalizeExtractedText(text);
} catch (Exception e) {
return "";
}
}
return "";
}
try (InputStream input = Files.newInputStream(file)) {
BodyContentHandler handler = new BodyContentHandler(-1);
Metadata metadata = new Metadata();
@@ -1737,6 +2105,7 @@ public class AihrSopSeedService {
private static boolean supportedFile(String fileName) {
String lower = fileName.toLowerCase();
return textFile(lower)
|| imageFile(lower)
|| lower.endsWith(".pdf")
|| lower.endsWith(".doc")
|| lower.endsWith(".docx")
@@ -1751,6 +2120,23 @@ public class AihrSopSeedService {
return lower.endsWith(".txt") || lower.endsWith(".md") || lower.endsWith(".markdown");
}
private static boolean imageFile(String fileName) {
String lower = fileName.toLowerCase();
return lower.endsWith(".jpg") || lower.endsWith(".jpeg")
|| lower.endsWith(".png") || lower.endsWith(".gif")
|| lower.endsWith(".webp") || lower.endsWith(".bmp");
}
private static String guessMimeType(String fileName) {
String lower = fileName.toLowerCase();
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".bmp")) return "image/bmp";
return "image/jpeg";
}
private static String fileType(String fileName) {
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
}
@@ -1924,7 +2310,19 @@ public class AihrSopSeedService {
private record KnowledgeConfig(long knowledgeId, int blockSize, int overlap) {
}
private record EmbeddingRuntime(String modelName, String baseUrl, String apiKey) {
private record EmbeddingRuntime(String modelName, String baseUrl, String apiKey, int dimension) {
}
private record EmbeddingBatch(String modelName, int count) {
}
private record VectorDbStats(int fragments, int embeddedFragments, String embeddingModel, Integer embeddingDimension) {
}
private record QdrantStats(Integer dimension, Long points, String message) {
}
private record VectorizeTarget(long knowledgeId, String category, String docId) {
}
private record ChatRuntime(String modelName, String baseUrl, String apiKey) {
@@ -0,0 +1,32 @@
-- 物业 AI HR 对练记录最小表结构。
-- ponytail: one session table covers history, review list and profile aggregation for MVP.
CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
`ext_party_id` varchar(100) DEFAULT NULL COMMENT '外部主体或手机号',
`trainee_name` varchar(100) DEFAULT NULL COMMENT '学员名称',
`scenario_id` varchar(80) DEFAULT NULL COMMENT '场景ID',
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
`mode` varchar(30) DEFAULT 'text' COMMENT '训练模式',
`total_score` int DEFAULT NULL COMMENT '总分',
`dim_compliance` int DEFAULT NULL COMMENT '合规分',
`dim_emotion` int DEFAULT NULL COMMENT '情绪分',
`dim_communication` int DEFAULT NULL COMMENT '沟通分',
`dim_marketing` int DEFAULT NULL COMMENT '营销分',
`mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写',
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
`trust_curve` varchar(100) DEFAULT NULL COMMENT '信任曲线',
`status` varchar(30) DEFAULT '待复盘' COMMENT '状态',
`started_time` datetime DEFAULT NULL COMMENT '开始时间',
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_session` (`tenant_id`, `session_id`),
KEY `idx_aihr_practice_party` (`tenant_id`, `ext_party_id`, `finished_time`),
KEY `idx_aihr_practice_review` (`tenant_id`, `mode`, `status`, `finished_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练记录';
+28
View File
@@ -0,0 +1,28 @@
# Design QA
source visual truth path:
- `/Users/yuanjiantsui/dev/11-project/wygj/docs/prototypes/员工端-首页.png`
- `/Users/yuanjiantsui/dev/11-project/wygj/docs/prototypes/候选人端-首页.png`
- `/Users/yuanjiantsui/dev/11-project/wygj/docs/prototypes/主管端-首页.png`
implementation screenshot path:
- `/Users/yuanjiantsui/dev/11-project/wygj/.tmp/design-qa/css-user.png`
- `/Users/yuanjiantsui/dev/11-project/wygj/.tmp/design-qa/css-candidate.png`
- `/Users/yuanjiantsui/dev/11-project/wygj/.tmp/design-qa/css-supervisor.png`
viewport: 426x922 CSS px
state: default home route for `/h5/user`, `/h5/candidate`, `/h5/supervisor`
full-view comparison evidence: local Chrome screenshots were captured at mobile viewport and compared against the supplied PNG prototypes.
focused region comparison evidence: checked header, first task/team card, tool/todo cards, and fixed bottom navigation; no focused crop needed after final pass.
findings:
- No actionable P0/P1/P2 findings remain. Remaining differences are acceptable prototype constraints: avatar portraits are lightweight local CSS portraits instead of photo assets, and candidate copy is role-specific because the candidate reference image duplicates the employee layout.
patches made since previous QA pass:
- Rebuilt `mobile/src/App.vue` around the three prototype layouts.
- Removed the visible API placeholder note from the home screens.
- Switched the app style from scoped to global so render-function child nodes receive button, card, and icon styles.
- Reduced top spacing and aligned sub-640px rendering to the mobile viewport.
final result: passed
@@ -1,31 +1,31 @@
# AI 人力资源系统 · 一期 Demo 作战清单
# AI 人力资源系统 · 一期 MVP 版作战清单
> 版本:v1.8 | 日期:2026-07-03
> 用途:一份纸面作战图,支撑「2026-07-05 周日出可跑 Demo、2026-07-06 周一给大老板演示」,同时说清「做了什么、没做什么、为什么」。
> 用途:一份纸面作战图,支撑「2026-07-05 周日出可跑 MVP 演示、2026-07-06 周一给大老板演示」,同时说清「做了什么、没做什么、为什么」。
> 收敛来源:原《物业管家与生活顾问 AI 陪练系统建设方案》+ 一期实施方案(含 ch.14/15 选型评估)+ 2026-07-01 需求讨论纪要 + 数据资源模型主题域草案。
> v1.1 变更:D1–D8 决策已定(见第 8 节),相关章节同步锁定;D9(验收标准)待定。
> v1.2 变更:新增第 9 节「周末施工排期(2026-07-03 周五 / 2026-07-04 周六 / 2026-07-05 周日 · 2 人时间盒)」。
> v1.3 变更:D9 已定,新增第 10 节「一期最小可验收承诺」。至此 D1–D9 全部闭环。
> v1.4 变更:因 Demo 进度提前,新增 D10「资料中台提前加码」和第 11 节;MinIO、OSS-first 文档上传、Excel/PPT 解析进入当前施工线,图片智能分析列入下一阶段。
> v1.4 变更:因 MVP 进度提前,新增 D10「资料中台提前加码」和第 11 节;MinIO、OSS-first 文档上传、Excel/PPT 解析进入当前施工线,图片智能分析列入下一阶段。
> v1.5 变更:新增 D11「向量库与语义检索路线」和第 12 节;一期选 Qdrant,保留 MySQL Fulltext,制度/SOP 回答采用混合召回 + 重排序 + 引用约束。
> v1.6 变更:Qdrant 本地编排和后端最小闭环已落地;上传后尽力 upsert 向量,检索时与 MySQL Fulltext 做 RRF 融合。
> v1.7 变更:资料处理服务端目录导入已从同步 Demo 升级为后台任务,页面可轮询进度和按同目录重试。
> v1.8 变更:补齐 D12「用户端最小闭环」和第 5.1 节;Demo 必须能从候选人/员工视角发起至少 1 条路径,但仍用 Web/H5,不做独立 APP。
> v1.7 变更:资料处理服务端目录导入已从同步导入升级为后台任务,页面可轮询进度和按同目录重试。
> v1.8 变更:补齐 D12「用户端最小闭环」和第 5.1 节;MVP 必须能从候选人/员工视角发起至少 1 条路径,用户端拆为独立 `mobile/` Web/H5 工程,但不做原生 APP。
---
## 0. 一页速览(TL;DR)
- **系统定位**:面向物业一线的「**选 · 用 · 育 · 留**」全生命周期 **AI 人力资源系统**(HR 老大提出、给大老板汇报)。原「AI 陪练」只是其中「育/用」一个模块。
- **死线**:2026-07-05 周日出可演示 Demo,2026-07-06 周一演示。团队约 2 人,时间极紧。
- **死线**:2026-07-05 周日出可演示 MVP,2026-07-06 周一演示。团队约 2 人,时间极紧。
- **六条核心决议**:
1. **基座 = 若依(RuoYi)**,非 aifei / PandaWiki / JeecgBoot / Yuxi;集中式前后端分离,非微服务。
2. **数据模型 = 复用 Silverston《数据模型资源手册》卷1 HR 主干**(Party-Role 分离 + Product 目录承载 SOP 分层)。
3. **组织/人员/登录复用外部系统**,本系统只做「消费 + 扩展」,不当主数据源头。
4. **资料中台提前加码**:已知业务资料约 3GB,包含 PPT/图片/Excel/Word/PDF;一期不再只演示 seed SOP,要先把 MinIO + 文档入库 + 解析状态打底。
5. **语义检索路线 = MySQL Fulltext + Qdrant + rerank**:MySQL/MinIO 仍是事实源,Qdrant 只做向量索引;制度/SOP 必须混合召回、重排序、带引用回答。
6. **用户端必须显式进入 Demo**:管理端负责配置/审核/看结果;用户端负责候选人作答、员工对练、SOP 问答、案例上传。Demo 用 Web/H5 轻入口,不做独立 APP。
- **头号风险**:从单纯「范围失控」升级为**用户端缺口、资料规模、解析质量、异步任务稳定性、语义检索准确率和引用一致性**。Demo 仍守住第 5 节切割线,资料中台按第 11/12 节分片推进。
6. **用户端必须显式进入 MVP**:管理端负责配置/审核/看结果;用户端负责候选人作答、员工对练、SOP 问答、案例上传。MVP 用独立 `mobile/` Web/H5 轻入口,不做原生 APP。
- **头号风险**:从单纯「范围失控」升级为**用户端缺口、资料规模、解析质量、异步任务稳定性、语义检索准确率和引用一致性**。MVP 仍守住第 5 节切割线,资料中台按第 11/12 节分片推进。
---
@@ -43,7 +43,7 @@
**明确移出一期范围(已定)**:
- 内容营销中心(原第七章):属营销/获客,与 HR 无关 → ✅ **一期砍除**(D5)。
- 独立语音 APP(豆包式):愿景,非交付物 → ✅ **Demo 用 Web**,APP 进二期(D6)。
- 独立语音 APP(豆包式):愿景,非交付物 → ✅ **MVP 用 Web**,APP 进二期(D6)。
- 双图谱知识图谱(Neo4j):冷启动成本高 → ✅ **一期先 RAG**,图谱降级二期(D4)。
---
@@ -85,7 +85,7 @@
**两条关键建模决定**:
- **Party-Role 分离**:一个人在全生命周期里依次/同时是候选人→新员工→学员→师父→考官,用 Party + Role,不为每种身份建表。
- **SOP 用 Product-Category 层级 + Applicability 承载**:大类/中类/小类/行业 × 住宅/公建/景区。Demo 只填住宅类,其余预留结构。
- **SOP 用 Product-Category 层级 + Applicability 承载**:大类/中类/小类/行业 × 住宅/公建/景区。MVP 只填住宅类,其余预留结构。
- **关系库 vs 向量库切分**:元数据/权限/版本/正文片段事实源落 MySQL,原始文件落 MinIO,Qdrant 只存向量索引与最小 payload;MySQL Fulltext 保留做精确关键词召回。
---
@@ -94,7 +94,7 @@
**「复用采购系统用户体系」= 三件独立的事,别混为一谈**:
| 事项 | 含义 | Demo 处置 |
| 事项 | 含义 | MVP 处置 |
|---|---|---|
| ① 认证 / SSO | 用户怎么登录本系统 | ✅ 已定(D2):**本地账号**登录;SSO 留二期对接项 |
| ② 权限映射 | 外部岗位/组织 → 本地角色 | 本地建映射表 |
@@ -121,9 +121,9 @@
## 5. MVP 三分切割线(核心作战图)⭐
> 规则:**2026-07-05 Demo 只做「英雄路径 + 壳」,其余明确归入一期或推迟。任何超出「2026-07-05 Demo」列的东西,在 Demo 冲刺期即刻砍掉。**
> 规则:**2026-07-05 MVP 演示只做「英雄路径 + 壳」,其余明确归入一期或推迟。任何超出「2026-07-05 MVP 演示」列的东西,在冲刺期即刻砍掉。**
### 🔴 2026-07-05 Demo 必做(阻塞项)
### 🔴 2026-07-05 MVP 演示必做(阻塞项)
| # | 事项 | 交付标准 |
|---|---|---|
@@ -136,31 +136,32 @@
| 7 | 方言语音 | 选 1 家 ASR/TTS 厂商,跑通 1 条语音路径 |
| 8 | 演示脚本 | 一页故事线:点开顺序 + 每屏一句价值(服务于 HR 老大邀功叙事) |
| 9 | 前端壳 | 全功能点可点开(AI 出图 + 若依脚手架批量生成),英雄路径精做 |
| 10 | 用户端轻入口 | Web/H5 同前端提供候选人/员工入口;演示时至少从用户端完成 1 条路径,管理端只负责配置和看结果 |
| 10 | 用户端轻入口 | 独立 `mobile/` Web/H5 工程提供候选人/员工/主管入口;演示时至少从用户端完成 1 条路径,管理端只负责配置和看结果 |
### 5.1 用户端最小切割线(新增 D12)
> 结论:没有用户端,Demo 会变成 HR 后台管理系统,无法证明一线员工/候选人真的能用。用户端要补,但只补 Web/H5 轻入口,不新建独立 APP。
> 结论:没有用户端,MVP 会变成 HR 后台管理系统,无法证明一线员工/候选人真的能用。用户端要补,并拆成独立 `mobile/` Web/H5 工程,但不新建原生 APP。
| 端/角色 | 2026-07-05 Demo 必做 | 一期内补 | 明确推迟 |
| 端/角色 | 2026-07-05 MVP 演示必做 | 一期内补 | 明确推迟 |
|---|---|---|---|
| 管理端(HR/管理员/教练) | 配置内容、查看记录、查看评分/处理状态 | Rubric 配置、任务分派、审核流 | 多组织复杂工作流 |
| 候选人端 | 打开面试入口 → 听/看题 → 作答 → 提交 → 生成面试记录 | 面试邀请、历史记录、人工复核状态 | 外部招聘系统深度集成 |
| 员工/学员端 | 打开今日任务 → 三角色对练 / SOP 问答 / 案例上传 至少跑通 1 条 | 学习路径、错题本、能力雷达、认证进度 | 独立 APP、企微/钉钉深度集成、消息推送 |
| 员工/学员端 | 打开今日任务 → 三角色对练 / SOP 问答 / 案例上传 至少跑通 1 条 | 学习路径、错题本、能力雷达、认证进度 | 原生 APP、企微/钉钉深度集成、消息推送 |
**实现边界**:
- 复用现有 Vue 前端,用独立路由/菜单/演示入口区分用户端;不新建第二套前端工程。
- Demo 可用本地账号或 mock token,但演示脚本必须从「用户端发起 → 管理端看结果」走一遍。
- 用户端使用独立 `mobile/` Vue/Vite Web/H5 工程,与管理端 `frontend/` 分离;MVP 不做原生 APP。
- MVP 用户端走手机号短信登录;新手机号验证码通过后自动注册为 `app_user`。演示脚本必须从「用户端发起 → 管理端看结果」走一遍。
- 当前员工端已用“投诉接待三角色对练”跑通移动端训练闭环:两轮提交后自动评分,记录落 `aihr_practice_session`,并同步更新主管端完训人数、待复盘列表、复盘详情、标记已复盘状态和员工能力画像。
- 用户端只做「能完成任务」,不做复杂个人中心、社交、积分商城。
### 🟡 一期内做(Demo 后)
### 🟡 一期内做(MVP 演示后)
数据地基:补全 HR 主干实体(#10)、关系/向量切分(#11)、招聘域(#12)、SOP 多层分类(#13)、Rubric 可配置(#14)、版本管理(#15)、ID 规约(#16)、数据范围字段(#17)、训练事务+学习路径(#18)。
能力与运营:LLM/ASR/视频运营成本测算(#23)、内容贡献激励+署名(#25)、Agent 落点标注(#33)、认证/晋升/绩效规则引擎(#34)。
### ⚪ 明确推迟(现在不做)
内容营销中心、独立 APP、知识图谱、实时同步、企业微信/钉钉深度集成、脱敏审计、AI 分数挂钩绩效/晋升 —— 均见第 7 节闸门或第 8 节决策。
内容营销中心、原生 APP、知识图谱、实时同步、企业微信/钉钉深度集成、脱敏审计、AI 分数挂钩绩效/晋升 —— 均见第 7 节闸门或第 8 节决策。
---
@@ -199,13 +200,13 @@
| D3 | 大模型选型 | **公有大模型 API**(出境合规入闸门 G3/G6) | ✅ 已定 |
| D4 | 知识图谱一期是否上 | **一期先 RAG**,图谱降二期 | ✅ 已定 |
| D5 | 内容营销中心去留 | **一期砍除** | ✅ 已定 |
| D6 | 独立 APP | **用 Web**,APP 进二期 | ✅ 已定 |
| D6 | 原生 APP | **用独立 mobile/ Web/H5**,原生 APP 进二期 | ✅ 已定 |
| D7 | 老文档 vs 新 HR 框架口径 | **综合统一成新的一版**(见下方跟进项 F1) | ✅ 已定 |
| D8 | 企微/钉钉/工单集成深度 | **只列接口、不实现** | ✅ 已定 |
| D9 | 一期验收标准 / 量化承诺 | **最小可验收承诺已定**(见第 10 节;C3 一致率 70%、试点 1–2 项目) | ✅ 已定 |
| D10 | 资料中台是否提前建设 | **提前建设 MinIO + OSS-first 文档入库**;PDF/Word/Excel/PPT 先抽文本,图片智能分析进下一阶段 | ✅ 已定 |
| D11 | 向量数据库与语义检索路线 | **一期选 Qdrant**;MySQL Fulltext 保留,制度/SOP 采用关键词 + 向量混合召回、rerank、引用约束;Milvus/Weaviate/pgvector 暂缓 | ✅ 已定 |
| D12 | 用户端是否进入一期 | **必须进入**;Demo 用 Web/H5 轻入口,候选人/员工至少能发起 1 条英雄路径;独立 APP 和企微/钉钉深度集成推迟 | ✅ 已定 |
| D12 | 用户端是否进入一期 | **必须进入**;MVP 用独立 `mobile/` Web/H5 轻入口,候选人/员工至少能发起 1 条英雄路径;原生 APP 和企微/钉钉深度集成推迟 | ✅ 已定 |
**由决策派生的跟进项**:
- **F1(源自 D7)**:综合原《建设方案》+ 一期实施方案 + 本清单,产出**统一新版对外方案**(口径:选用育留 AI 人力资源系统)。
@@ -217,7 +218,7 @@
## 9. 周末施工排期(2026-07-03 / 2026-07-04 / 2026-07-05 · 2 人时间盒)
> 把第 5 节 🔴 2026-07-05 Demo 10 项拆成两人施工单。**铁律:2026-07-05 不加新功能,只联调 + 精修 + 彩排。**
> 把第 5 节 🔴 2026-07-05 MVP 演示 10 项拆成两人施工单。**铁律:2026-07-05 不加新功能,只联调 + 精修 + 彩排。**
**分工**:
- **A = 技术**(若依骨架 / 后端 / 大模型 & 语音集成 / 评分逻辑)
@@ -268,7 +269,7 @@
> 原则:验收只写**可判定**的东西——「是/否」或「可测量」。原方案的大 ROI 数字归入**愿景层,不作验收依据**。
### L0 · Demo 验收(2026-07-06 周一 · 过大老板)—— 全部「是/否」
### L0 · MVP 演示验收(2026-07-06 周一 · 过大老板)—— 全部「是/否」
| # | 验收项 | 通过标准 |
|---|---|---|
@@ -324,7 +325,7 @@
- **合规上线**(脱敏/审计/业主 PII 出境评估)= 生产前闸门 **G3/G6**
- **AI 分数挂钩绩效/晋升/招聘决策** = 推迟(**G1/G2**),一期只做「辅助参考」
- 知识图谱、独立 APP、内容营销中心、多项目类型(公建/景区)、企微/钉钉深度集成、实时同步
- 知识图谱、原生 APP、内容营销中心、多项目类型(公建/景区)、企微/钉钉深度集成、实时同步
### 愿景层(方向性目标,**不作为一期验收依据**)
@@ -334,7 +335,7 @@
## 11. 提前加码:资料中台施工线(v1.5)
> 背景:Demo 进度快于原计划,且已知业务资料约 3GB,包含 PPT、图片、Excel、Word、PDF。知识库不再只服务 SOP seed,而是要成为后续 RAG、文档解析、案例沉淀和 Chat 的公共入口。
> 背景:MVP 进度快于原计划,且已知业务资料约 3GB,包含 PPT、图片、Excel、Word、PDF。知识库不再只服务 SOP seed,而是要成为后续 RAG、文档解析、案例沉淀和 Chat 的公共入口。
### 11.1 当前立刻做(L0+)
@@ -343,14 +344,14 @@
| M1 | MinIO 本地对象存储 | `./scripts/dev.sh` 和 `--reset` 自动启动 MinIO,默认 bucket `ruoyi` 可用 |
| M2 | OSS-first 文档上传 | 知识库上传先落 `sys_oss`,再绑定 `aihr_knowledge_attach.oss_id`,同名文档替换旧片段 |
| M3 | 文档解析扩展 | 在现有 Tika 解析基础上支持 txt/md/PDF/Word/Excel/PPT 文本抽取 |
| M4 | Demo 页面反馈 | 上传后在 SOP 知识库展示 OSS ID、片段数、状态,可立即检索命中 |
| M4 | MVP 页面反馈 | 上传后在 SOP 知识库展示 OSS ID、片段数、状态,可立即检索命中 |
### 11.2 下一阶段(P1)
| # | 事项 | 处理 |
|---|---|---|
| M5 | 解析任务状态页 | 已接 `/knowledge/processing` 和 `GET /api/knowledge/processing/overview`;展示等待解析/解析中/已完成/失败、片段数、向量化状态、处理链路和最近事件 |
| M6 | 3GB 资料批量导入 | 页面已支持多文件/浏览器目录选择批量导入 Demo 文件;已接 `POST /api/knowledge/doc/import-local-task` 从服务端导入根目录启动后台任务,并用 `GET /api/knowledge/doc/import-tasks` 轮询进度;当前重试粒度是同目录重跑,单失败文件重试/取消暂停仍待做 |
| M5 | 解析任务状态页 | 已接 `/knowledge/processing` 和 `GET /api/knowledge/processing/overview`;展示等待解析/解析中/已完成/失败、片段数、向量化状态、处理链路和事件列表 |
| M6 | 3GB 资料批量导入 | 页面已支持多文件/浏览器目录选择批量导入 MVP 样例文件;已接 `POST /api/knowledge/doc/import-local-task` 从服务端导入根目录启动后台任务,并用 `GET /api/knowledge/doc/import-tasks` 轮询进度;当前重试粒度是同目录重跑,单失败文件重试/取消暂停仍待做 |
| M7 | 图片智能分析 | 图片先落 OSS,再用视觉模型/OCR 生成结构化描述入库;不伪装成普通文本解析 |
| M8 | Qdrant 向量召回 | 已接 `qdrant` 本地服务;以 `aihr_knowledge_fragment` 为事实源 upsert 向量,与 MySQL Fulltext 双路召回后做 RRF 融合 |
| M9 | 制度/SOP 精确回答 | 检索必须带租户/项目/分类/版本/生效日期过滤,回答必须引用片段;低置信不编造 |
@@ -358,7 +359,7 @@
### 11.3 明确不做
- 不整包迁移 `ruoyi-chat`;只迁 `ruoyi-aihr` 当前需要的 loader、任务和检索能力。
- 不把 3GB 资料通过浏览器单次上传解决;浏览器上传只服务 Demo 和少量验证文件。
- 不把 3GB 资料通过浏览器单次上传解决;浏览器上传只服务 MVP 和少量验证文件。
- 不把图片智能分析做成假 OCR;没有视觉模型/OCR 结果就先标为待处理。
---
@@ -433,7 +434,7 @@ flowchart LR
## 附录 A:35 条完整查漏登记(可追溯)
**第一层(Demo 阻塞)**:1 陪练英雄路径 · 2 内容冷启动 · 3 组织API未就绪 · 4 案例视频过重 · 5 登录/认证未定 · 6 大模型+出境合规 · 7 方言厂商未定 · 8 演示脚本 · 9 UI壳工作量 · 35 用户端入口缺口
**第一层(MVP 演示阻塞)**:1 陪练英雄路径 · 2 内容冷启动 · 3 组织API未就绪 · 4 案例视频过重 · 5 登录/认证未定 · 6 大模型+出境合规 · 7 方言厂商未定 · 8 演示脚本 · 9 UI壳工作量 · 35 用户端入口缺口
**第二层(数据模型地基)**:10 HR主干抽全 · 11 关系/向量切分 · 12 招聘域空白 · 13 SOP多层分类 · 14 Rubric数据结构 · 15 版本管理 · 16 ID/主键策略 · 17 数据权限字段 · 18 训练事务+学习路径 · 19 知识图谱去留
@@ -445,4 +446,4 @@ flowchart LR
## 附录 B:一句话结论
> 现在真正的头号风险不是选型(若依已够),也不是要不要 aifei(不用),而是「用户端缺口」「Demo 范围」和「3GB 业务资料接入」三条线互相拖累。守住第 5 节的三分切割线,按第 5.1 节让用户端只做轻入口,同时按第 11 节把 MinIO、文档解析和批量导入拆开推进,按第 12 节把 Qdrant 作为向量索引而非事实源,才能既演得通、又不把资料中台做成临时脚本。
> 现在真正的头号风险不是选型(若依已够),也不是要不要 aifei(不用),而是「用户端缺口」「MVP 范围」和「3GB 业务资料接入」三条线互相拖累。守住第 5 节的三分切割线,按第 5.1 节让用户端只做轻入口,同时按第 11 节把 MinIO、文档解析和批量导入拆开推进,按第 12 节把 Qdrant 作为向量索引而非事实源,才能既演得通、又不把资料中台做成临时脚本。
+32 -9
View File
@@ -11,6 +11,9 @@
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | 已接入 seed API |
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed |
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、复用 `POST /api/knowledge/doc/upload` | 已接入解析任务状态聚合;页面支持多文件/目录选择、服务端后台目录导入和进度轮询,失败回退 seed |
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user` |
| 移动端三端首页 `/h5/user`、`/h5/candidate`、`/h5/supervisor` | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页 seed API;移动端本地 fallback 保演示 |
| 移动端员工训练闭环 | 复用 `POST /api/train/practice/start`、`/turn`、`/finish`;查询 `GET /api/aihr/mobile/practice/history`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化 |
## 后端落点
@@ -20,23 +23,35 @@
- 包名建议:`org.dromara.aihr`
- Controller 返回统一用 `org.dromara.common.core.domain.R`
当前四条页面流仍保留 seed fallback。知识库、模型能力、文档解析、RAG、chat 按 [ruoyi-ai 能力分片迁移计划](RUOYI_AI_INCREMENTAL_MIGRATION.md) 逐片引入;知识库 DDL 与住宅类 SOP seed 在 `backend/script/sql/aihr_knowledge_mysql8.sql`,模型 DDL 在 `backend/script/sql/aihr_model_mysql8.sql`。
当前管理端页面流和移动端首页仍保留 seed fallback。知识库、模型能力、文档解析、RAG、chat 按 [ruoyi-ai 能力分片迁移计划](RUOYI_AI_INCREMENTAL_MIGRATION.md) 逐片引入;知识库 DDL 与住宅类 SOP seed 在 `backend/script/sql/aihr_knowledge_mysql8.sql`,模型 DDL 在 `backend/script/sql/aihr_model_mysql8.sql`,训练记录 DDL 在 `backend/script/sql/aihr_practice_mysql8.sql`。
直接打后端 `/api/**` 需要登录后的 `Authorization: Bearer <access_token>`;浏览器内通过已登录前端和 `/dev-api` 代理访问。
直接打后端 `/api/**` 通常需要登录后的 `Authorization: Bearer <access_token>`;浏览器内通过已登录前端和 `/dev-api` 代理访问。移动端登录接口为 `POST /auth/mobile/sms-login`,请求 `{ phonenumber, smsCode, tenantId }`,内部固定使用 app 客户端 `428a8310cd442757ae699df5d894f051` 和 `sms` grant;验证码通过后若手机号不存在,会创建 `app_user`,用户名为手机号,备注为“移动端短信自动注册”。移动端 MVP 首页接口 `GET /api/aihr/mobile/home/{role}` 目前仍是 `@SaIgnore` 的公开只读 seed 接口,避免 H5 首屏被后台管理登录态阻断;后续接小程序登录后再收紧为移动端 token。
阿里云短信复用 RuoYi 的 `sms.blends.config1`。本地开发把真实短信参数放根目录 `.env.local` 或外部环境变量,`scripts/dev-backend.sh` 会自动加载;`application-dev.yml` / `application-prod.yml` 只保留占位,不写真实密钥。
```bash
AIHR_SMS_LOGIN_TEMPLATE_ID=SMS_xxxxxx
ALIYUN_SMS_ACCESS_KEY_ID=xxx
ALIYUN_SMS_ACCESS_KEY_SECRET=xxx
ALIYUN_SMS_SIGN_NAME=物业AI助手
```
SOP 文档上传第三片已经落最小后端边界:
| 能力 | 后端接口 | 处理 |
|---|---|---|
| 文档上传解析 | `POST /api/knowledge/doc/upload` | `multipart/form-data`,字段 `file` 和 `category`;支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx`、100MB 内;先写 `sys_oss`,再绑定 `aihr_knowledge_attach.oss_id` 并切分写入 `aihr_knowledge_fragment` |
| 文档上传解析 | `POST /api/knowledge/doc/upload` | `multipart/form-data`,字段 `file` 和 `category`;支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 及图片 `.jpg/.jpeg/.png/.gif/.webp/.bmp`、100MB 内;先写 `sys_oss`,再绑定 `aihr_knowledge_attach.oss_id` 并切分写入 `aihr_knowledge_fragment` |
| 图片视觉 OCR | 同一上传/导入链路 | 上传图片时,若启用了 `aihr_model_config.category='vision'`(无则回退 `category='chat'`)的模型,会把图片编码为 base64 data URL 调用该供应商 OpenAI-compatible `/chat/completions` 提取文字,识别结果按普通正文切分写入 fragment;无视觉模型时不报错,返回空正文、fragment 为 0。不依赖 Tesseract,识别质量由所配置视觉模型决定 |
| 智能归类与标签 | 同一上传/导入链路 | `category=__auto__` 时,解析正文后优先调用已启用的 `aihr_model_config.category='chat'` 模型生成分类、摘要、标签和归类理由,温度固定为 `0`;无模型或调用失败时按文件名/正文关键词兜底。最终分类写入 `aihr_knowledge_info/attach`,摘要和标签写入 `sys_oss.ext1` |
| 重复资料处理 | 同一上传/导入链路 | 上传时计算原始文件 `aihrFileSha256`、解析文本 `aihrTextSha256` 和 `md5` 写入 `sys_oss.ext1`;重复判断优先按文件 SHA-256,其次按文本 SHA-256,最后用同名同大小兼容旧数据。命中重复时复用原附件并迁移到最新分类,删除其他重复附件和旧 fragment |
| 归类稳定性 | 同一上传/导入链路 | 命中重复资料时优先复用已有 `sys_oss.ext1` 里的分类、摘要和标签;只有旧资料没有存过模型结果时才重新分析,避免同文件因重复上传或更换模型导致标签漂移 |
| 文档替换 | 同一 `category + fileName` 再上传 | 复用原 `doc_id`,删除旧 fragment 后重写新 fragment,避免重复文档堆积 |
| 片段向量化 | 同一上传接口内机会性执行 | 若 `aihr_model_config.category='vector'` 且启用,会调用配置供应商的 OpenAI-compatible `/embeddings`,把返回向量写入 `aihr_knowledge_fragment.embedding_json` |
| Qdrant 向量索引 | 同一上传接口内机会性执行 | embedding 写入 MySQL 后尽力 upsert 到 Qdrant;同名文档替换会尽力删除旧 points;Qdrant 不可用不影响上传和 MySQL 检索 |
| 混合检索 | `POST /api/knowledge/search` | 先跑 MySQL Fulltext,同时在 vector 模型和 Qdrant 可用时生成 query embedding 走 Qdrant,再按 RRF 融合并回 MySQL hydrate 片段 |
| 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和最近事件 |
| 片段向量化 | 同一上传接口内机会性执行 | 若 `aihr_model_config.category='vector'` 且启用,会优先调用配置供应商的 OpenAI-compatible `/embeddings`,把返回向量写入 `aihr_knowledge_fragment.embedding_json`;配置缺失或外部接口失败时使用本地确定性 `local-hash-v1` 兜底,保证 MVP 链路仍有真实向量数据 |
| 缺失向量补跑 | `POST /api/knowledge/doc/vectorize-missing` | 扫描缺少 `embedding_json` 的片段,复用同一向量化与 Qdrant 入库链路,适合模型配置变更后或历史资料补齐向量 |
| 向量库状态与重建 | `GET /api/knowledge/doc/vector-index-status`、`POST /api/knowledge/doc/rebuild-vector-index` | 模型配置页展示当前 vector 模型维度、Qdrant collection 维度、点数和片段向量数;维度不一致时可一键清空旧 embedding、删除 collection 并按当前模型重建 |
| Qdrant 向量索引 | 同一上传接口内机会性执行 | embedding 写入 MySQL 后尽力 upsert 到 Qdrant;同名文档替换会尽力删除旧 points;Qdrant 不可用不影响上传和 MySQL 检索;外部 embedding 成功但 collection 维度不一致时不静默降级为 `local-hash-v1`,通过状态接口和重建入口处理 |
| 混合检索 | `POST /api/knowledge/search` | 先跑中文关键词 `LIKE` 打分和 MySQL Fulltext,再生成 query embedding 走 Qdrant,最后按 RRF 融合并回 MySQL hydrate 片段;Qdrant 或外部向量接口不可用时保留关键词/全文检索 |
| 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 |
| 服务端目录导入 | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,保留给小批量/调试 |
| 服务端导入任务 | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,页面轮询查看总数、成功数、失败数、当前文件和进度;重试当前按同目录重新启动一轮 |
@@ -61,6 +76,7 @@ Qdrant 本地默认值可不配;需要覆盖时用 JVM property 或环境变
## 前端落点
- 管理端前端:`frontend/`,只承载后台管理、配置、审核、查看结果等页面。
- AI 面试 API 文件:`frontend/src/api/aihr/interview.ts`
- AI 面试页面:`frontend/src/views/recruit/interview.vue`
- 三角色对练 API 文件:`frontend/src/api/aihr/practice.ts`
@@ -73,12 +89,16 @@ Qdrant 本地默认值可不配;需要覆盖时用 JVM property 或环境变
- 资料处理页面:`frontend/src/views/knowledge/processing.vue`
- 模型配置 API 文件:`frontend/src/api/aihr/model.ts`
- 模型配置页面:`frontend/src/views/system/model/index.vue`
- 保留本地 seed fallback:接口失败时仍能演示,不让现场 Demo 被后端状态拖死。
- 移动端独立工程:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`;当前三端首页已按高保真原型实现,未登录先走手机号短信登录,登录后 API 优先请求 `/api/aihr/mobile/home/{role}`,不复用后台页面路由。
- 员工端训练:任务卡“开始训练”复用管理端三角色对练 seed API,完成两轮后展示评分和导师改写;记录写入 `aihr_practice_session`,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。
- 保留本地 seed fallback:接口失败时仍能演示,不让现场演示被后端状态拖死。
## 验收
```bash
./scripts/demo-check.sh
npm --prefix mobile run build
curl -fsS http://127.0.0.1:8080/api/aihr/mobile/home/user
npm --prefix frontend run lint:eslint -- src/api/aihr/interview.ts src/views/recruit/interview.vue src/api/aihr/practice.ts src/views/train/practice.vue src/api/aihr/case.ts src/views/knowledge/cases.vue src/api/aihr/sop.ts src/views/knowledge/sop.vue
mvn -f backend/pom.xml -pl ruoyi-admin -am -DskipTests package
```
@@ -95,10 +115,13 @@ curl -fsS -X POST http://127.0.0.1:8080/api/aihr/model/chat \
-d '{"prompt":"物业管家处理漏水投诉第一步是什么?"}'
```
Qdrant 可单独 smoke:
Qdrant 和向量索引可单独 smoke;重建接口会清空旧 embedding 并重建 collection,只用于本地验证或确认维度不一致后的修复:
```bash
TOKEN=<登录后 access_token>
curl -fsS http://127.0.0.1:6333/
curl -fsS http://127.0.0.1:8080/api/knowledge/doc/vector-index-status -H "Authorization: Bearer $TOKEN"
curl -fsS -X POST http://127.0.0.1:8080/api/knowledge/doc/rebuild-vector-index -H "Authorization: Bearer $TOKEN"
```
浏览器验收仍按 [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) 的四条关键路径执行。
+9 -8
View File
@@ -1,6 +1,6 @@
# 一期 Demo 演示与验收清单
# 一期 MVP 演示与验收清单
本文件是现场演示入口:先跑预检,再按四条演示流逐条点击。当前只证明一期 Demo 路径可讲、可点、可截图;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,其余记录仍按演示态验收。
本文件是 MVP 现场演示入口:先跑预检,再按演示流逐条点击。当前证明一期 MVP 路径可讲、可点、可截图;移动端员工端、候选人端、主管端首页已拆到独立 `mobile/` 工程并按高保真原型实现,已接手机号短信登录,数据 API 优先请求 `GET /api/aihr/mobile/home/{role}` 并保留本地 fallback;员工端训练完成后写入 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,管理端 seed 记录仍按演示态验收。
## 演示前预检
@@ -15,11 +15,12 @@
| 顺序 | 页面 | 点击路径 | 必须看到 |
|---|---|---|---|
| 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” |
| 2 | AI面试 `/recruit/interview` | 生成题目 → 填满 seed 回答 → 完成评分 | 已完成闭环、建议复试、新增面试记录 |
| 3 | 三角色对练 `/train/practice` | 开始对练 → 填入 seed 回复 → 继续一轮 → 填入 seed 回复 → 结束并评分 | 已完成闭环、导师改写、新增对练记录 |
| 4 | 案例沉淀 `/knowledge/cases` | 选择样例录音 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环、`seed入库`、样片兜底文案 |
| 5 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 |
| 6 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和最近事件 |
| 2 | 移动端三端 `http://127.0.0.1:5174/h5/user`、`/h5/candidate`、`/h5/supervisor` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端点“开始训练”完成两轮提交,再打开主管端 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;主管端完训人数、“待复盘对练”计数和待复盘列表增加;点进待复盘记录可看评分、话术、导师改写,并可标记已复盘;候选人面试、主管团队概况分别可见;接口不可用时走本地 fallback;视觉参考 `docs/prototypes/*端-首页.png`,底部导航停留在移动端工程内 |
| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满 seed 回答 → 完成评分 | 已完成闭环、建议复试、新增面试记录 |
| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入 seed 回复 → 继续一轮 → 填入 seed 回复 → 结束并评分 | 已完成闭环、导师改写、新增对练记录 |
| 5 | 案例沉淀 `/knowledge/cases` | 选择样例录音 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环、`seed入库`、样片兜底文案 |
| 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 |
| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 |
## 录屏兜底
@@ -32,5 +33,5 @@
## 不演示
- 不演示 SSO、权限配置、知识图谱、数字人视频生成、绩效挂钩。
- 不承诺面试、对练、案例的 seed 记录已写入后端数据库;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。
- 不承诺管理端 AI 面试、三角色对练、案例沉淀的 seed 记录已写入业务数据库;移动端员工训练记录写入 `aihr_practice_session`;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。
- 不现场接大模型,AI 效果以本地 seed 数据兜底。
+36 -11
View File
@@ -1,13 +1,15 @@
# 物业AI人力资源系统 Demo 本地启动
# 物业AI人力资源系统 MVP 本地启动
## 当前仓库
- 后端:`backend`,来自 `dromara/RuoYi-Vue-Plus` 的 `5.X` 分支
- 前端:`frontend`,来自 `CrazyLionCat/plus-ui` 的 `5.X` 分支
- 管理端前端:`frontend`,来自 `CrazyLionCat/plus-ui` 的 `5.X` 分支
- 移动端前端:`mobile`,独立 Vue/Vite 工程
## 本地端口
- 前端:`http://127.0.0.1:5173/`
- 管理端前端:`http://127.0.0.1:5173/`
- 移动端前端:`http://127.0.0.1:5174/h5/user`
- 后端:`http://127.0.0.1:8080/`
- MySQL:`127.0.0.1:13306`,数据库 `ry-vue`,账号 `root/root`
- Redis:`127.0.0.1:16379`,密码 `ruoyi123`
@@ -15,7 +17,18 @@
- Qdrant REST:`http://127.0.0.1:6333`,默认 collection `aihr_knowledge`
- 服务端资料导入根目录:`./.data/import`
Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。
Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。移动端手机号登录的短信模板 ID、阿里云 AccessKey、Secret 和短信签名都通过环境变量注入;本地放根目录 `.env.local`,`scripts/dev-backend.sh` 会自动加载。
本地 `.env.local` 示例:
```bash
AIHR_SMS_LOGIN_TEMPLATE_ID=SMS_xxxxxx
ALIYUN_SMS_ACCESS_KEY_ID=xxx
ALIYUN_SMS_ACCESS_KEY_SECRET=xxx
ALIYUN_SMS_SIGN_NAME=物业AI助手
```
`application-dev.yml` 只保留占位和默认值,不提交真实短信密钥。
## 启动步骤
@@ -25,13 +38,13 @@ Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDR
./scripts/dev.sh
```
首次启动或需要重建本地 `ry-vue` Demo 数据库时执行:
首次启动或需要重建本地 `ry-vue` MVP 数据库时执行:
```bash
./scripts/dev.sh --reset
```
`--reset` 会删除并重建本地 Demo 数据库,只用于本地开发环境。
`--reset` 会删除并重建本地 MVP 数据库,只用于本地开发环境。
## 默认登录
@@ -50,11 +63,15 @@ Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDR
- Qdrant 容器随本地开发编排启动,供 SOP 知识库向量召回使用
- `ry_vue_5.X.sql`、`ry_job.sql`、`ry_workflow.sql` 已导入
- `aihr_knowledge_mysql8.sql`、`aihr_model_mysql8.sql` 已导入;本地库含住宅 SOP seed 片段与模型配置表
- `aihr_practice_mysql8.sql` 已导入;移动端员工训练记录落 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像聚合
- SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB
- 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。
- 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。
- 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。
- 后端 `ruoyi-admin` 已完成 Maven 打包并启动在 `8080`
- 前端依赖已安装,Vite 已启动在 `5173`
- 移动端依赖已安装,Vite 已启动在 `5174`
- 移动端手机号登录页已接 `/resource/sms/code` 与 `/auth/mobile/sms-login`;未配置真实 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`、`AIHR_SMS_LOGIN_TEMPLATE_ID` 时不会发送阿里云短信;手机号不存在时会自动注册为 `app_user`
- 移动端员工端“开始训练”已复用 `/api/train/practice/start`、`/turn`、`/finish`;移动端请求需带登录返回的 `Authorization` 与 `clientid`;完成后主管端完训人数、“待复盘对练”计数、复盘列表、复盘详情、员工训练历史和能力画像会变化
- `/dev-api/auth/tenant/list` 与 `/dev-api/auth/code` 已通过前端代理返回 `200`
- `000000 / admin / admin123` 已通过真实加密登录接口返回 `access_token`
@@ -64,7 +81,14 @@ Qdrant 单独检查:
curl -fsS http://127.0.0.1:6333/
```
## Demo 页面验证
登录后可检查向量索引状态:
```bash
TOKEN=<登录后 access_token>
curl -fsS http://127.0.0.1:8080/api/knowledge/doc/vector-index-status -H "Authorization: Bearer $TOKEN"
```
## MVP 页面验证
登录后侧栏应只展示以下入口:
@@ -76,17 +100,18 @@ curl -fsS http://127.0.0.1:6333/
- 资料处理:`/knowledge/processing`
- 系统设置-模型配置:`/system/model`
若依默认菜单如“系统管理 / 租户管理 / 系统监控 / 系统工具 / 测试菜单”在当前 Demo 阶段应保持隐藏。
若依默认菜单如“系统管理 / 租户管理 / 系统监控 / 系统工具 / 测试菜单”在当前 MVP 阶段应保持隐藏。
## Demo 演示流验证
## MVP 演示流验证
当前已跑通四个本地演示流:
当前已跑通管理端五个本地演示流和移动端员工训练闭环:
- AI面试:进入 `/recruit/interview`,点击“生成题目” → “填满 seed 回答” → “完成评分”,应看到“已完成闭环”“建议复试”和新增面试记录。
- 三角色对练:进入 `/train/practice`,点击“开始对练” → 两次“填入 seed 回复 / 继续一轮” → “结束并评分”,应看到“已完成闭环”“导师改写”和新增对练记录。
- 案例沉淀:进入 `/knowledge/cases`,点击“选择样例录音” → “AI 整理” → “送审” → “入库”,应看到“已完成闭环”和新增 `seed入库` 记录。
- SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。
- 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;可用少量文件验证“批量导入/选择目录/服务端导入”。服务端导入读取 `./.data/import` 下的相对目录,启动后台任务并在页面显示进度;当前重试粒度是同目录重新导入,不是单失败文件重试。
- 移动端员工训练:进入 `http://127.0.0.1:5174/h5/user`,手机号登录 → “开始训练” → 两次“填入建议回复 / 提交本轮”,应看到评分、导师改写、训练历史和能力画像;切到主管端后完训人数与“待复盘对练”计数增加,并展示待复盘列表;点进单条可看评分、话术、导师改写,并可标记已复盘。
演示前可先跑最小预检:
+7 -4
View File
@@ -7,14 +7,17 @@
| 文档 | 用途 |
|---|---|
| [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证 |
| [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 Demo 演示脚本、录屏兜底、四条 seed 流验收清单 |
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、业务模块落点、四条 seed API 切片与 SOP 上传解析接口 |
| [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 |
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、业务模块落点、移动端训练记录/复盘详情、SOP 上传解析接口 |
| [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 |
| [AI人力资源系统一期Demo作战清单.md](AI人力资源系统一期Demo作战清单.md) | Demo 作战图、MVP 切割线、排期、验收承诺 |
| [AI人力资源系统一期MVP版作战清单.md](AI人力资源系统一期MVP版作战清单.md) | MVP 作战图、切割线、排期、验收承诺 |
| [物业AI人力资源系统业务需求文档BRD.md](物业AI人力资源系统业务需求文档BRD.md) | 业务需求、范围边界、角色、风险与验收 |
| [物业AI人力资源系统开发规格TechSpec.md](物业AI人力资源系统开发规格TechSpec.md) | 工程结构、数据模型、页面路由、API 和核心实现规格 |
| [prototypes/ai-hr-dashboard.png](prototypes/ai-hr-dashboard.png) | 后台首页高保真目标图 |
| [prototypes/ai-hr-p0-pages-composite.png](prototypes/ai-hr-p0-pages-composite.png) | P0 四个页面组合高保真原型图 |
| [prototypes/员工端-首页.png](prototypes/员工端-首页.png) | 移动端员工/用户端首页目标图 |
| [prototypes/候选人端-首页.png](prototypes/候选人端-首页.png) | 移动端候选人端首页目标图 |
| [prototypes/主管端-首页.png](prototypes/主管端-首页.png) | 移动端主管端首页目标图 |
## 参考材料
@@ -29,7 +32,7 @@
| 文档 | 对应 Markdown |
|---|---|
| [originals/AI人力资源系统一期Demo作战清单.docx](originals/AI人力资源系统一期Demo作战清单.docx) | [AI人力资源系统一期Demo作战清单.md](AI人力资源系统一期Demo作战清单.md) |
| [originals/AI人力资源系统一期Demo作战清单.docx](originals/AI人力资源系统一期Demo作战清单.docx) | [AI人力资源系统一期MVP版作战清单.md](AI人力资源系统一期MVP版作战清单.md) |
| [originals/物业AI人力资源系统业务需求文档BRD.docx](originals/物业AI人力资源系统业务需求文档BRD.docx) | [物业AI人力资源系统业务需求文档BRD.md](物业AI人力资源系统业务需求文档BRD.md) |
| [originals/物业AI人力资源系统开发规格TechSpec.docx](originals/物业AI人力资源系统开发规格TechSpec.docx) | [物业AI人力资源系统开发规格TechSpec.md](物业AI人力资源系统开发规格TechSpec.md) |
| [originals/物业AI陪练系统一期建设实施方案.docx](originals/物业AI陪练系统一期建设实施方案.docx) | [legacy/物业AI陪练系统一期建设实施方案.md](legacy/物业AI陪练系统一期建设实施方案.md) |
+14 -4
View File
@@ -17,7 +17,7 @@
| 2. 模型能力 | `chat_provider / chat_model` 改造为 `aihr_model_*`,最小 OpenAI-compatible 调用边界 | 多模型市场、图像、SSE、成本看板 | 模型列表可查;数据库配置后可调用 `/chat/completions` |
| 3. 文档解析 | 文本/Markdown/PDF/Word/Excel/PPT loader、OSS-first 上传与分片 | 异步重试队列、图片智能分析 | txt/md/PDF/Word/Excel/PPT 文件可落 OSS 并解析为 fragment |
| 4. 检索与 embedding | MySQL Fulltext + OpenAI-compatible embedding + Qdrant 最小向量召回 | Milvus/Weaviate 全量适配、独立向量库后台 | 同一问题返回带分数片段,配置 vector 模型后片段有 embedding,Qdrant 可用时参与 RRF 融合 |
| 5. 资料处理状态 | 解析任务状态页、资料分类、处理链路、多文件/目录选择上传、服务端后台目录导入任务 | 单失败文件重试、分布式队列 | 能查看等待解析/解析中/已完成/失败、片段数、向量化状态并导入 Demo 文件 |
| 5. 资料处理状态 | 解析任务状态页、资料分类、处理链路、多文件/目录选择上传、服务端后台目录导入任务 | 单失败文件重试、分布式队列 | 能查看等待解析/解析中/已完成/失败、片段数、向量化状态并导入 MVP 样例文件 |
| 6. RAG 回答 | 带引用回答、训练题生成 | 多模型市场、成本看板 | SOP 页面展示真实引用 |
| 7. Chat | 最小会话、消息、引用来源 | 多智能体、工作流、MCP、Skills | 可围绕 SOP 连续追问 |
@@ -59,21 +59,31 @@
- `/api/knowledge/search` 已优先查询 `aihr_knowledge_fragment`。
- 检索方式先迁移 `ruoyi-ai` 的 MySQL Fulltext 关键词召回:`MATCH(content) AGAINST(...)`。
- MySQL 未命中或表未导入时仍回退当前 seed,保证 Demo 页面不断。
- MySQL 未命中或表未导入时仍回退当前 seed,保证 MVP 页面不断。
- `aihr_knowledge_fragment` 已增加 `embedding_json/embedding_model/embedding_time`。
- 若数据库启用 `category=vector` 的模型配置,上传后会同步调用 OpenAI-compatible `/embeddings` 并写入片段向量。
- Qdrant 已接入本地开发编排;embedding 写入 MySQL 后尽力 upsert 到默认 collection `aihr_knowledge`。
- `/api/knowledge/search` 在 vector 模型和 Qdrant 可用时会生成 query embedding,走 Qdrant 召回后回 MySQL hydrate 片段,并与 MySQL Fulltext 用 RRF 融合。
- Qdrant 不可用或 vector 模型未配置时,仍走 MySQL Fulltext / LIKE / seed fallback。
- 新增 `GET /api/knowledge/doc/vector-index-status` 和 `POST /api/knowledge/doc/rebuild-vector-index`,模型配置页可检查当前 vector 模型维度、Qdrant collection 维度、点数和片段向量数,并按当前模型清空旧 embedding 后重建。
- 外部 embedding 成功但 Qdrant 维度不匹配时不再静默降级为本地 hash 向量,避免同一资料在模型切换后出现“数据库有向量、索引不可用”的假成功。
## 当前第五片
- 新增后端 API:`GET /api/knowledge/processing/overview`。
- 新增前端页面:`/knowledge/processing`,菜单名“资料处理”;`SOP知识库` 菜单保持独立不改名。
- 页面聚合展示资料总量、已完成、处理中、失败、资料分类、解析任务表、处理链路、规则风险和最近事件。
- 页面聚合展示资料总量、已完成、处理中、失败、资料分类、解析任务表、处理链路、规则风险和事件列表。
- 批量导入先复用 `POST /api/knowledge/doc/upload`,支持多文件和浏览器目录选择。
- 新增 `POST /api/knowledge/doc/import-local-task` 和 `GET /api/knowledge/doc/import-tasks`,只读取 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,写入 `aihr_knowledge_import_task` 后后台逐文件复用上传解析链路,页面轮询进度;`POST /api/knowledge/doc/import-local` 保留同步调试。
## 当前第六片
- 新增图片文件支持(jpg/jpeg/png/gif/webp/bmp),纳入 `supportedFile()` 和上传入口。
- 上传图片时,若数据库配置了 `category='vision'` 或 `category='chat'` 的启用模型,会将图片编码为 base64 data URL,调用同一供应商的 OpenAI-compatible `/chat/completions` 做视觉 OCR,提取图片文字写入 fragment;无视觉模型时返回空字符串,fragment 为 0,不影响其他格式上传。
- `visionRuntime()` 优先查 `category='vision'`,无则回退 `category='chat'`,复用 `chatRuntime()` 同一查询模式。
- `ProcessingRulesResponse.imageOcrEnabled` 改为动态值 `visionRuntime().isPresent()`,前端“图片 OCR”开关随模型配置实时反映;未配置时灰显并显示“即将支持”标签。
- 不依赖 Tesseract,零服务器安装依赖;识别精度与中文复杂版面质量由所配置的视觉 LLM 决定。
## 暂缓项
- 完整 `ruoyi-chat` 模块
@@ -82,5 +92,5 @@
- 工作流、多智能体、MCP、Skills
- 全量模型管理后台
- 完整流式 chat 与消息持久化
- 图片 OCR/视觉模型解析
- 单失败文件重试、导入任务取消/暂停、分布式队列
- 大规模资料的异步向量重建进度条
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -8,7 +8,7 @@
> - 《物业管家与生活顾问 AI 陪练系统建设方案》(原始业务方案,陪练视角)
> - 《物业 AI 陪练系统一期建设实施方案》(含 ch.14/15 技术选型评估)
> - 《物业 AI 陪练系统建设方案研判报告》
> - 《AI 人力资源系统一期 Demo 作战清单》v1.6(决策 / MVP / 验收 / 排期 / 闸门 / Qdrant 语义检索路线)
> - 《AI 人力资源系统一期 MVP 版作战清单》v1.8(决策 / MVP / 验收 / 排期 / 闸门 / Qdrant 语义检索路线)
> - 2026-07-01 需求讨论纪要(HR 负责人提出,确立"选用育留"定位)
> - 《数据资源模型主题域草案》(Silverston 通用数据模型)
>
@@ -64,7 +64,7 @@
### 3.2 明确移出一期范围(已决策)
- **内容营销中心**(原第七章朋友圈工坊):属营销/获客,与 HR 无关 → 一期砍除(D5)。
- **独立语音 APP**(豆包式):愿景非交付物 → Demo 用 Web,APP 进二期(D6)。
- **独立语音 APP**(豆包式):愿景非交付物 → MVP 用 Web,APP 进二期(D6)。
- **双图谱知识图谱**(Neo4j):冷启动成本高 → 一期先 RAG,图谱降二期(D4)。
- **多项目类型**:一期只跑**住宅类** SOP;公建类、景区类只预留结构。
@@ -225,7 +225,7 @@
| # | 外部依赖 | 一期做法 |
|---|---|---|
| 1 | 组织/人员数据同步(采购系统) | 接口疑似与待批预算系统绑定;Demo 用静态快照兜底 |
| 1 | 组织/人员数据同步(采购系统) | 接口疑似与待批预算系统绑定;MVP 用静态快照兜底 |
| 2 | 登录 / 身份认证(采购系统) | **一期本地账号(D2)**,SSO 留二期 |
| 3 | 权限映射(外部岗位/组织→本地角色) | 本地建 |
| 4 | 方言 ASR / TTS(第三方) | 选 1 家跑通 |
@@ -244,7 +244,7 @@
| D3 | 大模型 | 公有大模型 API(出境合规入闸门) |
| D4 | 知识图谱 | 一期先 RAG,图谱降二期 |
| D5 | 内容营销中心 | 一期砍除 |
| D6 | 独立 APP | 用 Web,APP 进二期 |
| D6 | 原生 APP | 用独立 `mobile/` Web/H5,原生 APP 进二期 |
| D7 | 文档口径 | 综合统一成新的一版(即本 BRD) |
| D8 | 企微/钉钉/工单集成 | 只列接口、不实现 |
| D9 | 一期验收标准 | 最小可验收承诺(见第 10 章;一致率 70%、试点 1–2 项目) |
@@ -253,7 +253,7 @@
## 9. 一期范围与 MVP 切割线
### 9.1 🔴 Demo 必做(阻塞项)
### 9.1 🔴 MVP 演示必做(阻塞项)
英雄路径(1–2 条端到端跑通)、住宅类内容冷启动(SOP+案例+Rubric)、组织人员快照、案例视频样片、本地登录、大模型接入、方言语音 1 条路径、演示脚本、前端壳全可点开。
@@ -269,7 +269,7 @@
> 原则:验收只写"是/否"或"可测量";大 ROI 数字归入愿景层,不作验收依据。
### 10.1 L0 · Demo 验收(过大老板)
### 10.1 L0 · MVP 演示验收(过大老板)
L0-1 全功能点 Web 可点开 · L0-2 至少 1 条英雄路径端到端跑通 · L0-3 住宅类 SOP 可检索且 AI 问答能引用 · L0-4 案例语音上传→AI 整理可演示 · L0-5 脚本无致命卡点且有录屏兜底。
@@ -322,21 +322,21 @@ AI 分挂钩个人利益的公平性/申诉、员工对被 AI 监考的抵触(
- **集成负责人指派**:第 7 章 6 个外部依赖需落实到人。
- **F2 派生**:数据权限以项目为范围主体落地(源自 D1)。
- **F3 派生**:公有大模型已定,业主 PII 出境评估升为一期必须评估项(源自 D3)。
- 二期:知识图谱、独立 APP、玉溪 Agent 化(即时锦囊/语音咨询)、多项目类型、企微/钉钉深度集成、实时同步。
- 二期:知识图谱、原生 APP、玉溪 Agent 化(即时锦囊/语音咨询)、多项目类型、企微/钉钉深度集成、实时同步。
---
## 附录 A:34 条完整查漏登记(可追溯)
- **第一层(Demo 阻塞)**:陪练英雄路径 · 内容冷启动 · 组织 API 未就绪 · 案例视频过重 · 登录/认证 · 大模型+出境合规 · 方言厂商 · 演示脚本 · UI 壳工作量
- **第一层(MVP 演示阻塞)**:陪练英雄路径 · 内容冷启动 · 组织 API 未就绪 · 案例视频过重 · 登录/认证 · 大模型+出境合规 · 方言厂商 · 演示脚本 · UI 壳工作量
- **第二层(数据模型地基)**:HR 主干抽全 · 关系/向量切分 · 招聘域空白 · SOP 多层分类 · Rubric 数据结构 · 版本管理 · ID/主键策略 · 数据权限字段 · 训练事务+学习路径 · 知识图谱去留
- **第三层(生产前闸门+组织风险)**:AI 分挂钩绩效 · AI 招聘合规 · 业主/员工数据合规 · 运营成本模型 · 员工接受度 · 内容贡献激励+署名 · 情绪树洞隐私 · 系统/数据归属
- **第四层(范围边界+交付一致性)**:营销中心去留 · 独立 APP · 老文档一致性 · 企微/钉钉/工单集成 · 一期验收标准 · 玉溪 Agent 落点 · 认证/晋升/绩效规则引擎
- **第四层(范围边界+交付一致性)**:营销中心去留 · 原生 APP · 老文档一致性 · 企微/钉钉/工单集成 · 一期验收标准 · 玉溪 Agent 落点 · 认证/晋升/绩效规则引擎
## 附录 B:配套文档
- 《AI 人力资源系统一期 Demo 作战清单》:执行层作战图(决策/MVP/排期/闸门/验收),与本 BRD 配套,本 BRD 为需求基准、作战清单为执行细则。
- 《AI 人力资源系统一期 MVP 版作战清单》:执行层作战图(决策/MVP/排期/闸门/验收),与本 BRD 配套,本 BRD 为需求基准、作战清单为执行细则。
## 附录 C:一句话结论
> 本项目是以"选用育留"为主线的物业行业 AI 人力资源系统;若依做基座、核心三角色对练与评分自建、数据模型抬 Silverston HR 主干、组织人员从外部同步;一期以住宅类 + 单条英雄路径 Demo 起步,合规与挂钩留生产前闸门。当前头号风险是"2 人 + 死线"下的范围失控,守住 MVP 切割线即可闭环。
> 本项目是以"选用育留"为主线的物业行业 AI 人力资源系统;若依做基座、核心三角色对练与评分自建、数据模型抬 Silverston HR 主干、组织人员从外部同步;一期以住宅类 + 单条 MVP 英雄路径起步,合规与挂钩留生产前闸门。当前头号风险是"2 人 + 死线"下的范围失控,守住 MVP 切割线即可闭环。
@@ -1,13 +1,14 @@
# 物业行业 AI 人力资源系统 · 开发规格(Tech Spec)
> 版本:v1.3 | 日期:2026-07-02
> 版本:v1.4 | 日期:2026-07-03
> 定位:**开发层唯一依据**。回答"怎么建"——工程结构、数据表、API 契约、Prompt 规格、集成适配。
> v1.1 变更:数据模型对齐《手册》卷1 第9章 HR 主干,补 3 张 backbone 表(职位职责/雇员绩效/雇用终止),新增附录 D 对照表。
> v1.2 变更:第 6 章按 Codex 设计评审重写——状态机补异常/终止态、COACH_CHECK 工程化、评分引擎分层(P0 单 LLM / P1 融合)、澄清"单场对练评分 vs 跨期能力画像"两套评分、Prompt 加固 + RAG 硬约束、新增 6.5 P0/P1 切分。
> v1.3 变更:Qdrant 作为一期向量索引层落地;SOP 知识库检索升级为 MySQL Fulltext + Qdrant RRF 融合,MySQL/MinIO 仍是事实源。
> 配套:需求见[《物业AI人力资源系统业务需求文档BRD》](物业AI人力资源系统业务需求文档BRD.md);执行计划见[《AI人力资源系统一期Demo作战清单》](AI人力资源系统一期Demo作战清单.md)。
> **优先级图例**:`P0`=2026-07-05 Demo 必需 · `P1`=一期必需 · `P2`=二期/推迟。
> 决策基线:若依基座 / 集中式前后端分离 / 本地登录 / 公有大模型API / 一期RAG / 组织人员外部同步(Demo用快照) / 数据范围以项目为主体。
> v1.4 变更:移动端拆为独立 `mobile/` Vue/Vite Web/H5 工程,承载员工端、候选人端、主管端;后台管理端继续在 `frontend/`。
> 配套:需求见[《物业AI人力资源系统业务需求文档BRD》](物业AI人力资源系统业务需求文档BRD.md);执行计划见[《AI人力资源系统一期MVP版作战清单》](AI人力资源系统一期MVP版作战清单.md)。
> **优先级图例**:`P0`=2026-07-05 MVP 演示必需 · `P1`=一期必需 · `P2`=二期/推迟。
> 决策基线:若依基座 / 集中式前后端分离 / 本地登录 / 公有大模型API / 一期RAG / 组织人员外部同步(MVP 用快照) / 数据范围以项目为主体。
---
@@ -18,7 +19,8 @@
| 层 | 选型 |
|---|---|
| 后端 | Java + Spring Boot 4(若依 RuoYi 后端) |
| 前端 | Vue(若依前端,前后端分离) |
| 管理端前端 | Vue + plus-ui(若依前端,前后端分离) |
| 移动端前端 | 独立 `mobile/` Vue/Vite Web/H5;小程序技术栈后续再定 |
| 数据库 | MySQL(主数据/事务)+ MinIO(原始文件)+ Qdrant(向量索引) |
| 检索 | MySQL Fulltext + Qdrant 混合 RAG(一期);知识图谱 Neo4j(P2) |
| 大模型 | 公有大模型 API(对话/出题/评分/案例整理) |
@@ -31,19 +33,18 @@
backend/
ruoyi-admin/ # 启动入口
ruoyi-system/ # 复用:用户/角色/菜单/数据权限
hr-recruit/ # 选:招聘面试
hr-onboard/ # 用:上岗/SOP/培训
hr-train/ # 育:对练/每日一练/训练营/错题/师徒
hr-competency/ # 留:能力评估/认证/Rubric/激励
hr-knowledge/ # 知识库/案例库/RAG
hr-ai/ # AI 适配层:大模型/ASR/TTS/Prompt 模板/评分引擎
hr-sync/ # 组织人员同步适配层
ruoyi-modules/ruoyi-aihr/ # AI HR 自有业务 API、seed fallback、知识库/RAG/模型配置
script/sql/aihr_knowledge_mysql8.sql
script/sql/aihr_model_mysql8.sql
frontend/
src/views/{recruit,onboard,train,competency,knowledge,ai-practice,dashboard,admin}
src/views/{index,recruit,train,knowledge,system/model} # 管理端 MVP 页面
mobile/
src/App.vue # 员工端/候选人端/主管端 H5 首页
```
- 复用若依 `ruoyi-system` 的用户/角色/菜单/数据权限;业务模块按选用育留 + 支撑拆分。
- AI 能力集中在 `hr-ai`,对上层暴露统一接口(对话/评分/转写/生成),便于切换厂商与兜底。
- 复用若依 `ruoyi-system` 的用户/角色/菜单/数据权限;自有后端 API 集中在 `ruoyi-aihr`,不要塞回上游 `ruoyi-demo`。
- 管理端 `frontend/` 不承载 `/h5/*`;移动端通过 `5174` 独立启动,路由为 `/h5/user`、`/h5/candidate`、`/h5/supervisor`。
- AI 能力先在 `ruoyi-aihr` 内提供统一接口(对话/评分/转写/生成),便于切换厂商与兜底;后续可按模块拆分。
- 可借助已有的 "codex 若依组件生成 skill" 批量生成 CRUD 骨架。
---
@@ -147,7 +148,7 @@ frontend/
| `aihr_knowledge_fragment`(知识片段) | id, knowledge_id, doc_id, idx, content, embedding_json, embedding_model, embedding_time, FULLTEXT(content) | P0 已落,txt/md/PDF/Word/Excel/PPT 可解析入库,可写 embedding;Qdrant 只存向量索引和最小 payload |
| `case`(案例主表,脱敏) | id, title, raw_audio_url, transcript, ai_summary, curated(是否入选), project_ext_org_id, status | P0 |
| `case_tag`(多维标签) | id, case_id, dim(业务类型/紧急/业主画像/情绪/渠道), value | P1 |
| `case_video`(案例视频/样片) | id, case_id, video_url, is_sample | P1(Demo样片) |
| `case_video`(案例视频/样片) | id, case_id, video_url, is_sample | P1(MVP样片) |
### 3.8 M8/M9 治理 · 版本 · 集成日志
@@ -171,13 +172,14 @@ frontend/
| `/recruit/interview` | **AI 面试(英雄路径A:出题→作答→打分)** | P0跑通 |
| `/recruit/candidates` | 候选人列表 | P1 |
| `/train/practice` | **三角色对练(英雄路径核心)** | P0跑通 |
| `/h5/user` | 员工端首页,手机号登录后可“开始训练”并同步主管待复盘计数 | P0跑通 |
| `/train/daily` | 每日一练 | P1 |
| `/train/camp` | 专项训练营 | P1 |
| `/train/mistakes` | 错题本 | P1 |
| `/knowledge/sop` | SOP 库(住宅类检索→引用→训练题) | P0跑通 |
| `/knowledge/cases` | **案例库(英雄路径B:语音上传→整理)** | P0跑通 |
| `/knowledge/processing` | 资料处理(解析状态、处理链路、浏览器/服务端导入) | P1已提前落地 |
| `/system/model` | 模型配置(供应商 Key/Base URL/模型启停/测试) | P1已提前落地 |
| `/system/model` | 模型配置(供应商 Key/Base URL/模型启停/测试、向量库状态与重建) | P1已提前落地 |
| `/competency/radar` | 能力雷达图 | P1 |
| `/competency/cert` | 认证/激励 | P1 |
| `/onboard/tasks` | 上岗培训任务 | P1 |
@@ -188,7 +190,7 @@ frontend/
## 5. API 契约
> 统一响应信封:`{ code, msg, data }`(若依风格)。以下英雄路径详写,其余按 CRUD 惯例。
> 当前本地 Demo 先用 API + fallback 跑通 `/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop` 页面流;SOP 知识库已接 `aihr_knowledge_fragment` MySQL Fulltext 检索、Qdrant 向量召回、txt/md/PDF/Word/Excel/PPT 上传解析和 embedding 写入。
> 当前本地 MVP 先用 API + fallback 跑通 `/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop` 页面流;SOP 知识库已接 `aihr_knowledge_fragment` MySQL Fulltext 检索、Qdrant 向量召回、txt/md/PDF/Word/Excel/PPT 上传解析和 embedding 写入。
> API 对接顺序见 `docs/API_INTEGRATION.md`;面试、对练、案例仍以 seed service 为主,SOP/模型配置已建最小表。
### 5.1 英雄路径 A:AI 面试(P0)
@@ -223,6 +225,8 @@ POST /api/train/practice/finish
resp: { score:{ compliance, emotion, communication, marketing }, mentorRewrite, aiComment, trustCurve } // 考官评分
```
移动端员工训练闭环复用同一组三角色对练接口。员工端请求必须带手机号登录返回的 `Authorization` 与 `clientid`,`start` 传 `mode=mobile`;`finish` 后写入 `aihr_practice_session`,并刷新 `/api/aihr/mobile/home/supervisor` 的完训人数与“待复盘对练”计数。员工端训练历史查 `GET /api/aihr/mobile/practice/history`,主管端复盘列表查 `GET /api/aihr/mobile/practice/reviews`,复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记已复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,能力画像查 `GET /api/aihr/mobile/profile`。当前只落 session 汇总表,并用 `dialogue_json` 保存本场话术;不拆 `practice_turn/practice_score`,需要逐轮复盘明细、人工点评留痕或多次复盘记录时再拆表。
### 5.3 英雄路径 B:案例语音上传→整理(P0)
```
@@ -242,12 +246,13 @@ POST /api/ai/score req:{ rubricId, dialogue } resp:{ dimensions, co
### 5.5 支撑接口
- 同步:`POST /api/sync/pull`(拉取组织人员,Demo 可读快照文件)
- 同步:`POST /api/sync/pull`(拉取组织人员,MVP 可读快照文件)
- 知识检索(RAG):`POST /api/knowledge/search` → `{ queryText, category, answer, reference, docs[], snippets[], training[], records[] }`
- 知识上传解析:`POST /api/knowledge/doc/upload` → `multipart/form-data { file, category }`,支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx`、100MB 内;先落 `sys_oss` 并绑定 `aihr_knowledge_attach.oss_id`,返回 `{ docId, ossId, fileName, category, fragments, snippets[] }`
- 服务端目录导入:`POST /api/knowledge/doc/import-local-task` → `{ directory, category, limit }`,只允许读取 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,写入后台导入任务并逐文件复用上传解析链路;`GET /api/knowledge/doc/import-tasks` 返回最近任务进度;`POST /api/knowledge/doc/import-local` 保留同步调试。
- 资料处理状态:`GET /api/knowledge/processing/overview` → 聚合附件状态、片段数、向量化状态、分类、处理链路和最近事件。
- 服务端目录导入:`POST /api/knowledge/doc/import-local-task` → `{ directory, category, limit }`,只允许读取 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,写入后台导入任务并逐文件复用上传解析链路;`GET /api/knowledge/doc/import-tasks` 返回任务进度列表;`POST /api/knowledge/doc/import-local` 保留同步调试。
- 资料处理状态:`GET /api/knowledge/processing/overview` → 聚合附件状态、片段数、向量化状态、分类、处理链路和事件列表。
- 片段向量化:同一上传接口在 `category=vector` 模型配置可用时调用 OpenAI-compatible `/embeddings`,写入 `embedding_json/embedding_model/embedding_time`,并尽力 upsert 到 Qdrant;未配置或 Qdrant 不可用不阻塞上传。
- 向量库状态与重建:`GET /api/knowledge/doc/vector-index-status` 返回当前 vector 模型维度、Qdrant collection 维度、点数和片段向量数;`POST /api/knowledge/doc/rebuild-vector-index` 清空旧 embedding、删除 collection,并按当前 vector 模型重建。外部 embedding 成功但 Qdrant 维度不一致时不降级为本地 hash。
- 混合检索:`POST /api/knowledge/search` 先查 MySQL Fulltext,同时在 vector 模型和 Qdrant 可用时生成 query embedding 走 Qdrant,最后按 RRF 融合并回 MySQL hydrate 片段;Qdrant 不存正文事实源。
- Rubric 配置:`/api/competency/rubric/**` CRUD
@@ -306,7 +311,7 @@ INIT → WAITING_INPUT → PROCESSING → WAITING_INPUT(loop) → FINISHED
- **单场对练评分(每场一次)**:4 维 —— 合规与完整度、情感应变力、沟通有效性、营销敏感性;外加**导师润色版**(不是评分维度,是话术重写对比)。
- **能力画像(跨期加权,BRD 4.5)**:5 维加权 —— 任务完成度40% / 话术规范性25% / 情绪管理20% / 响应时效10% / 增值转化5%。**由多场对练评分聚合而来,不是同一层。**
**P0(2026-07-05 Demo):单 LLM 考官一次性出结构化分**
**P0(2026-07-05 MVP 演示):单 LLM 考官一次性出结构化分**
- 输入 `dialogue + rubric + sop_points + persona`,输出固定 JSON;**不做多模型融合**。
- **防漂移**:固定模型 + `temperature=0` + 固定 `prompt_version`;首次评分后**缓存 `practice_score`,刷新不重算**;分数用**档位锚点 60/75/90** 减少小数漂移。
- **可验收的可解释**:JSON 必带证据 —— `evidence_turn_ids`、`hit_sop_point_ids`、`missed_sop_point_ids`、`rewrite_before/after`;无证据的理由不展示。
@@ -315,7 +320,7 @@ INIT → WAITING_INPUT → PROCESSING → WAITING_INPUT(loop) → FINISHED
**P1(一期):再拆多模型融合**
- SOP 关键点覆盖检测 + 语义相似 + 情感/语音特征融合;Rubric 后台可配置 + 版本 + 启停;一致率校准集 + 人工评审流程。
**一致率验收口径**:`≥70%` 指**分档一致**(非原始分一致),档位建议 `<60 / 60–74 / 75–89 / ≥90`,用 ≥20 段样本对人工基准评估——**是一期目标,不是 2026-07-05 Demo 目标**;2026-07-05 只验端到端 + 结构化输出。
**一致率验收口径**:`≥70%` 指**分档一致**(非原始分一致),档位建议 `<60 / 60–74 / 75–89 / ≥90`,用 ≥20 段样本对人工基准评估——**是一期目标,不是 2026-07-05 MVP 演示目标**;2026-07-05 只验端到端 + 结构化输出。
### 6.4 Prompt 规格(实际入 `hr-ai` 模板库,均需后端兜底)
@@ -328,17 +333,17 @@ INIT → WAITING_INPUT → PROCESSING → WAITING_INPUT(loop) → FINISHED
### 6.5 三角色 P0 / P1 最小切分
- **P0(2026-07-05 Demo 必须)**:1 场景 × 1 人设 × 1 套 SOP × 1 套 rubric;**文本端到端先跑通**,语音作为外层 ASR/TTS,失败降级文本;`/start /turn /finish` 三接口闭环;每轮轻量 checker + 客户回应;`/finish` 单 LLM 出结构化分;固定 max_turns + 手动结束 + 评分缓存展示。
- **P0(2026-07-05 MVP 演示必须)**:1 场景 × 1 人设 × 1 套 SOP × 1 套 rubric;**文本端到端先跑通**,语音作为外层 ASR/TTS,失败降级文本;`/start /turn /finish` 三接口闭环;每轮轻量 checker + 客户回应;`/finish` 单 LLM 出结构化分;固定 max_turns + 手动结束 + 评分缓存展示。
- **P1(一期)**:Rubric 后台配置与版本;SOP 覆盖/语义/情绪融合;一致率校准集 + 人工评审;多场景/多 persona/动态难度/错题本/能力画像沉淀;LLM 调用日志/成本/prompt 版本/失败重试。
---
## 7. 集成适配层
| 集成 | 接口/做法 | Demo 兜底 | 优先级 |
| 集成 | 接口/做法 | MVP 兜底 | 优先级 |
|---|---|---|---|
| 组织人员同步 | `hr-sync` 拉取采购系统 API → 写 `sync_*` | 读静态快照文件 | P0 |
| 登录/SSO | 一期本地账号(若依) | — | P0(SSO=P2) |
| 登录/SSO | 管理端用若依本地账号;移动端用手机号短信登录,验证码通过后自动注册 `app_user` | SSO 不做 | P0(SSO=P2) |
| 权限映射 | `role_perm_mapping` 规则引擎 | 手工配置 | P0 |
| 大模型 | `hr-ai/chat` 封装公有 API | — | P0 |
| ASR/TTS | `hr-ai/asr,tts` 封装第三方(方言) | 1 家 1 条路径 | P0 |
@@ -349,7 +354,7 @@ INIT → WAITING_INPUT → PROCESSING → WAITING_INPUT(loop) → FINISHED
## 8. 权限与数据范围
- 认证:若依本地账号(一期);SSO 预留 `hr-sync` 对接点(P2)。
- 认证:管理端使用若依本地账号;移动端使用短信验证码登录,不存在的手机号自动注册 `app_user`;SSO 预留 `hr-sync` 对接点(P2)。
- 授权:`role_perm_mapping` 把外部岗位/组织映射到本地 `role_code`。
- **数据范围以项目为主体**:所有业务查询套若依数据权限,按 `party_role.scope_project_ext_org_id` 过滤;主管只见本项目数据。
@@ -366,7 +371,7 @@ INIT → WAITING_INPUT → PROCESSING → WAITING_INPUT(loop) → FINISHED
## 10. 实现优先级与里程碑(对齐 MVP 切割线 / 施工单)
### 10.1 P0(2026-07-05 Demo 必需)
### 10.1 P0(2026-07-05 MVP 演示必需)
- 工程骨架 + 本地登录 + 数据权限(M1/M2)
- AI 适配层:chat / asr / tts / score(M9)
@@ -382,7 +387,7 @@ INIT → WAITING_INPUT → PROCESSING → WAITING_INPUT(loop) → FINISHED
### 10.3 P2(二期)
- 知识图谱(Neo4j)、独立 APP、玉溪 Agent 化、企微/钉钉深度集成、SSO、实时同步、多项目类型(公建/景区)。
- 知识图谱(Neo4j)、原生 APP、玉溪 Agent 化、企微/钉钉深度集成、SSO、实时同步、多项目类型(公建/景区)。
---
+35
View File
@@ -64,6 +64,26 @@ export type ModelChatResponse = {
hints: string[];
};
export type VectorIndexStatus = {
modelName: string;
modelDimension: number;
collection: string;
qdrantDimension?: number;
qdrantPoints?: number;
fragments: number;
embeddedFragments: number;
embeddingModel?: string;
embeddingDimension?: number;
matched: boolean;
message: string;
};
export type VectorizeResponse = {
documents: number;
fragments: number;
model: string;
};
export function getModelProviders(): Promise<ApiResult<ModelProvider[]>> {
return request({
url: '/api/aihr/model/providers',
@@ -140,3 +160,18 @@ export function testModelChat(data: { prompt: string; model?: string }): Promise
headers: { repeatSubmit: false }
});
}
export function getVectorIndexStatus(): Promise<ApiResult<VectorIndexStatus>> {
return request({
url: '/api/knowledge/doc/vector-index-status',
method: 'get'
});
}
export function rebuildVectorIndex(): Promise<ApiResult<VectorizeResponse>> {
return request({
url: '/api/knowledge/doc/rebuild-vector-index',
method: 'post',
headers: { repeatSubmit: false }
});
}
+25 -3
View File
@@ -143,9 +143,12 @@
<span>PDF/Word/Excel/PPT 解析</span>
<el-switch :model-value="displayOverview.rules.parserEnabled" disabled active-color="#e60012" />
</label>
<label>
<span>图片 OCR</span>
<el-switch :model-value="displayOverview.rules.imageOcrEnabled" disabled active-color="#e60012" />
<label :class="displayOverview.rules.imageOcrEnabled ? '' : 'rule-item-disabled'">
<span :class="displayOverview.rules.imageOcrEnabled ? '' : 'rule-label-muted'">
图片 OCR
<el-tag v-if="!displayOverview.rules.imageOcrEnabled" size="small" type="info" class="coming-soon-tag">即将支持</el-tag>
</span>
<el-switch :model-value="displayOverview.rules.imageOcrEnabled" disabled :active-color="displayOverview.rules.imageOcrEnabled ? '#e60012' : undefined" />
</label>
<label>
<span>低置信回答不编造</span>
@@ -988,6 +991,25 @@ function fileBadgeClass(type: string) {
}
}
.rule-item-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.rule-label-muted {
display: flex;
align-items: center;
gap: 6px;
color: #94a3b8;
}
.coming-soon-tag {
font-size: 11px;
padding: 0 5px;
height: 18px;
line-height: 18px;
}
.risk-tip {
display: flex;
gap: 8px;
+97 -5
View File
@@ -98,6 +98,45 @@
</div>
</section>
<section class="panel vector-panel" v-loading="vectorLoading">
<div class="panel-title with-actions">
<h2>向量库状态</h2>
<div>
<el-button :icon="Refresh" @click="loadVectorStatus">刷新</el-button>
<el-button type="danger" plain :icon="Refresh" :loading="rebuilding" @click="runVectorRebuild">重建索引</el-button>
</div>
</div>
<div class="vector-grid">
<div class="field-row">
<span>启用模型</span>
<strong>{{ vectorStatus?.modelName || '-' }}</strong>
</div>
<div class="field-row">
<span>模型维度</span>
<strong>{{ vectorStatus?.modelDimension || '-' }}</strong>
</div>
<div class="field-row">
<span>Qdrant 维度</span>
<strong>{{ vectorStatus?.qdrantDimension || '-' }}</strong>
</div>
<div class="field-row">
<span>点数</span>
<strong>{{ vectorStatus?.qdrantPoints ?? '-' }}</strong>
</div>
<div class="field-row">
<span>片段向量</span>
<strong>{{ vectorStatus ? `${vectorStatus.embeddedFragments} / ${vectorStatus.fragments}` : '-' }}</strong>
</div>
<div class="field-row">
<span>当前状态</span>
<strong>
<el-tag :type="vectorStatus?.matched ? 'success' : 'warning'">{{ vectorStatusLabel }}</el-tag>
</strong>
</div>
</div>
</section>
<section class="panel model-panel">
<div class="panel-title">
<h2>模型列表</h2>
@@ -203,14 +242,16 @@
</template>
<script setup name="SystemModelConfig" lang="ts">
import { Connection, EditPen, Hide, InfoFilled, Plus, Search, View } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { Connection, EditPen, Hide, InfoFilled, Plus, Refresh, Search, View } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import {
createModelConfig,
createModelProvider,
getModelConfigs,
getModelProviders,
getVectorIndexStatus,
rebuildVectorIndex,
testModelChat,
updateModelConfig,
updateModelConfigEnabled,
@@ -219,7 +260,8 @@ import {
type ModelConfig,
type ModelConfigPayload,
type ModelProvider,
type ModelProviderPayload
type ModelProviderPayload,
type VectorIndexStatus
} from '@/api/aihr/model';
type ProviderView = ModelProvider & { logo: string };
@@ -385,10 +427,13 @@ const keyword = ref('');
const loading = ref(false);
const testing = ref(false);
const saving = ref(false);
const vectorLoading = ref(false);
const rebuilding = ref(false);
const showKey = ref(false);
const providerDialogVisible = ref(false);
const modelDialogVisible = ref(false);
const lastTestAt = ref('未测试');
const vectorStatus = ref<VectorIndexStatus>();
const providerForm = reactive<ProviderFormState>({
id: 0,
@@ -435,6 +480,7 @@ const providerEnabled = computed({
providerForm.status = value ? '0' : '1';
}
});
const vectorStatusLabel = computed(() => (vectorStatus.value?.matched ? '一致' : vectorStatus.value?.message || '需重建'));
const providerTag = (provider: ProviderView) => {
if (provider.providerCode.includes('ollama')) return { label: '本地', className: 'neutral' };
@@ -483,6 +529,38 @@ const loadData = async (preferredProviderCode = selectedProviderCode.value) => {
}
};
const loadVectorStatus = async () => {
vectorLoading.value = true;
try {
const result = await getVectorIndexStatus();
vectorStatus.value = result.data;
} catch {
vectorStatus.value = undefined;
} finally {
vectorLoading.value = false;
}
};
const runVectorRebuild = async () => {
try {
await ElMessageBox.confirm('将清空旧片段向量并重建 Qdrant 索引,继续吗?', '重建向量索引', {
confirmButtonText: '重建',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
rebuilding.value = true;
try {
const result = await rebuildVectorIndex();
await loadVectorStatus();
ElMessage.success(`已重建 ${result.data.documents} 份资料 / ${result.data.fragments} 个片段`);
} finally {
rebuilding.value = false;
}
};
const runConnectionTest = async () => {
testing.value = true;
try {
@@ -623,6 +701,7 @@ const submitModel = async () => {
}
modelDialogVisible.value = false;
await loadData(payload.providerCode);
await loadVectorStatus();
ElMessage.success('模型配置已保存');
} finally {
saving.value = false;
@@ -640,13 +719,17 @@ const toggleModelEnabled = async (row: ModelConfig, enabled: boolean) => {
try {
await updateModelConfigEnabled(row.id, enabled);
row.enabled = enabled;
await loadVectorStatus();
ElMessage.success(enabled ? '模型已启用' : '模型已停用');
} catch {
row.enabled = !enabled;
}
};
onMounted(loadData);
onMounted(() => {
loadData();
loadVectorStatus();
});
</script>
<style lang="scss" scoped>
@@ -849,6 +932,7 @@ onMounted(loadData);
}
.detail-panel,
.vector-panel,
.model-panel {
padding-bottom: 18px;
}
@@ -870,6 +954,13 @@ onMounted(loadData);
padding: 22px 24px 6px;
}
.vector-grid {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 18px 24px;
padding: 22px 24px 6px;
}
.field-list {
display: grid;
gap: 20px;
@@ -943,7 +1034,8 @@ onMounted(loadData);
}
.model-layout,
.detail-grid {
.detail-grid,
.vector-grid {
grid-template-columns: 1fr;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>AI人力助手</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1483
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "aihr-mobile",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite serve --mode development",
"build": "vite build --mode development",
"preview": "vite preview"
},
"dependencies": {
"vue": "3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.4",
"typescript": "~5.9.3",
"vite": "7.3.2",
"vue-tsc": "^3.2.5"
},
"engines": {
"node": ">=20.19.0",
"npm": ">=8.19.0"
}
}
+2378
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
export default defineConfig({
base: '/',
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5174,
open: false,
proxy: {
'/dev-api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (url) => url.replace(/^\/dev-api/, '')
}
}
}
});
+29
View File
@@ -3,6 +3,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FRONTEND_URL="${FRONTEND_URL:-http://127.0.0.1:5173}"
MOBILE_URL="${MOBILE_URL:-http://127.0.0.1:5174}"
cd "$ROOT_DIR"
@@ -33,6 +34,7 @@ check_url() {
need curl
check_url "frontend" "$FRONTEND_URL/"
check_url "mobile" "$MOBILE_URL/h5/user"
tenant_payload="$(curl -fsS "$FRONTEND_URL/dev-api/auth/tenant/list")"
[[ "$tenant_payload" == *'"tenantId":"000000"'* ]] || fail "tenant list missing tenant 000000"
@@ -48,6 +50,20 @@ contains frontend/src/router/index.ts "/knowledge/cases"
contains frontend/src/router/index.ts "/knowledge/sop"
contains frontend/src/router/index.ts "/system/model"
contains mobile/src/App.vue "早上好,王敏"
contains mobile/src/App.vue "AI结构化面试"
contains mobile/src/App.vue "团队今日概况"
contains mobile/src/App.vue "sendSmsCode"
contains mobile/src/App.vue "/dev-api/resource/sms/code"
contains mobile/src/App.vue "/dev-api/auth/mobile/sms-login"
contains mobile/src/App.vue "fetchMobileHome"
contains mobile/src/App.vue "/dev-api/api/aihr/mobile/home/"
contains mobile/src/App.vue "startMobilePractice"
contains mobile/src/App.vue "/dev-api/api/train/practice/start"
contains mobile/src/App.vue "训练完成,主管端待复盘已更新"
contains mobile/src/App.vue "/dev-api/api/aihr/mobile/practice/history"
contains mobile/src/App.vue "competencyProfile"
contains mobile/package.json "aihr-mobile"
contains frontend/src/views/recruit/interview.vue "建议复试"
contains frontend/src/views/train/practice.vue "导师改写"
contains frontend/src/views/knowledge/cases.vue "seed入库"
@@ -57,6 +73,12 @@ contains frontend/src/api/aihr/interview.ts "/api/recruit/interview/start"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "/api/recruit/interview"
contains frontend/src/api/aihr/practice.ts "/api/train/practice/start"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/api/train/practice"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "mobileCompletedCount"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "aihr_practice_session"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "待复盘对练"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed"
contains mobile/src/App.vue "标记已复盘"
contains frontend/src/api/aihr/case.ts "/api/knowledge/case/upload"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case"
contains frontend/src/api/aihr/sop.ts "/api/knowledge/search"
@@ -72,6 +94,8 @@ contains docs/RUOYI_AI_INCREMENTAL_MIGRATION.md "ruoyi-ai"
contains docs/RUOYI_AI_INCREMENTAL_MIGRATION.md "POST /api/knowledge/doc/upload"
contains backend/script/sql/aihr_knowledge_mysql8.sql "aihr_knowledge_fragment"
contains backend/script/sql/aihr_model_mysql8.sql "aihr_model_config"
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_session"
contains backend/script/sql/aihr_practice_mysql8.sql "dialogue_json"
contains backend/script/sql/aihr_model_mysql8.sql "api_key"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrModelController.java "/api/aihr/model"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrModelController.java "/providers/{providerCode}"
@@ -81,6 +105,10 @@ contains frontend/src/api/aihr/model.ts "/api/aihr/model/providers"
contains frontend/src/api/aihr/model.ts "/api/aihr/model/configs"
contains frontend/src/views/system/model/index.vue "submitProvider"
contains frontend/src/views/system/model/index.vue "submitModel"
contains backend/ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java "/mobile/sms-login"
contains backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java "aihr.sms.login-template-id"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/api/aihr/mobile"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "candidateHome"
echo "OK: demo routes and seed markers"
echo "OK: AI interview API markers"
@@ -90,4 +118,5 @@ echo "OK: AI SOP API markers"
echo "OK: ruoyi-ai incremental migration markers"
echo "OK: AI model migration markers"
echo "OK: AI model config page markers"
echo "OK: AI mobile role home markers"
echo "PASS: demo preflight"
+7
View File
@@ -3,6 +3,13 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f "$ROOT_DIR/.env.local" ]]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env.local"
set +a
fi
cd "$ROOT_DIR/backend"
mkdir -p "$ROOT_DIR/.data/import"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR/mobile"
if [[ ! -d node_modules ]]; then
npm install --registry=https://registry.npmmirror.com
fi
exec npm run dev -- --host 127.0.0.1 --port 5174 --strictPort
+10 -3
View File
@@ -8,7 +8,7 @@ usage() {
cat <<'EOF'
Usage: ./scripts/dev.sh [start|stop|restart|status|--reset]
Starts MySQL, Redis, MinIO, Qdrant, backend and frontend.
Starts MySQL, Redis, MinIO, Qdrant, backend, admin frontend and mobile frontend.
start start local demo services
stop stop backend and frontend dev processes
restart stop backend and frontend, then start again
@@ -78,12 +78,14 @@ stop_port() {
stop_dev() {
stop_port 8080
stop_port 5173
stop_port 5174
}
status() {
docker compose -f docker-compose.dev.yml ps
lsof -nP -iTCP:8080 -sTCP:LISTEN || true
lsof -nP -iTCP:5173 -sTCP:LISTEN || true
lsof -nP -iTCP:5174 -sTCP:LISTEN || true
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
@@ -125,6 +127,7 @@ cd "$ROOT_DIR"
ensure_docker
ensure_port_free 8080
ensure_port_free 5173
ensure_port_free 5174
if [[ "$RESET" == true ]]; then
./scripts/reset-dev-db.sh
@@ -140,10 +143,14 @@ PIDS+=("$!")
./scripts/dev-frontend.sh &
PIDS+=("$!")
./scripts/dev-mobile.sh &
PIDS+=("$!")
echo "Backend: http://127.0.0.1:8080/"
echo "Frontend: http://127.0.0.1:5173/"
echo "Admin: http://127.0.0.1:5173/"
echo "Mobile: http://127.0.0.1:5174/h5/user"
echo "Qdrant: http://127.0.0.1:6333/"
echo "Press Ctrl+C to stop backend and frontend."
echo "Press Ctrl+C to stop backend, admin frontend and mobile frontend."
while :; do
for pid in "${PIDS[@]}"; do
+1
View File
@@ -21,6 +21,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/ry_workflow.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
-e "select count(*) as tables_count from information_schema.tables where table_schema = 'ry-vue';"