diff --git a/.gitignore b/.gitignore index a72d719a..e91e8ab4 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 1c9b8024..95d199dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/README.md b/README.md index db1fe981..4b5052c6 100644 --- a/README.md +++ b/README.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`。 diff --git a/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java b/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java index c448a60d..33aa2d6f 100644 --- a/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java +++ b/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/AuthController.java @@ -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 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 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 + ) { + } + } diff --git a/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java b/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java index 2586adda..021ecd18 100644 --- a/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java +++ b/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java @@ -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 smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) { + public R 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 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(); } diff --git a/backend/ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java b/backend/ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java index 597a6013..db5dbee8 100644 --- a/backend/ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java +++ b/backend/ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java @@ -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().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); diff --git a/backend/ruoyi-admin/src/main/resources/application-dev.yml b/backend/ruoyi-admin/src/main/resources/application-dev.yml index 1b52fab9..567848b5 100644 --- a/backend/ruoyi-admin/src/main/resources/application-dev.yml +++ b/backend/ruoyi-admin/src/main/resources/application-dev.yml @@ -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 diff --git a/backend/ruoyi-admin/src/main/resources/application-prod.yml b/backend/ruoyi-admin/src/main/resources/application-prod.yml index d77ddf57..eb0f95e2 100644 --- a/backend/ruoyi-admin/src/main/resources/application-prod.yml +++ b/backend/ruoyi-admin/src/main/resources/application-prod.yml @@ -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 diff --git a/backend/ruoyi-modules/ruoyi-aihr/pom.xml b/backend/ruoyi-modules/ruoyi-aihr/pom.xml index 5bd7cab3..6883e0fc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/pom.xml +++ b/backend/ruoyi-modules/ruoyi-aihr/pom.xml @@ -12,7 +12,7 @@ ruoyi-aihr - 物业 AI 人力资源 Demo 业务模块 + 物业 AI 人力资源 MVP 业务模块 diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java new file mode 100644 index 00000000..585adb86 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -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 home(@PathVariable String role) { + return R.ok(mobileSeedService.home(role)); + } + + @GetMapping("/practice/history") + public R> practiceHistory(@RequestParam(required = false) String extPartyId) { + return R.ok(mobileSeedService.practiceHistory(extPartyId)); + } + + @GetMapping("/practice/reviews") + public R> practiceReviews() { + return R.ok(mobileSeedService.practiceReviews()); + } + + @GetMapping("/practice/reviews/{id}") + public R practiceReview(@PathVariable Long id) { + ReviewDetailResponse detail = mobileSeedService.practiceReview(id); + return detail == null ? R.fail("复盘记录不存在") : R.ok(detail); + } + + @PostMapping("/practice/reviews/{id}/reviewed") + public R markReviewed(@PathVariable Long id) { + return mobileSeedService.markReviewed(id) ? R.ok() : R.fail("复盘记录不存在"); + } + + @GetMapping("/profile") + public R profile(@RequestParam(required = false) String extPartyId) { + return R.ok(mobileSeedService.profile(extPartyId)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java index d7e97671..38ee22a3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java @@ -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 vectorizeMissing() { + return R.ok(sopSeedService.vectorizeMissing()); + } + + @GetMapping("/doc/vector-index-status") + public R vectorIndexStatus() { + return R.ok(sopSeedService.vectorIndexStatus()); + } + + @PostMapping("/doc/rebuild-vector-index") + public R rebuildVectorIndex() { + return R.ok(sopSeedService.rebuildVectorIndex()); + } + @GetMapping("/doc/import-tasks") public R> localImportTasks() { return R.ok(sopSeedService.localImportTasks()); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index 210876b0..373378ba 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -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 scoreItems, + List dialogue + ) { + } + + public record CompetencyResponse(Integer score, Integer completed, Integer pendingReview, List dimensions) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java index 6e441edd..09378fd1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java @@ -13,6 +13,24 @@ public final class AihrSopDto { public record UploadResponse(String docId, Long ossId, String fileName, String category, Integer fragments, String summary, List tags, List 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) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java new file mode 100644 index 00000000..76d219a3 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java @@ -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 tools, + List metrics, + List tabs, + TeamOverviewResponse team, + List 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 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 + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java new file mode 100644 index 00000000..9794db1b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java @@ -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 practiceHistory(String extPartyId) { + return practiceSeedService.mobileHistory(extPartyId, 5); + } + + public List 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" + ) + ); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java index f9c5def4..467b3e4e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java @@ -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"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index d5e4504d..48e43f19 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -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 scenarios = buildScenarios(); + private final ConcurrentMap 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 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 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 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 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 scoreItems(ResultSet rs) throws SQLException { + List 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 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 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 readDialogue(String json, ScenarioSeed scenario) { + if (!isBlank(json)) { + try { + List 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 dialogueTurns(ActiveSession activeSession, ScenarioSeed scenario) { + List turns = new ArrayList<>(); + List 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 traineeReplies) { + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 41871c7a..aee158d9 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -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 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 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 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 visionRuntime() { + try { + List 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 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(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 fragments) { - Optional runtime = embeddingRuntime(); - if (runtime.isEmpty() || fragments.isEmpty()) { - return; + private EmbeddingBatch embedFragments(long knowledgeId, String category, String docId, List fragments) { + if (fragments.isEmpty()) { + return new EmbeddingBatch("", 0); } - try { - List embeddings = callEmbeddings(runtime.get(), fragments); + for (EmbeddingRuntime runtime : embeddingRuntimes()) { + List embeddings; + try { + 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()); - } catch (Exception ignored) { - // ponytail: embedding is opportunistic; upload/search should survive bad vector config. + try { + upsertQdrant(knowledgeId, category, docId, fragments, embeddings, runtime.modelName()); + } catch (Exception ignored) { + // 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() { + private List embeddingRuntimes() { + List runtimes = new ArrayList<>(); try { - List 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 callEmbeddings(EmbeddingRuntime runtime, List 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 localEmbeddings(List 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 hits = rrf(fulltextHits(category, queryText, limitValue), vectorHits(category, queryText, limitValue), limitValue); - if (hits.isEmpty()) { - hits = likeHits(category, queryText, limitValue); - } + List keywordHits = likeHits(category, queryText, limitValue); + List textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue); + List hits = rrf(textHits, vectorHits(category, queryText, limitValue), limitValue); if (hits.isEmpty()) { return null; } @@ -1176,25 +1396,25 @@ public class AihrSopSeedService { } private List vectorHits(String category, String queryText, int limit) { - Optional runtime = embeddingRuntime(); - if (runtime.isEmpty()) { - return List.of(); - } - try { - List embeddings = callEmbeddings(runtime.get(), List.of(queryText)); - if (embeddings.isEmpty()) { - return List.of(); + for (EmbeddingRuntime runtime : embeddingRuntimes()) { + try { + List embeddings = callEmbeddings(runtime, List.of(queryText)); + if (embeddings.isEmpty()) { + continue; + } + List matches = queryQdrant(category, embeddings.get(0), limit); + List 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; try the next runtime before fulltext fallback. } - List matches = queryQdrant(category, embeddings.get(0), limit); - List hits = new ArrayList<>(); - for (VectorMatch match : matches) { - hydrateQdrantHit(match).ifPresent(hits::add); - } - return hits; - } catch (Exception ignored) { - // ponytail: vector recall is additive; MySQL fulltext remains the demo-safe path. - return List.of(); } + return List.of(); } private List fulltextHits(String category, String queryText, int limit) { @@ -1226,23 +1446,40 @@ public class AihrSopSeedService { } private List likeHits(String category, String queryText, int limit) { - return jdbcTemplate.query(""" + List terms = searchTerms(queryText); + if (terms.isEmpty()) { + return List.of(); + } + List scoreParts = new ArrayList<>(); + List 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 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 hydrateQdrantHit(VectorMatch match) { @@ -1333,6 +1570,37 @@ public class AihrSopSeedService { return Math.max(1, Math.min(limit, 10)); } + private static List searchTerms(String queryText) { + String cleaned = Optional.ofNullable(queryText).orElse("") + .toLowerCase() + .replaceAll("[^\\p{IsHan}\\p{Alnum}]+", " ") + .trim(); + if (cleaned.isEmpty()) { + return List.of(); + } + LinkedHashSet 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 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 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 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 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 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) { diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql new file mode 100644 index 00000000..a549e33f --- /dev/null +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -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 对练记录'; diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..d36dd074 --- /dev/null +++ b/design-qa.md @@ -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 diff --git a/docs/AI人力资源系统一期Demo作战清单.md b/docs/AI人力资源系统一期MVP版作战清单.md similarity index 83% rename from docs/AI人力资源系统一期Demo作战清单.md rename to docs/AI人力资源系统一期MVP版作战清单.md index 7b39199b..7febd0b1 100644 --- a/docs/AI人力资源系统一期Demo作战清单.md +++ b/docs/AI人力资源系统一期MVP版作战清单.md @@ -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 作为向量索引而非事实源,才能既演得通、又不把资料中台做成临时脚本。 diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 7ed47a3d..8dd20a5c 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -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 `;浏览器内通过已登录前端和 `/dev-api` 代理访问。 +直接打后端 `/api/**` 通常需要登录后的 `Authorization: Bearer `;浏览器内通过已登录前端和 `/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) 的四条关键路径执行。 diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index 81646827..7f1ad936 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/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 数据兜底。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 2d7658bd..213b32fd 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -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`,手机号登录 → “开始训练” → 两次“填入建议回复 / 提交本轮”,应看到评分、导师改写、训练历史和能力画像;切到主管端后完训人数与“待复盘对练”计数增加,并展示待复盘列表;点进单条可看评分、话术、导师改写,并可标记已复盘。 演示前可先跑最小预检: diff --git a/docs/README.md b/docs/README.md index 68bd1e97..40936660 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | diff --git a/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md b/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md index 8cda46fc..24adc5bc 100644 --- a/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md +++ b/docs/RUOYI_AI_INCREMENTAL_MIGRATION.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/视觉模型解析 - 单失败文件重试、导入任务取消/暂停、分布式队列 +- 大规模资料的异步向量重建进度条 diff --git a/docs/prototypes/主管端-首页.png b/docs/prototypes/主管端-首页.png new file mode 100644 index 00000000..9790f3f5 Binary files /dev/null and b/docs/prototypes/主管端-首页.png differ diff --git a/docs/prototypes/候选人端-首页.png b/docs/prototypes/候选人端-首页.png new file mode 100644 index 00000000..ad3f1222 Binary files /dev/null and b/docs/prototypes/候选人端-首页.png differ diff --git a/docs/prototypes/员工端-首页.png b/docs/prototypes/员工端-首页.png new file mode 100644 index 00000000..ad3f1222 Binary files /dev/null and b/docs/prototypes/员工端-首页.png differ diff --git a/docs/物业AI人力资源系统业务需求文档BRD.md b/docs/物业AI人力资源系统业务需求文档BRD.md index 8c478aad..ca34607a 100644 --- a/docs/物业AI人力资源系统业务需求文档BRD.md +++ b/docs/物业AI人力资源系统业务需求文档BRD.md @@ -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 切割线即可闭环。 diff --git a/docs/物业AI人力资源系统开发规格TechSpec.md b/docs/物业AI人力资源系统开发规格TechSpec.md index 8fa0bfcd..dbc91e91 100644 --- a/docs/物业AI人力资源系统开发规格TechSpec.md +++ b/docs/物业AI人力资源系统开发规格TechSpec.md @@ -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、实时同步、多项目类型(公建/景区)。 --- diff --git a/frontend/src/api/aihr/model.ts b/frontend/src/api/aihr/model.ts index 491f1bc9..e62d5bf5 100644 --- a/frontend/src/api/aihr/model.ts +++ b/frontend/src/api/aihr/model.ts @@ -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> { 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> { + return request({ + url: '/api/knowledge/doc/vector-index-status', + method: 'get' + }); +} + +export function rebuildVectorIndex(): Promise> { + return request({ + url: '/api/knowledge/doc/rebuild-vector-index', + method: 'post', + headers: { repeatSubmit: false } + }); +} diff --git a/frontend/src/views/knowledge/processing.vue b/frontend/src/views/knowledge/processing.vue index 9009c9bf..82f77395 100644 --- a/frontend/src/views/knowledge/processing.vue +++ b/frontend/src/views/knowledge/processing.vue @@ -143,9 +143,12 @@ PDF/Word/Excel/PPT 解析 -