feat: complete aihr mvp mobile workflow
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+15
-6
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<artifactId>ruoyi-aihr</artifactId>
|
||||
|
||||
<description>
|
||||
物业 AI 人力资源 Demo 业务模块
|
||||
物业 AI 人力资源 MVP 业务模块
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
|
||||
+61
@@ -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));
|
||||
}
|
||||
}
|
||||
+17
@@ -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());
|
||||
|
||||
+23
-1
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+108
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+222
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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");
|
||||
|
||||
+448
-3
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+440
-42
@@ -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 对练记录';
|
||||
@@ -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
@@ -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) 的四条关键路径执行。
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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 |
+11
-11
@@ -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、实时同步、多项目类型(公建/景区)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
Generated
+1483
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Executable
+12
@@ -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
@@ -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
|
||||
|
||||
@@ -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';"
|
||||
|
||||
Reference in New Issue
Block a user