diff --git a/AGENTS.md b/AGENTS.md index 2cea1902..4e28f257 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ - 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`。 - 模型配置:`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`,但移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换(isHomeTab + activeUserPanel/activeSupervisorPanel 条件),**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 全屏层(sopPanel)调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。 +- 移动端三端首页:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,分别对应用户/员工端、候选人端、主管端;兼容 `/h5/employee`、`/h5/admin`,但移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);员工端训练列表页是 `/h5/user/training`,首屏必须先给「场景模拟/开始练习/最近练过」,每日三题后置;点击「开始练习」进入独立 `/h5/user/practice` 对话页,不能在训练列表里用锚点或页内展开。师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换(isHomeTab + activeUserPanel/activeSupervisorPanel 条件),**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 全屏层(sopPanel)调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。 - 移动端首页接口:`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`。dev 兜底:`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod profile 代码级强制失效,不要移除该闸门。 - 移动端员工训练闭环:员工端首页“开始训练”复用 `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`。 diff --git a/README.md b/README.md index 11ad6c5c..4f9a57c6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。 - 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。 - 管理端 MVP:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。 -- 移动端 MVP:三端首页已拆到独立工程 `mobile/`;路由为 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,并兼容 `/h5/employee`、`/h5/admin`;已接手机号短信登录、`GET /api/aihr/mobile/home/{role}` 首页 API。员工端首页为「数字师傅」形态(师傅头像四状态+按住说话/打字问+快捷chips→今日安排→我的地图四色块→成长摘要,视觉基准 `docs/feedback/2026070423/design/`),底部 tab 今日/练/问/我整页切换;“开始训练”可完成两轮三角色对练,记录落 `aihr_practice_session` 并刷新主管端待复盘列表、复盘详情、标记已复盘状态和员工能力画像;「查 SOP」文本/语音提问返回结构化概要+引用片段,可按需生成信息图总结卡(听一遍/开始跟读);候选人端“开始面试/面试练习”复用 AI 面试 API 拉题、作答、评分,“补充资料”走 OSS + `aihr_candidate_material` 入库,HR 可在 AI 面试页审核通过/驳回;前端保留本地 fallback。 +- 移动端 MVP:三端首页已拆到独立工程 `mobile/`;路由为 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,并兼容 `/h5/employee`、`/h5/admin`;员工端另有 `/h5/user/training` 训练列表页和 `/h5/user/practice` 对练页;已接手机号短信登录、`GET /api/aihr/mobile/home/{role}` 首页 API。员工端首页为「数字师傅」形态(师傅头像四状态+按住说话/打字问+快捷chips→今日安排→我的地图四色块→成长摘要,视觉基准 `docs/feedback/2026070423/design/`),底部 tab 今日/练/问/我整页切换;“练”页首屏展示场景模拟、开始练习和最近练过,每日三题在下方;“开始练习”进入独立对练页并完成两轮三角色对练,记录落 `aihr_practice_session` 并刷新主管端待复盘列表、复盘详情、标记已复盘状态和员工能力画像;「查 SOP」文本/语音提问返回结构化概要+引用片段,可按需生成信息图总结卡(听一遍/开始跟读);候选人端“开始面试/面试练习”复用 AI 面试 API 拉题、作答、评分,“补充资料”走 OSS + `aihr_candidate_material` 入库,HR 可在 AI 面试页审核通过/驳回;前端保留本地 fallback。 - 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述);模型配置页可查看向量库维度/点数并一键重建。 - 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。 - 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。 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 index 52618a22..68358243 100644 --- 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 @@ -2,15 +2,27 @@ package org.dromara.aihr.controller; import cn.dev33.satoken.annotation.SaIgnore; import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse; import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse; +import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest; +import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; +import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; 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.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.satoken.utils.LoginHelper; 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.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -22,7 +34,6 @@ import java.util.Map; /** * Mobile H5 home APIs. */ -@SaIgnore @RequiredArgsConstructor @RestController @RequestMapping("/api/aihr/mobile") @@ -30,6 +41,7 @@ public class AihrMobileController { private final AihrMobileSeedService mobileSeedService; + @SaIgnore @GetMapping("/home/{role}") public R home(@PathVariable String role) { return R.ok(mobileSeedService.home(role)); @@ -37,7 +49,7 @@ public class AihrMobileController { @GetMapping("/practice/history") public R> practiceHistory(@RequestParam(required = false) String extPartyId) { - return R.ok(mobileSeedService.practiceHistory(extPartyId)); + return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId))); } @GetMapping("/practice/reviews") @@ -52,13 +64,61 @@ public class AihrMobileController { } @PostMapping("/practice/reviews/{id}/reviewed") - public R markReviewed(@PathVariable Long id) { - return mobileSeedService.markReviewed(id) ? R.ok() : R.fail("复盘记录不存在"); + public R markReviewed(@PathVariable Long id, @RequestBody(required = false) ReviewAdviceRequest request) { + return mobileSeedService.markReviewed(id, request) ? R.ok() : R.fail("复盘记录不存在"); } @GetMapping("/profile") public R profile(@RequestParam(required = false) String extPartyId) { - return R.ok(mobileSeedService.profile(extPartyId)); + return R.ok(mobileSeedService.profile(ownMobileExtPartyId(extPartyId))); + } + + @GetMapping("/profile/promotion-evidence") + public R promotionEvidence(@RequestParam(required = false) String extPartyId) { + return R.ok(mobileSeedService.promotionEvidence(ownMobileExtPartyId(extPartyId))); + } + + @GetMapping("/practice/alerts") + public R> practiceAlerts() { + return R.ok(mobileSeedService.practiceAlerts()); + } + + @GetMapping("/practice/assignments") + public R> practiceAssignments(@RequestParam(required = false) String extPartyId) { + return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId))); + } + + @PostMapping("/practice/assignments") + public R createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) { + return R.ok(mobileSeedService.createPracticeAssignment(request)); + } + + @PostMapping("/practice/assignments/{id}/answer") + public R answerDailyDrill(@PathVariable Long id, @RequestBody(required = false) DailyDrillAnswerRequest request) { + DailyDrillAnswerResponse response = mobileSeedService.answerDailyDrill(id, request, ownMobileExtPartyId(null)); + return response == null ? R.fail("每日三题不存在") : R.ok(response); + } + + private static String ownMobileExtPartyId(String requested) { + String username = currentAppUsername(); + return username.isBlank() ? requested : username; + } + + private static String scopedAssignmentExtPartyId(String requested) { + if (requested == null || requested.isBlank()) { + // ponytail: supervisor H5 keeps team-scope listing until mobile roles exist. + return requested; + } + return ownMobileExtPartyId(requested); + } + + private static String currentAppUsername() { + LoginUser loginUser = LoginHelper.getLoginUser(); + if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { + return ""; + } + String username = loginUser.getUsername(); + return username == null ? "" : username.trim(); } private static Map reviewDetailPayload(ReviewDetailResponse detail) { @@ -73,6 +133,8 @@ public class AihrMobileController { data.put("summary", detail.summary()); data.put("mentorRewrite", detail.mentorRewrite()); data.put("aiComment", detail.aiComment()); + data.put("reviewAdvice", detail.reviewAdvice()); + data.put("incentivePoint", detail.incentivePoint()); data.put("scoreItems", detail.scoreItems().stream().map(item -> Map.of( "label", item.label(), "value", item.value(), @@ -81,8 +143,20 @@ public class AihrMobileController { data.put("dialogue", detail.dialogue().stream().map(turn -> Map.of( "role", turn.role(), "label", turn.label(), - "text", turn.text() + "text", turn.text(), + "audioUrl", turn.audioUrl() )).toList()); + data.put("annotations", detail.annotations().stream().map(AihrMobileController::annotationPayload).toList()); return data; } + + private static Map annotationPayload(AnnotationResponse annotation) { + return Map.of( + "turnIndex", annotation.turnIndex(), + "sourceText", annotation.sourceText(), + "issue", annotation.issue(), + "rewrite", annotation.rewrite(), + "sopPoint", annotation.sopPoint() + ); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java index 429dabbd..7409556d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java @@ -1,19 +1,43 @@ package org.dromara.aihr.controller; +import cn.dev33.satoken.annotation.SaCheckRole; +import cn.dev33.satoken.annotation.SaMode; import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse; +import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest; +import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest; +import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse; import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest; import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse; +import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest; +import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; +import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest; 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.dromara.aihr.service.AihrPracticeSeedService; +import org.dromara.common.core.constant.TenantConstants; import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * 三角色对练 API。 */ @@ -22,11 +46,55 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/train/practice") public class AihrPracticeController { + private static final String HR_OPERATOR_ROLE = "hr_operator"; + private final AihrPracticeSeedService practiceSeedService; + @GetMapping("/scenarios") + public R> scenarios(@RequestParam(required = false) String position, + @RequestParam(required = false) String scenarioType) { + return R.ok(practiceSeedService.scenarios(position, scenarioType)); + } + + @GetMapping("/scenarios/{id}") + public R scenario(@PathVariable String id) { + return R.ok(practiceSeedService.scenario(id)); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PostMapping("/scenarios") + public R saveScenario(@RequestBody ScenarioSaveRequest request) { + return R.ok(practiceSeedService.saveScenario(request)); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PatchMapping("/scenarios/{id}/enabled") + public R updateScenarioEnabled(@PathVariable String id, @RequestBody ScenarioEnabledRequest request) { + return practiceSeedService.updateScenarioEnabled(id, request) ? R.ok() : R.fail("训练场景不存在"); + } + + @GetMapping("/scenarios/{id}/prep-card") + public R prepCard(@PathVariable String id) { + return R.ok(practiceSeedService.prepCard(id)); + } + + @GetMapping("/rubrics") + public R> rubrics(@RequestParam(required = false) String scenarioId) { + return R.ok(practiceSeedService.rubrics(scenarioId)); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @GetMapping("/export") + public ResponseEntity export() { + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=aihr-practice-pilot.csv") + .contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")) + .body(practiceSeedService.exportPilotCsv()); + } + @PostMapping("/start") public R start(@RequestBody StartRequest request) { - return R.ok(practiceSeedService.start(request)); + return R.ok(practiceSeedService.start(bindMobileStartIdentity(request))); } @PostMapping("/turn") @@ -38,4 +106,37 @@ public class AihrPracticeController { public R finish(@RequestBody FinishRequest request) { return R.ok(practiceSeedService.finish(request)); } + + @PostMapping("/sessions/{sessionId}/annotations") + public R> annotations(@PathVariable String sessionId, @RequestBody(required = false) AnnotationSaveRequest request) { + List annotations = practiceSeedService.saveAnnotations(sessionId, request); + return annotations.isEmpty() ? R.fail("训练会话不存在") : R.ok(annotations); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PostMapping("/sessions/{sessionId}/calibration") + public R calibration(@PathVariable String sessionId, @RequestBody(required = false) CalibrationRequest request) { + CalibrationResponse response = practiceSeedService.calibrate(sessionId, request); + return response == null ? R.fail("训练会话不存在") : R.ok(response); + } + + private static StartRequest bindMobileStartIdentity(StartRequest request) { + if (request == null || !"mobile".equalsIgnoreCase(request.mode())) { + return request; + } + String username = currentAppUsername(); + if (username.isBlank()) { + return request; + } + return new StartRequest(username, request.scenarioId(), request.mode()); + } + + private static String currentAppUsername() { + LoginUser loginUser = LoginHelper.getLoginUser(); + if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { + return ""; + } + String username = loginUser.getUsername(); + return username == null ? "" : username.trim(); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java new file mode 100644 index 00000000..20a7b188 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java @@ -0,0 +1,53 @@ +package org.dromara.aihr.controller; + +import cn.dev33.satoken.annotation.SaCheckRole; +import cn.dev33.satoken.annotation.SaMode; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; +import org.dromara.aihr.service.AihrPracticeSeedService; +import org.dromara.common.core.constant.TenantConstants; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * AI HR 提示词模板 API。 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/aihr/prompt-templates") +public class AihrPromptTemplateController { + + private static final String HR_OPERATOR_ROLE = "hr_operator"; + + private final AihrPracticeSeedService practiceSeedService; + + @GetMapping + public R> list(@RequestParam(required = false) String category, + @RequestParam(required = false) String code, + @RequestParam(required = false) Boolean enabled) { + return R.ok(practiceSeedService.promptTemplates(category, code, enabled)); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PostMapping + public R create(@RequestBody PromptTemplateRequest request) { + return R.ok(practiceSeedService.savePromptTemplate(request)); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PutMapping("/{id}") + public R update(@PathVariable Long id, @RequestBody PromptTemplateRequest request) { + PromptTemplateResponse response = practiceSeedService.updatePromptTemplate(id, request); + return response == null ? R.fail("提示词模板不存在") : R.ok(response); + } +} 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 bfc96ba8..847df036 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 @@ -7,6 +7,8 @@ import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse; 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.SopReviewRequest; +import org.dromara.aihr.domain.AihrSopDto.SopReviewResponse; import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest; import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse; import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse; @@ -63,6 +65,12 @@ public class AihrSopController { return R.ok(sopSeedService.search(request)); } + @PostMapping("/reviews/{id}") + public R review(@PathVariable Long id, @RequestBody(required = false) SopReviewRequest request) { + SopReviewResponse response = sopSeedService.reviewSearch(id, request); + return response == null ? R.fail("SOP问答评审记录不存在") : R.ok(response); + } + @PostMapping("/summary-card") public R summaryCard(@RequestBody SummaryCardRequest request) { return R.ok(sopSeedService.summaryCard(request)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java index de4af3df..dc01ade3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java @@ -1,11 +1,14 @@ package org.dromara.aihr.controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.domain.AihrSpeechDto.AsrResponse; import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest; import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse; import org.dromara.aihr.service.AihrSpeechService; import org.dromara.common.core.domain.R; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.service.ISysOssService; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -14,12 +17,16 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Base64; /** * 对练语音 API:ASR 语音输入 + TTS 客户语音播报。未配置语音模型时返回失败,前端降级为文本。 */ +@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/ai") @@ -30,6 +37,7 @@ public class AihrSpeechController { private static final int MAX_TTS_CHARS = 300; private final AihrSpeechService speechService; + private final ISysOssService ossService; @PostMapping(value = "/asr", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public R asr(@RequestPart("file") MultipartFile file) throws IOException { @@ -43,7 +51,10 @@ public class AihrSpeechController { return R.fail("音频过大(上限 5MB),请缩短录音时长"); } return speechService.transcribe(file.getBytes(), file.getOriginalFilename(), file.getContentType()) - .map(text -> R.ok(new AsrResponse(text, "openai-compatible"))) + .map(text -> { + SysOssVo oss = ossService.upload(file); + return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl())); + }) .orElseGet(() -> R.fail("语音识别失败,请改用文字输入")); } @@ -60,7 +71,30 @@ public class AihrSpeechController { text = text.substring(0, MAX_TTS_CHARS); } return speechService.synthesize(text, request == null ? null : request.voice()) - .map(audio -> R.ok(new TtsResponse("data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio), "openai-compatible"))) + .map(this::storeTtsAudio) .orElseGet(() -> R.fail("语音合成失败,已降级为文本展示")); } + + private R storeTtsAudio(byte[] audio) { + String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio); + Path tempFile = null; + try { + tempFile = Files.createTempFile("aihr-tts-", ".mp3"); + Files.write(tempFile, audio); + File file = tempFile.toFile(); + SysOssVo oss = ossService.upload(file); + return R.ok(new TtsResponse(oss.getUrl(), "openai-compatible", oss.getOssId(), inlineAudioUrl)); + } catch (Exception e) { + log.warn("tts audio upload failed: {}", e.getMessage()); + return R.ok(new TtsResponse(inlineAudioUrl, "openai-compatible")); + } finally { + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + log.warn("delete tts temp file failed: {}", e.getMessage()); + } + } + } + } } 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 373378ba..be59c550 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 @@ -26,7 +26,62 @@ public final class AihrPracticeDto { public record PersonaResponse(String customer, String project, String strategy, String goal) { } - public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl) { + public record ScenarioResponse( + String id, + String name, + String position, + String scenarioType, + String projectType, + Integer difficulty, + String customerPersona, + String openingText, + String explicitNeed, + String hiddenNeed, + String irritants, + String delights, + String sopRefs, + String successCriteria, + String strategy, + String goal, + Boolean enabled, + RubricResponse rubric + ) { + } + + public record ScenarioSaveRequest( + String id, + String name, + String position, + String scenarioType, + String projectType, + Integer difficulty, + String customerPersona, + String openingText, + String explicitNeed, + String hiddenNeed, + String irritants, + String delights, + String sopRefs, + String successCriteria, + String strategy, + String goal, + Boolean enabled + ) { + } + + public record ScenarioEnabledRequest(Boolean enabled) { + } + + public record RubricResponse(String code, String name, String version, List dimensions) { + } + + public record RubricDimensionResponse(String code, String name, Double weight, String description) { + } + + public record PrepCardResponse(List keyPoints, List redLines, List scripts, String promptVersion) { + } + + public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId) { } public record TurnResponse( @@ -35,6 +90,7 @@ public final class AihrPracticeDto { Integer emotion, Integer trust, String coachHint, + Boolean redFlag, Integer roundIndex, Boolean finished ) { @@ -59,7 +115,37 @@ public final class AihrPracticeDto { 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 DialogueResponse(String role, String label, String text, String audioUrl, Integer emotion, Integer trust, Boolean redFlag, String coachHint) { + public DialogueResponse(String role, String label, String text) { + this(role, label, text, "", null, null, null, ""); + } + + public DialogueResponse(String role, String label, String text, String audioUrl) { + this(role, label, text, audioUrl, null, null, null, ""); + } + } + + public record AnnotationResponse(Integer turnIndex, String sourceText, String issue, String rewrite, String sopPoint) { + } + + public record AnnotationSaveRequest(List annotations) { + } + + public record CalibrationRequest( + String reviewer, + Integer correctedTotal, + Integer correctedCompliance, + Integer correctedEmotion, + Integer correctedCommunication, + Integer correctedMarketing, + String reason + ) { + } + + public record CalibrationResponse(Long id, String sessionId, Integer originalScore, Integer correctedScore, String reviewer, String reason, String status, String createTime) { + } + + public record ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId, String nextReason) { } public record ReviewDetailResponse( @@ -73,11 +159,102 @@ public final class AihrPracticeDto { String summary, String mentorRewrite, String aiComment, + String reviewAdvice, + Integer incentivePoint, List scoreItems, - List dialogue + List dialogue, + List annotations ) { } - public record CompetencyResponse(Integer score, Integer completed, Integer pendingReview, List dimensions) { + public record CompetencyResponse( + Integer score, + Integer completed, + Integer pendingReview, + Integer trainingMinutes, + Integer contribution, + Integer assessmentScore, + String aiLevel, + List dimensions, + List growthPath + ) { + } + + public record GrowthStageResponse(String level, Integer progress, String status, String evidence) { + } + + public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) { + } + + public record PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId, String scenarioName, String source, String reason) { + } + + public record PracticeAssignmentResponse( + Long id, + String extPartyId, + String traineeName, + String scenarioId, + String scenarioName, + String source, + String reason, + String status, + String createTime, + String question, + String referenceAnswer, + String userAnswer, + String feedback, + Boolean correct + ) { + } + + public record DailyDrillAnswerRequest(String answer) { + } + + public record DailyDrillAnswerResponse(Long id, String status, Boolean correct, Integer score, String feedback, String referenceAnswer) { + } + + public record PromptTemplateRequest( + String code, + String name, + String category, + String outputFormat, + String systemPrompt, + String template, + String variables, + String version, + Boolean enabled, + String description + ) { + } + + public record PromptTemplateResponse( + Long id, + String code, + String name, + String category, + String outputFormat, + String systemPrompt, + String template, + String variables, + String version, + Boolean enabled, + String description, + String updateTime + ) { + } + + public record PromotionEvidenceResponse( + String extPartyId, + String currentLevel, + Integer assessmentScore, + Integer trainingMinutes, + Integer contribution, + Integer completed, + Integer pendingReview, + List dimensions, + List latestRecords, + List assignments, + List evidenceItems + ) { } } 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 f50ae605..564d1006 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 @@ -7,12 +7,18 @@ public final class AihrSopDto { private AihrSopDto() { } - public record SearchRequest(String queryText, String category, Integer limit) { + public record SearchRequest(String queryText, String category, String position, String source, Integer limit) { } public record SummaryCardRequest(String queryText, String category) { } + public record SopReviewRequest(Boolean usable, String reviewer, String note) { + } + + public record SopReviewResponse(Long id, Boolean usable, String status, String reviewedAt) { + } + public record SummaryCardResponse(String title, List steps, List objections, List scripts, List reminders) { } @@ -79,7 +85,8 @@ public final class AihrSopDto { List training, List records, List keyPoints, - List cautions + List cautions, + Long reviewId ) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSpeechDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSpeechDto.java index fbf2278d..87654e5f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSpeechDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSpeechDto.java @@ -5,12 +5,18 @@ public final class AihrSpeechDto { private AihrSpeechDto() { } - public record AsrResponse(String text, String source) { + public record AsrResponse(String text, String source, Long ossId, String audioUrl) { + public AsrResponse(String text, String source) { + this(text, source, null, ""); + } } public record TtsRequest(String text, String voice) { } - public record TtsResponse(String audioUrl, String source) { + public record TtsResponse(String audioUrl, String source, Long ossId, String inlineAudioUrl) { + public TtsResponse(String audioUrl, String source) { + this(audioUrl, source, null, ""); + } } } 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 index 9794db1b..0baca866 100644 --- 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 @@ -1,7 +1,14 @@ package org.dromara.aihr.service; import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse; +import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest; +import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; +import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse; import org.dromara.aihr.domain.vo.AihrMobileDto.MetricResponse; @@ -51,13 +58,41 @@ public class AihrMobileSeedService { } public boolean markReviewed(Long id) { - return practiceSeedService.markReviewed(id); + return markReviewed(id, null); + } + + public boolean markReviewed(Long id, ReviewAdviceRequest request) { + return practiceSeedService.markReviewed(id, request); } public CompetencyResponse profile(String extPartyId) { return practiceSeedService.competency(extPartyId); } + public PromotionEvidenceResponse promotionEvidence(String extPartyId) { + return practiceSeedService.promotionEvidence(extPartyId); + } + + public List practiceAlerts() { + return practiceSeedService.alerts(); + } + + public List practiceAssignments(String extPartyId) { + return practiceSeedService.assignments(extPartyId, 10); + } + + public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request) { + return practiceSeedService.createAssignment(request); + } + + public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) { + return answerDailyDrill(id, request, ""); + } + + public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request, String extPartyId) { + return practiceSeedService.answerDailyDrill(id, request, extPartyId); + } + private static String normalize(String role) { if (role == null) { return "user"; 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 87daca2d..ae815a5f 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 @@ -14,6 +14,7 @@ import org.dromara.aihr.domain.AihrModelDto.ConfigResponse; import org.dromara.aihr.domain.AihrModelDto.ProviderRequest; import org.dromara.aihr.domain.AihrModelDto.ProviderResponse; import org.dromara.aihr.domain.AihrModelDto.ProviderStatusRequest; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -43,6 +44,15 @@ public class AihrModelSeedService { private final ObjectMapper objectMapper; private final JdbcTemplate jdbcTemplate; + @Value("${aihr.ai-runtime.enabled:${AIHR_AI_RUNTIME_ENABLED:true}}") + private boolean aiEnabled; + + @Value("${aihr.ai-runtime.chat-enabled:${AIHR_AI_CHAT_ENABLED:true}}") + private boolean chatEnabled; + + @Value("${aihr.ai-runtime.speech-enabled:${AIHR_AI_SPEECH_ENABLED:true}}") + private boolean speechEnabled; + public List providers() { List rows = dbProviders(); return rows.isEmpty() ? seedProviders() : mergeProviders(rows); @@ -181,6 +191,18 @@ public class AihrModelSeedService { // 运行参数已就绪时以解析出的模型名为准:请求可能带的是 seed 占位模型名(如 gpt-4o-mini),不能透传给供应商 String modelName = runtime.configured() || request == null || isBlank(request.model()) ? runtime.modelName() : request.model(); + if (!chatAllowed()) { + return new ChatResponse( + false, + runtime.providerCode(), + modelName, + "AI 调用已被试点成本闸门关闭,当前返回文本兜底:先安抚业主情绪,确认事实与责任人,再承诺首次反馈时间。", + "cost-guard-fallback", + null, + List.of("需要恢复真实调用时设置 AIHR_AI_RUNTIME_ENABLED=true 且 AIHR_AI_CHAT_ENABLED=true。") + ); + } + if (!runtime.configured()) { return new ChatResponse( false, @@ -216,6 +238,9 @@ public class AihrModelSeedService { * 按类目(asr/tts)查已启用且配置齐全的语音模型运行参数,供 AihrSpeechService 使用。 */ public Optional speechModel(String category) { + if (!speechAllowed()) { + return Optional.empty(); + } try { List rows = jdbcTemplate.query(""" select c.model_name, c.provider_code, @@ -249,6 +274,10 @@ public class AihrModelSeedService { * 供其他模块(如三角色对练)复用的 chat 调用:模型未配置或调用失败返回 empty,由调用方决定兜底。 */ public Optional tryChat(String systemPrompt, String userPrompt, double temperature) { + if (!chatAllowed()) { + // ponytail: manual cost breaker; replace with metered monthly billing guard when vendor usage data is wired. + return Optional.empty(); + } RuntimeConfig runtime = runtimeConfig(null); if (!runtime.configured()) { return Optional.empty(); @@ -261,6 +290,14 @@ public class AihrModelSeedService { } } + private boolean chatAllowed() { + return aiEnabled && chatEnabled; + } + + private boolean speechAllowed() { + return aiEnabled && speechEnabled; + } + private List dbProviders() { try { return jdbcTemplate.query(""" diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java index 1ee0715f..1280e04e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java @@ -38,6 +38,15 @@ public class AihrPracticeLlmService { ) { } + public record PracticeTurn( + String customerText, + int emotion, + int trust, + String coachHint, + boolean redFlag + ) { + } + /** * 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。 */ @@ -64,6 +73,42 @@ public class AihrPracticeLlmService { .filter(text -> !text.isBlank()); } + /** + * 生成下一句业主回应,并让模型同步给出回合情绪、信任、红线和教练提示;解析失败由调用方回退 seed。 + */ + public Optional customerTurn( + String personaTraits, + String project, + String goal, + List dialogue, + String traineeText, + String nextBeat, + int defaultEmotion, + int defaultTrust, + String defaultCoachHint + ) { + String system = """ + 你只扮演物业小区业主,同时作为训练引擎给出结构化回合状态。 + 人设特征:%s + 场景:%s + 隐性诉求:%s + 规则: + 1. customerText 是业主下一句话,一句以内,不超过%d个字,口语化。 + 2. 禁止在 customerText 中提及系统、人设、评分、SOP、AI、训练。 + 3. emotion/trust 为 0-100 整数;员工承接越好 trust 越高,推责或对抗则 redFlag=true。 + 4. coachHint 给员工下一步改进建议,不超过60字。 + 5. 剧情推进方向(不要照抄原句):%s + 只输出 JSON,不要输出其他文字: + {"customerText":"","emotion":%d,"trust":%d,"coachHint":"","redFlag":false} + """.formatted(personaTraits, project, goal, MAX_CUSTOMER_CHARS, nextBeat, defaultEmotion, defaultTrust); + String user = "对话记录:\n" + renderDialogue(dialogue) + + "\n员工刚才说:" + traineeText + + "\n默认教练提示:" + defaultCoachHint + + "\n请输出 JSON。"; + return modelService.tryChat(system, user, CUSTOMER_TEMPERATURE) + .flatMap(content -> parseTurn(content, defaultEmotion, defaultTrust, defaultCoachHint)); + } + /** * /finish 单 LLM 结构化评分:temperature=0 + 固定 prompt 版本 + 档位锚点 60/75/90。 */ @@ -111,6 +156,26 @@ public class AihrPracticeLlmService { } } + private Optional parseTurn(String content, int defaultEmotion, int defaultTrust, String defaultCoachHint) { + try { + JsonNode root = objectMapper.readTree(extractJson(content)); + String customerText = cleanCustomerLine(root.path("customerText").asText("")); + if (customerText.isBlank()) { + return Optional.empty(); + } + return Optional.of(new PracticeTurn( + truncateText(customerText, MAX_CUSTOMER_CHARS * 2), + clampScore(root.path("emotion").asInt(defaultEmotion)), + clampScore(root.path("trust").asInt(defaultTrust)), + truncateText(root.path("coachHint").asText(defaultCoachHint), 120), + root.path("redFlag").asBoolean(false) + )); + } catch (Exception e) { + log.warn("practice turn parse failed, falls back to seed: {}", e.getMessage()); + return Optional.empty(); + } + } + /** 维度字段缺失时回落到 total,避免 asInt() 把缺失当 0 分落库。 */ private static int dimension(JsonNode root, String field, int fallback) { JsonNode node = root.path(field); 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 b8741762..4170d806 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 @@ -3,18 +3,40 @@ 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.AnnotationResponse; +import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest; +import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest; +import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse; import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse; +import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest; +import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse; 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.GrowthStageResponse; import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; +import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse; +import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest; +import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; +import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest; 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.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn; +import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -27,6 +49,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -45,12 +68,177 @@ public class AihrPracticeSeedService { private final ObjectMapper objectMapper; private final JdbcTemplate jdbcTemplate; private final AihrPracticeLlmService practiceLlmService; + private final AihrSopSeedService sopSeedService; private volatile boolean practiceTableReady; + private volatile boolean assignmentTableReady; + private volatile boolean scenarioTableReady; + private volatile boolean calibrationTableReady; + private volatile boolean promptTemplateTableReady; + private volatile boolean annotationTableReady; + private volatile boolean audioTableReady; - public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService) { + public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService, AihrSopSeedService sopSeedService) { this.objectMapper = objectMapper; this.jdbcTemplate = jdbcTemplate; this.practiceLlmService = practiceLlmService; + this.sopSeedService = sopSeedService; + } + + public List scenarios(String position, String scenarioType) { + String safePosition = isBlank(position) ? "" : position.trim(); + String safeType = isBlank(scenarioType) ? "" : scenarioType.trim(); + try { + List rows = jdbcTemplate.query(""" + SELECT scenario_code, scenario_name, position, scenario_type, project_type, difficulty, + customer_persona, opening_text, explicit_need, hidden_need, irritants, delights, + sop_refs, success_criteria, strategy, goal, enabled + FROM aihr_practice_scenario + WHERE tenant_id = ? + AND enabled = 1 + AND (? = '' OR position = ?) + AND (? = '' OR scenario_type = ?) + ORDER BY sort_order ASC, id ASC + """, this::mapScenarioResponse, TENANT_ID, safePosition, safePosition, safeType, safeType); + return rows.isEmpty() ? fallbackScenarios() : rows; + } catch (DataAccessException e) { + return fallbackScenarios(); + } + } + + public ScenarioResponse scenario(String id) { + if (isBlank(id)) { + return scenarios(null, null).get(0); + } + try { + List rows = jdbcTemplate.query(""" + SELECT scenario_code, scenario_name, position, scenario_type, project_type, difficulty, + customer_persona, opening_text, explicit_need, hidden_need, irritants, delights, + sop_refs, success_criteria, strategy, goal, enabled + FROM aihr_practice_scenario + WHERE tenant_id = ? AND scenario_code = ? + LIMIT 1 + """, this::mapScenarioResponse, TENANT_ID, id.trim()); + if (!rows.isEmpty()) { + return rows.get(0); + } + } catch (DataAccessException ignored) { + // Fall through to seed scenario. + } + ScenarioSeed seed = resolveScenario(id, null); + return fallbackScenario(seed); + } + + public ScenarioResponse saveScenario(ScenarioSaveRequest request) { + ensureScenarioTable(); + LocalDateTime now = LocalDateTime.now(); + String code = normalizeScenarioCode(firstNonBlank(request == null ? null : request.id(), "scenario-" + System.currentTimeMillis())); + String name = firstNonBlank(request == null ? null : request.name(), "自定义对练场景"); + String scenarioType = firstNonBlank(request == null ? null : request.scenarioType(), "投诉处理"); + String projectType = firstNonBlank(request == null ? null : request.projectType(), "住宅"); + int difficulty = Math.max(1, Math.min(5, request == null || request.difficulty() == null ? 1 : request.difficulty())); + boolean enabled = request == null || request.enabled() == null || request.enabled(); + jdbcTemplate.update(""" + INSERT INTO aihr_practice_scenario + (tenant_id, scenario_code, scenario_name, position, scenario_type, project_type, difficulty, + customer_persona, opening_text, explicit_need, hidden_need, irritants, delights, sop_refs, + success_criteria, strategy, goal, trainee_name, trust_start, trust_end, rounds_json, enabled, sort_order, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '张三', 60, 80, NULL, ?, 100, ?, ?) + ON DUPLICATE KEY UPDATE + scenario_name = VALUES(scenario_name), + position = VALUES(position), + scenario_type = VALUES(scenario_type), + project_type = VALUES(project_type), + difficulty = VALUES(difficulty), + customer_persona = VALUES(customer_persona), + opening_text = VALUES(opening_text), + explicit_need = VALUES(explicit_need), + hidden_need = VALUES(hidden_need), + irritants = VALUES(irritants), + delights = VALUES(delights), + sop_refs = VALUES(sop_refs), + success_criteria = VALUES(success_criteria), + strategy = VALUES(strategy), + goal = VALUES(goal), + enabled = VALUES(enabled), + update_time = VALUES(update_time) + """, + TENANT_ID, + code, + name, + firstNonBlank(request == null ? null : request.position(), "生活顾问"), + scenarioType, + projectType, + difficulty, + firstNonBlank(request == null ? null : request.customerPersona(), "高情绪、需要明确反馈的业主"), + firstNonBlank(request == null ? null : request.openingText(), "你们这个问题到底什么时候能处理?"), + firstNonBlank(request == null ? null : request.explicitNeed(), "希望物业明确处理方案"), + firstNonBlank(request == null ? null : request.hiddenNeed(), "希望被重视并持续跟进"), + firstNonBlank(request == null ? null : request.irritants(), "被敷衍、无反馈节点、推给别人"), + firstNonBlank(request == null ? null : request.delights(), "先承接、给责任人、给首次反馈时间"), + firstNonBlank(request == null ? null : request.sopRefs(), "生活顾问SOP"), + firstNonBlank(request == null ? null : request.successCriteria(), "确认诉求、责任人、处理节点和回访方式"), + firstNonBlank(request == null ? null : request.strategy(), "仅在漏关键步骤时提示"), + firstNonBlank(request == null ? null : request.goal(), "训练生活顾问按SOP完成闭环沟通。"), + enabled, + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ); + return scenario(code); + } + + public boolean updateScenarioEnabled(String id, ScenarioEnabledRequest request) { + ensureScenarioTable(); + if (isBlank(id)) { + return false; + } + int updated = jdbcTemplate.update(""" + UPDATE aihr_practice_scenario + SET enabled = ?, update_time = ? + WHERE tenant_id = ? AND scenario_code = ? + """, + request == null || request.enabled() == null || request.enabled(), + Timestamp.valueOf(LocalDateTime.now()), + TENANT_ID, + id.trim() + ); + return updated > 0; + } + + public PrepCardResponse prepCard(String scenarioId) { + ScenarioResponse scenario = scenario(scenarioId); + return new PrepCardResponse( + List.of( + firstNonBlank(scenario.explicitNeed(), "先确认业主当前诉求"), + firstNonBlank(scenario.successCriteria(), "给出责任人、处理节点和反馈方式"), + firstNonBlank(scenario.sopRefs(), "按已入库SOP执行") + ), + splitOrDefault(scenario.irritants(), List.of("不要先讲规定", "不要口头承诺未经确认的结果", "不要让业主空等")), + List.of( + "我先确认您的诉求和现场情况,再给您明确的责任人和首次反馈时间。", + firstNonBlank(scenario.delights(), "这个问题我会跟进到闭环,过程节点会同步给您。") + ), + "builtin-prep_card-v1" + ); + } + + public List rubrics(String scenarioId) { + String safeScenario = isBlank(scenarioId) ? "" : scenarioId.trim(); + try { + List rows = jdbcTemplate.query(""" + SELECT rubric_code, rubric_name, version + FROM aihr_practice_rubric + WHERE tenant_id = ? + AND enabled = 1 + AND (? = '' OR scenario_code = ?) + ORDER BY id ASC + """, this::mapRubric, TENANT_ID, safeScenario, safeScenario); + if (!rows.isEmpty()) { + return rows; + } + } catch (DataAccessException ignored) { + // Fall through to seed rubric. + } + return List.of(defaultRubric(isBlank(scenarioId) ? "complaint-water" : scenarioId.trim())); } public StartResponse start(StartRequest request) { @@ -60,7 +248,7 @@ public class AihrPracticeSeedService { String trainee = resolveTrainee(request, scenario); List customerLines = new ArrayList<>(); customerLines.add(firstRound.customer()); - activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), isMobile(request), LocalDateTime.now(), new ArrayList<>(), customerLines)); + activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), isMobile(request), LocalDateTime.now(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>())); return new StartResponse( sessionId, scenario.id(), @@ -75,45 +263,96 @@ public class AihrPracticeSeedService { } public TurnResponse turn(TurnRequest request) { - ScenarioSeed scenario = resolveScenario(null, request == null ? null : request.sessionId()); + ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId()); + ScenarioSeed scenario = resolveScenario(session == null ? null : session.scenarioId(), request == null ? null : request.sessionId()); int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size()); rememberTraineeReply(request, roundIndex); - ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId()); RoundSeed currentRound = scenario.rounds().get(roundIndex); int nextRoundIndex = roundIndex + 1; boolean finished = nextRoundIndex >= scenario.rounds().size(); RoundSeed nextRound = finished ? currentRound : scenario.rounds().get(nextRoundIndex); - int trust = finished ? scenario.trustEnd() : nextRound.trust(); - String customerText = finished ? "" : resolveCustomerReply(scenario, session, request, nextRound, nextRoundIndex); + boolean localRedFlag = hasRedFlag(request == null ? null : request.traineeText()); + ResolvedTurn resolved = finished ? new ResolvedTurn("", nextRound.emotion(), scenario.trustEnd(), currentRound.coachHint(), localRedFlag) + : resolveCustomerTurn(scenario, session, request, nextRound, nextRoundIndex, localRedFlag, currentRound.coachHint()); + boolean redFlag = localRedFlag || resolved.redFlag(); + String coachHint = redFlag ? redFlagCoachHint(resolved.coachHint()) : resolved.coachHint(); + rememberTurnEvidence(session, roundIndex, resolved.emotion(), resolved.trust(), redFlag, coachHint); return new TurnResponse( - customerText, + resolved.customerText(), "", - nextRound.emotion(), - trust, - currentRound.coachHint(), + resolved.emotion(), + resolved.trust(), + coachHint, + redFlag, nextRoundIndex, finished ); } + private boolean hasRedFlag(String traineeText) { + if (isBlank(traineeText)) { + return false; + } + String text = traineeText.trim(); + return text.contains("不归我们管") + || text.contains("你自己") + || text.contains("自己协调") + || text.contains("没办法") + || text.contains("不知道") + || text.contains("肯定没问题") + || text.contains("保证") + || text.contains("马上减免") + || text.contains("必须交") + || text.contains("爱交不交") + || text.contains("规定就是") + || text.contains("你冷静") + || text.contains("别激动"); + } + + private String redFlagCoachHint(String originalHint) { + String hint = isBlank(originalHint) ? "补上责任人、反馈时间和SOP依据。" : originalHint; + return "红线纠偏:避免推责、绝对承诺或情绪对抗;先承接诉求,再给责任人、处理节点和反馈方式。" + hint; + } + /** * 优先用 LLM 按人设生成客户回应(seed 下一轮台词作为剧情锚点),失败回退 seed 台词。 */ - private String resolveCustomerReply(ScenarioSeed scenario, ActiveSession session, TurnRequest request, RoundSeed nextRound, int nextRoundIndex) { + private ResolvedTurn resolveCustomerTurn( + ScenarioSeed scenario, + ActiveSession session, + TurnRequest request, + RoundSeed nextRound, + int nextRoundIndex, + boolean localRedFlag, + String defaultCoachHint + ) { String traineeText = request == null || isBlank(request.traineeText()) ? "" : request.traineeText().trim(); - String customerText = nextRound.customer(); + ResolvedTurn resolved = new ResolvedTurn(nextRound.customer(), nextRound.emotion(), nextRound.trust(), defaultCoachHint, localRedFlag); if (session != null && !traineeText.isEmpty()) { - customerText = practiceLlmService.customerReply( + Optional llmTurn = practiceLlmService.customerTurn( scenario.customer(), scenario.project(), scenario.goal(), dialogueTurns(session, scenario), traineeText, - nextRound.customer() - ).orElse(customerText); + nextRound.customer(), + nextRound.emotion(), + nextRound.trust(), + defaultCoachHint + ); + if (llmTurn.isPresent()) { + PracticeTurn turn = llmTurn.get(); + resolved = new ResolvedTurn( + turn.customerText(), + turn.emotion(), + turn.trust(), + firstNonBlank(turn.coachHint(), defaultCoachHint), + localRedFlag || turn.redFlag() + ); + } } - rememberCustomerLine(session, nextRoundIndex, customerText); - return customerText; + rememberCustomerLine(session, nextRoundIndex, resolved.customerText()); + return resolved; } public FinishResponse finish(FinishRequest request) { @@ -197,6 +436,68 @@ public class AihrPracticeSeedService { """, this::mapRecord, TENANT_ID, normalizeLimit(limit)); } + public String exportPilotCsv() { + ensurePracticeTable(); + ensureAssignmentTable(); + ensureCalibrationTable(); + int trainingCount = count("SELECT COUNT(*) FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile'", TENANT_ID); + int pendingAssignments = count("SELECT COUNT(*) FROM aihr_practice_assignment WHERE tenant_id = ? AND status = '待训练'", TENANT_ID); + int reviewed = count("SELECT COUNT(*) FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile' AND status = '已复盘'", TENANT_ID); + CalibrationStats calibrationStats = calibrationStats(); + AihrSopSeedService.SopReviewStats sopStats = sopSeedService.sopReviewStats(); + int alertCount = count(""" + SELECT COUNT(*) + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile' AND (total_score < 80 OR status = '待复盘') + """, TENANT_ID); + Double averageScore = jdbcTemplate.queryForObject(""" + SELECT COALESCE(AVG(total_score), 0) + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile' + """, Double.class, TENANT_ID); + StringBuilder csv = new StringBuilder(); + appendCsvRow(csv, "metric", "value"); + appendCsvRow(csv, "training_count", trainingCount); + appendCsvRow(csv, "completion_rate", percent(trainingCount, trainingCount + pendingAssignments)); + appendCsvRow(csv, "average_score", String.format(java.util.Locale.ROOT, "%.1f", averageScore == null ? 0 : averageScore)); + appendCsvRow(csv, "alert_count", alertCount); + appendCsvRow(csv, "review_rate", percent(reviewed, trainingCount)); + appendCsvRow(csv, "calibration_sample_count", calibrationStats.total()); + appendCsvRow(csv, "calibration_sample_target", 20); + appendCsvRow(csv, "calibration_sample_ready", calibrationStats.total() >= 20); + appendCsvRow(csv, "ai_human_band_consistency_rate", percent(calibrationStats.matched(), calibrationStats.total())); + appendCsvRow(csv, "ai_human_band_consistency_target", "70.0%"); + appendCsvRow(csv, "ai_human_band_consistency_ready", calibrationStats.total() >= 20 && calibrationStats.matched() * 100 >= calibrationStats.total() * 70); + appendCsvRow(csv, "sop_review_sample_count", sopStats.total()); + appendCsvRow(csv, "sop_answer_usable_rate", percent(sopStats.usable(), sopStats.total())); + appendCsvRow(csv, "sop_answer_usable_target", "80.0%"); + appendCsvRow(csv, "sop_answer_usable_ready", sopStats.total() > 0 && sopStats.usable() * 100 >= sopStats.total() * 80); + appendCsvRow(csv, "pilot_metric_ready", calibrationStats.total() >= 20 + && calibrationStats.matched() * 100 >= calibrationStats.total() * 70 + && sopStats.total() > 0 + && sopStats.usable() * 100 >= sopStats.total() * 80); + csv.append('\n'); + appendCsvRow(csv, "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point"); + jdbcTemplate.queryForList(""" + SELECT session_id, trainee_name, scenario_name, total_score, status, finished_time, summary, review_advice, incentive_point + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile' + ORDER BY finished_time DESC, id DESC + """, TENANT_ID).forEach(row -> appendCsvRow( + csv, + row.get("session_id"), + row.get("trainee_name"), + row.get("scenario_name"), + row.get("total_score"), + row.get("status"), + row.get("finished_time"), + row.get("summary"), + row.get("review_advice"), + row.get("incentive_point") + )); + return csv.toString(); + } + public ReviewDetailResponse reviewDetail(Long id) { ensurePracticeTable(); if (id == null) { @@ -204,7 +505,7 @@ public class AihrPracticeSeedService { } 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 + summary, mentor_rewrite, ai_comment, review_advice, incentive_point, dim_compliance, dim_emotion, dim_communication, dim_marketing, dialogue_json, annotations_json FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile' AND id = ? """, this::mapReviewDetail, TENANT_ID, id); @@ -212,15 +513,145 @@ public class AihrPracticeSeedService { } public boolean markReviewed(Long id) { + return markReviewed(id, null); + } + + public boolean markReviewed(Long id, ReviewAdviceRequest request) { ensurePracticeTable(); if (id == null) { return false; } - return jdbcTemplate.update(""" + int updated = jdbcTemplate.update(""" UPDATE aihr_practice_session - SET status = '已复盘', update_time = ? + SET status = '已复盘', + review_advice = CASE WHEN ? = '' THEN review_advice ELSE ? END, + incentive_point = CASE WHEN ? IS NULL THEN incentive_point ELSE ? END, + update_time = ? WHERE tenant_id = ? AND mode = 'mobile' AND id = ? - """, Timestamp.valueOf(LocalDateTime.now()), TENANT_ID, id) > 0; + """, + adviceText(request), + adviceText(request), + incentivePoint(request), + incentivePoint(request), + Timestamp.valueOf(LocalDateTime.now()), + TENANT_ID, + id + ); + if (updated > 0 && request != null && !isBlank(request.nextScenarioId())) { + ReviewDetailResponse detail = reviewDetail(id); + String extPartyId = reviewExtPartyId(id); + createAssignment(new PracticeAssignmentRequest( + firstNonBlank(extPartyId, detail == null ? "mobile-user" : detail.trainee()), + detail == null ? "待复盘员工" : detail.trainee(), + request.nextScenarioId(), + null, + "manual", + firstNonBlank(request.nextReason(), "主管复盘后派发下一次训练") + )); + } + return updated > 0; + } + + public List saveAnnotations(String sessionId, AnnotationSaveRequest request) { + ensurePracticeTable(); + if (isBlank(sessionId)) { + return List.of(); + } + List rows = jdbcTemplate.query(""" + SELECT scenario_id, dialogue_json, annotations_json + FROM aihr_practice_session + WHERE tenant_id = ? AND session_id = ? + LIMIT 1 + """, (rs, rowNum) -> new SessionAnnotationSource( + rs.getString("scenario_id"), + rs.getString("dialogue_json"), + rs.getString("annotations_json") + ), TENANT_ID, sessionId.trim()); + if (rows.isEmpty()) { + return List.of(); + } + SessionAnnotationSource source = rows.get(0); + ScenarioSeed scenario = resolveScenario(source.scenarioId(), sessionId); + List dialogue = readDialogue(source.dialogueJson(), scenario); + List annotations = request == null || request.annotations() == null || request.annotations().isEmpty() + ? readAnnotations(source.annotationsJson(), dialogue, scenario) + : sanitizeAnnotations(request.annotations(), scenario); + jdbcTemplate.update(""" + UPDATE aihr_practice_session + SET annotations_json = ?, update_time = ? + WHERE tenant_id = ? AND session_id = ? + """, writeJson(annotations), Timestamp.valueOf(LocalDateTime.now()), TENANT_ID, sessionId.trim()); + replaceAnnotationRows(sessionId.trim(), annotations); + return annotations; + } + + public CalibrationResponse calibrate(String sessionId, CalibrationRequest request) { + ensurePracticeTable(); + ensureCalibrationTable(); + if (isBlank(sessionId)) { + return null; + } + List rows = jdbcTemplate.query(""" + SELECT total_score, dim_compliance, dim_emotion, dim_communication, dim_marketing + FROM aihr_practice_session + WHERE tenant_id = ? AND session_id = ? + LIMIT 1 + """, (rs, rowNum) -> new ScoreSnapshot( + rs.getInt("total_score"), + rs.getInt("dim_compliance"), + rs.getInt("dim_emotion"), + rs.getInt("dim_communication"), + rs.getInt("dim_marketing") + ), TENANT_ID, sessionId.trim()); + if (rows.isEmpty()) { + return null; + } + ScoreSnapshot score = rows.get(0); + int correctedTotal = request == null || request.correctedTotal() == null ? score.total() : clampScore(request.correctedTotal()); + LocalDateTime now = LocalDateTime.now(); + String reviewer = firstNonBlank(request == null ? null : request.reviewer(), "主管复盘"); + String reason = firstNonBlank(request == null ? null : request.reason(), "人工校准AI评分"); + jdbcTemplate.update(""" + INSERT INTO aihr_practice_calibration + (tenant_id, session_id, reviewer, original_score, corrected_score, corrected_dimensions_json, reason, status, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, '已记录', ?, ?) + """, + TENANT_ID, + sessionId.trim(), + reviewer, + score.total(), + correctedTotal, + calibrationDimensionsJson(score, request), + reason, + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ); + return latestCalibration(sessionId.trim()); + } + + private String adviceText(ReviewAdviceRequest request) { + if (request == null || isBlank(request.advice())) { + return ""; + } + String reviewer = firstNonBlank(request.reviewer(), "主管"); + return reviewer + ":" + maskSensitiveText(request.advice()); + } + + private Integer incentivePoint(ReviewAdviceRequest request) { + if (request == null || request.incentivePoint() == null) { + return null; + } + return clampScore(request.incentivePoint()); + } + + private String reviewExtPartyId(Long id) { + List rows = jdbcTemplate.query(""" + SELECT ext_party_id + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile' AND id = ? + LIMIT 1 + """, (rs, rowNum) -> rs.getString("ext_party_id"), TENANT_ID, id); + return rows.isEmpty() ? "" : rows.get(0); } public CompetencyResponse competency(String extPartyId) { @@ -233,7 +664,8 @@ public class AihrPracticeSeedService { 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 + COALESCE(ROUND(AVG(dim_emotion)), 82) emotion, + COALESCE(SUM(incentive_point), 0) incentive_points FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile' AND (? = '' OR ext_party_id = ?) """, (rs, rowNum) -> new CompetencySnapshot( @@ -242,21 +674,304 @@ public class AihrPracticeSeedService { rs.getInt("pending_review"), rs.getInt("compliance"), rs.getInt("communication"), - rs.getInt("emotion") + rs.getInt("emotion"), + rs.getInt("incentive_points") ), TENANT_ID, party, party); - CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 82, 0, 82, 82, 82) : rows.get(0); + CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 82, 0, 82, 82, 82, 0) : rows.get(0); + int trainingMinutes = snapshot.completed() * 8; + int contribution = Math.min(100, snapshot.incentivePoints() + snapshot.completed() * 8 + Math.max(0, 100 - snapshot.pendingReview() * 10) / 5); + int assessmentScore = snapshot.score(); + String aiLevel = aiLevel(snapshot.completed(), assessmentScore); return new CompetencyResponse( snapshot.score(), snapshot.completed(), snapshot.pendingReview(), + trainingMinutes, + contribution, + assessmentScore, + aiLevel, List.of( new DimensionResponse("合规", snapshot.compliance(), "SOP关键点覆盖"), new DimensionResponse("沟通", snapshot.communication(), "承诺与表达清晰度"), new DimensionResponse("情绪", snapshot.emotion(), "安抚与承接能力") - ) + ), + growthPath(snapshot.completed(), assessmentScore) ); } + public PromotionEvidenceResponse promotionEvidence(String extPartyId) { + String party = isBlank(extPartyId) ? "" : extPartyId.trim(); + CompetencyResponse profile = competency(party); + List latestRecords = mobileHistory(party, 5); + List assignmentRows = assignments(party, 5); + List evidenceItems = new ArrayList<>(); + evidenceItems.add("累计完成 " + profile.completed() + " 次移动端AI陪练,训练时长约 " + profile.trainingMinutes() + " 分钟"); + evidenceItems.add("AI测评分 " + profile.assessmentScore() + ",当前等级 " + profile.aiLevel()); + evidenceItems.add("训练贡献度 " + profile.contribution() + ",待主管复盘 " + profile.pendingReview() + " 条"); + if (!latestRecords.isEmpty()) { + RecordResponse latest = latestRecords.get(0); + evidenceItems.add("最近一次场景:" + latest.scene() + ",得分 " + latest.score() + ",状态 " + latest.status()); + } + return new PromotionEvidenceResponse( + party, + profile.aiLevel(), + profile.assessmentScore(), + profile.trainingMinutes(), + profile.contribution(), + profile.completed(), + profile.pendingReview(), + profile.dimensions(), + latestRecords, + assignmentRows, + evidenceItems + ); + } + + private String aiLevel(int completed, int score) { + if (completed >= 20 && score >= 90) { + return "高级"; + } + if (completed >= 10 && score >= 85) { + return "中级"; + } + return "初级"; + } + + private List growthPath(int completed, int score) { + return List.of( + new GrowthStageResponse("初级", Math.min(100, Math.max(30, completed * 10)), completed >= 3 ? "已达成" : "进行中", "完成3次基础场景并保持80分以上"), + new GrowthStageResponse("中级", Math.min(100, Math.max(0, completed * 6 + Math.max(0, score - 75))), completed >= 10 && score >= 85 ? "已达成" : "待积累", "完成10次训练且测评分达到85"), + new GrowthStageResponse("高级", Math.min(100, Math.max(0, completed * 4 + Math.max(0, score - 80))), completed >= 20 && score >= 90 ? "已达成" : "待积累", "完成20次训练、主管复盘稳定通过") + ); + } + + public List alerts() { + ensurePracticeTable(); + int pending = countPendingReview(""); + int lowScore = count(""" + SELECT COUNT(*) + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile' AND total_score < 80 + """, TENANT_ID); + List result = new ArrayList<>(); + if (pending > 0) { + result.add(new PracticeAlertResponse("pending_review", "待复盘积压", pending + " 条对练等待主管复盘", "medium", "")); + } + if (lowScore > 0) { + result.add(new PracticeAlertResponse("low_score", "低分训练预警", lowScore + " 条训练低于 80 分,建议派发定向再练", "high", "")); + } + if (result.isEmpty()) { + result.add(new PracticeAlertResponse("stable", "暂无高风险预警", "团队训练记录稳定,可继续按今日安排推进", "low", "")); + } + return result; + } + + public List assignments(String extPartyId, int limit) { + ensureAssignmentTable(); + String party = isBlank(extPartyId) ? "" : extPartyId.trim(); + ensureDailyDrills(party); + return jdbcTemplate.query(""" + SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, + answer_text, feedback, correct + FROM aihr_practice_assignment + WHERE tenant_id = ? + AND (? = '' OR ext_party_id = ?) + AND (status = '待训练' OR (source = 'daily' AND DATE(create_time) = CURRENT_DATE())) + ORDER BY create_time DESC, id DESC + LIMIT ? + """, this::mapAssignment, TENANT_ID, party, party, normalizeLimit(limit)); + } + + private void ensureDailyDrills(String extPartyId) { + if (isBlank(extPartyId) || countMobileRecords(extPartyId) >= 3) { + return; + } + int existingToday = count(""" + SELECT COUNT(*) + FROM aihr_practice_assignment + WHERE tenant_id = ? AND ext_party_id = ? AND source = 'daily' AND DATE(create_time) = CURRENT_DATE() + """, TENANT_ID, extPartyId); + if (existingToday >= 3) { + return; + } + List drills = List.of( + new DailyDrillSeed("complaint-water", "每日三题 1:业主投诉车库积水,第一句话先说什么?", "先承接情绪,再确认点位、责任人和首次反馈时间。"), + new DailyDrillSeed("fee-collection", "每日三题 2:业主拒缴物业费并质疑服务,如何拆分问题?", "把服务整改和费用沟通分开,说明复核、整改跟进和缴费节点。"), + new DailyDrillSeed("repair-leak", "每日三题 3:报修跟进超时,如何给下一步承诺?", "说明已核实的信息、责任人、到场或反馈时间,并主动同步变化。") + ); + int start = Math.max(0, existingToday); + for (int i = start; i < drills.size(); i++) { + DailyDrillSeed drill = drills.get(i); + ScenarioSeed scenario = resolveScenario(drill.scenarioId(), null); + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update(""" + INSERT INTO aihr_practice_assignment + (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time) + VALUES (?, ?, ?, ?, ?, 'daily', ?, '待训练', ?, ?) + """, + TENANT_ID, + extPartyId, + extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId, + scenario.id(), + scenario.name(), + drill.question() + " 参考答案:" + drill.referenceAnswer(), + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ); + } + } + + public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request) { + ensureAssignmentTable(); + LocalDateTime now = LocalDateTime.now(); + String scenarioId = firstNonBlank(request == null ? null : request.scenarioId(), "fee-parking"); + ScenarioSeed scenario = resolveScenario(scenarioId, null); + String extPartyId = firstNonBlank(request == null ? null : request.extPartyId(), "mobile-user"); + String traineeName = firstNonBlank(request == null ? null : request.traineeName(), extPartyId); + String source = firstNonBlank(request == null ? null : request.source(), "manual"); + String reason = firstNonBlank(request == null ? null : request.reason(), "主管派发专项训练"); + jdbcTemplate.update(""" + INSERT INTO aihr_practice_assignment + (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?) + """, TENANT_ID, extPartyId, traineeName, scenario.id(), firstNonBlank(request == null ? null : request.scenarioName(), scenario.name()), source, reason, Timestamp.valueOf(now), Timestamp.valueOf(now)); + List rows = assignments(extPartyId, 1); + return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null) : rows.get(0); + } + + public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) { + return answerDailyDrill(id, request, ""); + } + + public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request, String extPartyId) { + ensureAssignmentTable(); + if (id == null) { + return null; + } + String party = isBlank(extPartyId) ? "" : extPartyId.trim(); + List> rows = jdbcTemplate.queryForList(""" + SELECT id, reason, source, status + FROM aihr_practice_assignment + WHERE tenant_id = ? AND id = ? AND source = 'daily' + AND (? = '' OR ext_party_id = ?) + LIMIT 1 + """, TENANT_ID, id, party, party); + if (rows.isEmpty()) { + return null; + } + DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason")); + String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), "")); + DailyDrillScore score = scoreDailyDrill(answer, parts.referenceAnswer()); + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update(""" + UPDATE aihr_practice_assignment + SET status = '已完成', + answer_text = ?, + feedback = ?, + correct = ?, + finished_time = ?, + update_time = ? + WHERE tenant_id = ? AND id = ? AND source = 'daily' + """, + answer, + score.feedback(), + score.correct() ? 1 : 0, + Timestamp.valueOf(now), + Timestamp.valueOf(now), + TENANT_ID, + id + ); + return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer()); + } + + public List promptTemplates(String category, String code, Boolean enabled) { + ensurePromptTemplateTable(); + String safeCategory = isBlank(category) ? "" : category.trim(); + String safeCode = isBlank(code) ? "" : code.trim(); + int enabledFilter = enabled == null ? -1 : enabled ? 1 : 0; + return jdbcTemplate.query(""" + SELECT id, code, name, category, output_format, system_prompt, template, variables, version, enabled, description, update_time + FROM aihr_prompt_template + WHERE tenant_id = ? + AND (? = '' OR category = ?) + AND (? = '' OR code = ?) + AND (? = -1 OR enabled = ?) + ORDER BY category ASC, code ASC, version DESC, id DESC + """, this::mapPromptTemplate, TENANT_ID, safeCategory, safeCategory, safeCode, safeCode, enabledFilter, enabledFilter); + } + + public PromptTemplateResponse savePromptTemplate(PromptTemplateRequest request) { + ensurePromptTemplateTable(); + LocalDateTime now = LocalDateTime.now(); + String code = normalizeScenarioCode(firstNonBlank(request == null ? null : request.code(), "template-" + System.currentTimeMillis())); + String outputFormat = firstNonBlank(request == null ? null : request.outputFormat(), "json"); + String version = firstNonBlank(request == null ? null : request.version(), "v1"); + jdbcTemplate.update(""" + INSERT INTO aihr_prompt_template + (tenant_id, code, name, category, output_format, system_prompt, template, variables, version, enabled, description, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + category = VALUES(category), + system_prompt = VALUES(system_prompt), + template = VALUES(template), + variables = VALUES(variables), + enabled = VALUES(enabled), + description = VALUES(description), + update_time = VALUES(update_time) + """, + TENANT_ID, + code, + firstNonBlank(request == null ? null : request.name(), code), + firstNonBlank(request == null ? null : request.category(), "practice"), + outputFormat, + request == null ? "" : request.systemPrompt(), + firstNonBlank(request == null ? null : request.template(), "请根据输入生成结果。"), + request == null ? "" : request.variables(), + version, + request == null || request.enabled() == null || request.enabled(), + request == null ? "" : request.description(), + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ); + return findPromptTemplate(code, outputFormat, version); + } + + public PromptTemplateResponse updatePromptTemplate(Long id, PromptTemplateRequest request) { + ensurePromptTemplateTable(); + if (id == null) { + return savePromptTemplate(request); + } + List existing = promptTemplateById(id); + if (existing.isEmpty()) { + return null; + } + PromptTemplateResponse current = existing.get(0); + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update(""" + UPDATE aihr_prompt_template + SET code = ?, name = ?, category = ?, output_format = ?, system_prompt = ?, template = ?, + variables = ?, version = ?, enabled = ?, description = ?, update_time = ? + WHERE tenant_id = ? AND id = ? + """, + normalizeScenarioCode(firstNonBlank(request == null ? null : request.code(), current.code())), + firstNonBlank(request == null ? null : request.name(), current.name()), + firstNonBlank(request == null ? null : request.category(), current.category()), + firstNonBlank(request == null ? null : request.outputFormat(), current.outputFormat()), + request == null || request.systemPrompt() == null ? current.systemPrompt() : request.systemPrompt(), + firstNonBlank(request == null ? null : request.template(), current.template()), + request == null || request.variables() == null ? current.variables() : request.variables(), + firstNonBlank(request == null ? null : request.version(), current.version()), + request == null || request.enabled() == null ? current.enabled() : request.enabled(), + request == null || request.description() == null ? current.description() : request.description(), + Timestamp.valueOf(now), + TENANT_ID, + id + ); + List updated = promptTemplateById(id); + return updated.isEmpty() ? null : updated.get(0); + } + private boolean isMobile(StartRequest request) { return request != null && "mobile".equalsIgnoreCase(request.mode()); } @@ -289,12 +1004,13 @@ public class AihrPracticeSeedService { LocalDateTime started = activeSession == null ? now : activeSession.startedAt(); String id = isBlank(sessionId) ? "seed-" + scenario.id() + "-" + System.currentTimeMillis() : sessionId; boolean mobile = activeSession != null && activeSession.mobile(); + String annotationsJson = annotationsJson(activeSession, scenario); 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + mentor_rewrite, ai_comment, summary, dialogue_json, annotations_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), @@ -305,6 +1021,7 @@ public class AihrPracticeSeedService { ai_comment = VALUES(ai_comment), summary = VALUES(summary), dialogue_json = VALUES(dialogue_json), + annotations_json = COALESCE(aihr_practice_session.annotations_json, VALUES(annotations_json)), trust_curve = VALUES(trust_curve), status = VALUES(status), finished_time = VALUES(finished_time), @@ -326,6 +1043,7 @@ public class AihrPracticeSeedService { result.summary(), record.summary(), dialogueJson(activeSession, scenario), + annotationsJson, scenario.trustStart() + "," + scenario.rounds().get(0).trust() + "," + scenario.trustEnd(), record.status(), Timestamp.valueOf(started), @@ -333,6 +1051,39 @@ public class AihrPracticeSeedService { Timestamp.valueOf(now), Timestamp.valueOf(now) ); + if (mobile && result.total() < 80) { + createRetryAssignment(activeSession, scenario, record, result); + } + replaceAnnotationRows(id, storedAnnotations(id, annotationsJson, scenario)); + replaceAudioRows(id, activeSession); + } + + private void createRetryAssignment(ActiveSession activeSession, ScenarioSeed scenario, RecordResponse record, PracticeResult result) { + ensureAssignmentTable(); + String extPartyId = activeSession == null ? record.trainee() : activeSession.extPartyId(); + int exists = count(""" + SELECT COUNT(*) + FROM aihr_practice_assignment + WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = 'retry' AND status = '待训练' + """, TENANT_ID, extPartyId, scenario.id()); + if (exists > 0) { + return; + } + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update(""" + INSERT INTO aihr_practice_assignment + (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time) + VALUES (?, ?, ?, ?, ?, 'retry', ?, '待训练', ?, ?) + """, + TENANT_ID, + extPartyId, + record.trainee(), + scenario.id(), + scenario.name(), + "本场得分 " + result.total() + ",自动生成错题重练", + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ); } private int countMobileRecords(String extPartyId) { @@ -372,6 +1123,55 @@ public class AihrPracticeSeedService { return value == null ? 0 : value; } + private CalibrationStats calibrationStats() { + Map latestBySession = new LinkedHashMap<>(); + jdbcTemplate.queryForList(""" + SELECT id, session_id, original_score, corrected_score + FROM aihr_practice_calibration + WHERE tenant_id = ? AND original_score IS NOT NULL AND corrected_score IS NOT NULL + ORDER BY id DESC + """, TENANT_ID).forEach(row -> { + String sessionId = String.valueOf(row.get("session_id")); + latestBySession.putIfAbsent(sessionId, new CalibrationPair( + ((Number) row.get("original_score")).intValue(), + ((Number) row.get("corrected_score")).intValue() + )); + }); + long matched = latestBySession.values().stream() + .filter(pair -> scoreBand(pair.original()) == scoreBand(pair.corrected())) + .count(); + return new CalibrationStats(latestBySession.size(), (int) matched); + } + + private static int scoreBand(int score) { + if (score < 60) { + return 0; + } + if (score < 75) { + return 1; + } + return score < 90 ? 2 : 3; + } + + private static String percent(int part, int total) { + return total <= 0 ? "0.0%" : String.format(java.util.Locale.ROOT, "%.1f%%", part * 100.0 / total); + } + + private static void appendCsvRow(StringBuilder csv, Object... values) { + for (int i = 0; i < values.length; i++) { + if (i > 0) { + csv.append(','); + } + csv.append(csvCell(values[i])); + } + csv.append('\n'); + } + + private static String csvCell(Object value) { + String text = value == null ? "" : String.valueOf(value); + return "\"" + text.replace("\"", "\"\"") + "\""; + } + private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException { Timestamp finishedTime = rs.getTimestamp("finished_time"); String time = finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMATTER); @@ -387,10 +1187,149 @@ public class AihrPracticeSeedService { ); } + private PracticeAssignmentResponse mapAssignment(ResultSet rs, int rowNum) throws SQLException { + Timestamp createTime = rs.getTimestamp("create_time"); + String source = rs.getString("source"); + String reason = rs.getString("reason"); + DailyDrillParts daily = dailyDrillParts(reason); + Object correctValue = rs.getObject("correct"); + return new PracticeAssignmentResponse( + rs.getLong("id"), + rs.getString("ext_party_id"), + rs.getString("trainee_name"), + rs.getString("scenario_id"), + rs.getString("scenario_name"), + source, + reason, + rs.getString("status"), + createTime == null ? "" : createTime.toLocalDateTime().format(TIME_FORMATTER), + "daily".equals(source) ? daily.question() : null, + "daily".equals(source) ? daily.referenceAnswer() : null, + rs.getString("answer_text"), + rs.getString("feedback"), + correctValue == null ? null : rs.getBoolean("correct") + ); + } + + private DailyDrillParts dailyDrillParts(String reason) { + String safeReason = firstNonBlank(reason, ""); + String marker = "参考答案:"; + int markerIndex = safeReason.indexOf(marker); + if (markerIndex < 0) { + return new DailyDrillParts(safeReason, ""); + } + String question = safeReason.substring(0, markerIndex).trim(); + String reference = safeReason.substring(markerIndex + marker.length()).trim(); + return new DailyDrillParts(question, reference); + } + + private DailyDrillScore scoreDailyDrill(String answer, String reference) { + if (isBlank(answer)) { + return new DailyDrillScore(false, 0, "请先写出你的处理话术,再对照参考答案补齐关键动作。"); + } + List required = dailyDrillKeywords(reference); + List hit = new ArrayList<>(); + List missing = new ArrayList<>(); + String normalizedAnswer = answer.replaceAll("\\s+", ""); + for (String keyword : required) { + if (normalizedAnswer.contains(keyword)) { + hit.add(keyword); + } else { + missing.add(keyword); + } + } + int score = required.isEmpty() ? 70 : Math.min(100, 40 + hit.size() * 15); + if (answer.length() < 8) { + score = Math.min(score, 50); + } + boolean correct = score >= 70; + String feedback = (correct ? "回答方向正确。" : "还缺关键动作。") + + (hit.isEmpty() ? "" : "已命中:" + String.join("、", hit) + "。") + + (missing.isEmpty() ? "" : "建议补充:" + String.join("、", missing) + "。") + + (isBlank(reference) ? "" : "参考答案:" + reference); + return new DailyDrillScore(correct, score, feedback); + } + + private List dailyDrillKeywords(String reference) { + List keywords = List.of("承接情绪", "确认", "点位", "责任人", "反馈时间", "服务整改", "费用沟通", "节点", "核实信息", "到场", "同步变化"); + List result = new ArrayList<>(); + String safeReference = firstNonBlank(reference, ""); + for (String keyword : keywords) { + if (safeReference.contains(keyword)) { + result.add(keyword); + } + } + if (result.isEmpty() && !isBlank(safeReference)) { + result.add(safeReference.length() > 12 ? safeReference.substring(0, 12) : safeReference); + } + return result; + } + + private PromptTemplateResponse mapPromptTemplate(ResultSet rs, int rowNum) throws SQLException { + Timestamp updateTime = rs.getTimestamp("update_time"); + return new PromptTemplateResponse( + rs.getLong("id"), + rs.getString("code"), + rs.getString("name"), + rs.getString("category"), + rs.getString("output_format"), + rs.getString("system_prompt"), + rs.getString("template"), + rs.getString("variables"), + rs.getString("version"), + rs.getBoolean("enabled"), + rs.getString("description"), + updateTime == null ? "" : updateTime.toLocalDateTime().format(TIME_FORMATTER) + ); + } + + private PromptTemplateResponse findPromptTemplate(String code, String outputFormat, String version) { + List rows = jdbcTemplate.query(""" + SELECT id, code, name, category, output_format, system_prompt, template, variables, version, enabled, description, update_time + FROM aihr_prompt_template + WHERE tenant_id = ? AND code = ? AND output_format = ? AND version = ? + LIMIT 1 + """, this::mapPromptTemplate, TENANT_ID, code, outputFormat, version); + return rows.isEmpty() ? null : rows.get(0); + } + + private List promptTemplateById(Long id) { + return jdbcTemplate.query(""" + SELECT id, code, name, category, output_format, system_prompt, template, variables, version, enabled, description, update_time + FROM aihr_prompt_template + WHERE tenant_id = ? AND id = ? + LIMIT 1 + """, this::mapPromptTemplate, TENANT_ID, id); + } + + private CalibrationResponse latestCalibration(String sessionId) { + List rows = jdbcTemplate.query(""" + SELECT id, session_id, original_score, corrected_score, reviewer, reason, status, create_time + FROM aihr_practice_calibration + WHERE tenant_id = ? AND session_id = ? + ORDER BY id DESC + LIMIT 1 + """, (rs, rowNum) -> { + Timestamp createTime = rs.getTimestamp("create_time"); + return new CalibrationResponse( + rs.getLong("id"), + rs.getString("session_id"), + rs.getInt("original_score"), + rs.getInt("corrected_score"), + rs.getString("reviewer"), + rs.getString("reason"), + rs.getString("status"), + createTime == null ? "" : createTime.toLocalDateTime().format(TIME_FORMATTER) + ); + }, TENANT_ID, sessionId); + return rows.isEmpty() ? null : rows.get(0); + } + 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")); + List dialogue = readDialogue(rs.getString("dialogue_json"), scenario); return new ReviewDetailResponse( rs.getLong("id"), rs.getString("session_id"), @@ -402,8 +1341,11 @@ public class AihrPracticeSeedService { rs.getString("summary"), rs.getString("mentor_rewrite"), rs.getString("ai_comment"), + rs.getString("review_advice"), + rs.getObject("incentive_point") == null ? null : rs.getInt("incentive_point"), scoreItems(rs), - readDialogue(rs.getString("dialogue_json"), scenario) + dialogue, + readAnnotations(rs.getString("annotations_json"), dialogue, scenario) ); } @@ -422,6 +1364,121 @@ public class AihrPracticeSeedService { } } + private ScenarioResponse mapScenarioResponse(ResultSet rs, int rowNum) throws SQLException { + String code = rs.getString("scenario_code"); + return new ScenarioResponse( + code, + rs.getString("scenario_name"), + rs.getString("position"), + rs.getString("scenario_type"), + rs.getString("project_type"), + rs.getInt("difficulty"), + rs.getString("customer_persona"), + rs.getString("opening_text"), + rs.getString("explicit_need"), + rs.getString("hidden_need"), + rs.getString("irritants"), + rs.getString("delights"), + rs.getString("sop_refs"), + rs.getString("success_criteria"), + rs.getString("strategy"), + rs.getString("goal"), + rs.getBoolean("enabled"), + rubricFor(code).orElse(defaultRubric(code)) + ); + } + + private RubricResponse mapRubric(ResultSet rs, int rowNum) throws SQLException { + String rubricCode = rs.getString("rubric_code"); + return new RubricResponse( + rubricCode, + rs.getString("rubric_name"), + rs.getString("version"), + rubricDimensions(rubricCode) + ); + } + + private Optional rubricFor(String scenarioCode) { + if (isBlank(scenarioCode)) { + return Optional.empty(); + } + try { + List rows = jdbcTemplate.query(""" + SELECT rubric_code, rubric_name, version + FROM aihr_practice_rubric + WHERE tenant_id = ? AND scenario_code = ? AND enabled = 1 + ORDER BY id ASC + LIMIT 1 + """, this::mapRubric, TENANT_ID, scenarioCode); + return rows.stream().findFirst(); + } catch (DataAccessException e) { + return Optional.empty(); + } + } + + private List rubricDimensions(String rubricCode) { + if (isBlank(rubricCode)) { + return defaultRubricDimensions(false); + } + try { + List rows = jdbcTemplate.query(""" + SELECT dimension_code, dimension_name, weight, description + FROM aihr_practice_rubric_dimension + WHERE tenant_id = ? AND rubric_code = ? + ORDER BY sort_order ASC, id ASC + """, (rs, rowNum) -> new RubricDimensionResponse( + rs.getString("dimension_code"), + rs.getString("dimension_name"), + rs.getDouble("weight"), + rs.getString("description") + ), TENANT_ID, rubricCode); + return rows.isEmpty() ? defaultRubricDimensions(false) : rows; + } catch (DataAccessException e) { + return defaultRubricDimensions(false); + } + } + + private List fallbackScenarios() { + return scenarios.values().stream().map(this::fallbackScenario).toList(); + } + + private ScenarioResponse fallbackScenario(ScenarioSeed seed) { + return new ScenarioResponse( + seed.id(), + seed.name(), + "生活顾问", + seed.id().contains("fee") ? "催费/停车费" : "投诉处理", + seed.project(), + 1, + seed.customer(), + seed.rounds().isEmpty() ? "" : seed.rounds().get(0).customer(), + seed.goal(), + "希望物业持续跟进", + "被敷衍、先讲规定、无反馈节点", + "先共情、给责任人、给反馈时间", + "seed fallback", + seed.goal(), + seed.strategy(), + seed.goal(), + true, + defaultRubric(seed.id()) + ); + } + + private RubricResponse defaultRubric(String scenarioCode) { + boolean feeScenario = !isBlank(scenarioCode) && scenarioCode.contains("fee"); + return new RubricResponse("rubric-" + scenarioCode, "生活顾问试点 Rubric", "seed", defaultRubricDimensions(feeScenario)); + } + + private List defaultRubricDimensions(boolean feeScenario) { + return List.of( + new RubricDimensionResponse("compliance", "合规", feeScenario ? 0.30 : 0.35, "SOP关键点、权限边界、承诺口径"), + new RubricDimensionResponse("communication", "沟通", 0.25, "诉求确认、信息结构、反馈节点"), + new RubricDimensionResponse("emotion", "情绪", 0.25, "共情承接、降温、信任恢复"), + new RubricDimensionResponse("marketing", "营销", feeScenario ? 0.20 : 0.15, "收费季转化、政策解释、办理引导") + ); + } + private void rememberTraineeReply(TurnRequest request, int roundIndex) { if (request == null || isBlank(request.sessionId()) || isBlank(request.traineeText())) { return; @@ -437,6 +1494,20 @@ public class AihrPracticeSeedService { } replies.set(roundIndex, request.traineeText().trim()); } + List audioUrls = session.traineeAudioUrls(); + synchronized (audioUrls) { + while (audioUrls.size() <= roundIndex) { + audioUrls.add(""); + } + audioUrls.set(roundIndex, firstNonBlank(request.traineeAudioUrl(), "")); + } + List audioOssIds = session.traineeAudioOssIds(); + synchronized (audioOssIds) { + while (audioOssIds.size() <= roundIndex) { + audioOssIds.add(null); + } + audioOssIds.set(roundIndex, request.traineeAudioOssId()); + } } private void rememberCustomerLine(ActiveSession session, int roundIndex, String customerText) { @@ -452,14 +1523,230 @@ public class AihrPracticeSeedService { } } + private void rememberTurnEvidence(ActiveSession session, int roundIndex, Integer emotion, Integer trust, boolean redFlag, String coachHint) { + if (session == null) { + return; + } + List evidence = session.turnEvidence(); + synchronized (evidence) { + while (evidence.size() <= roundIndex) { + evidence.add(null); + } + evidence.set(roundIndex, new TurnEvidence(emotion, trust, redFlag, firstNonBlank(coachHint, ""))); + } + } + private String dialogueJson(ActiveSession activeSession, ScenarioSeed scenario) { try { - return objectMapper.writeValueAsString(dialogueTurns(activeSession, scenario)); + return objectMapper.writeValueAsString(maskDialogue(dialogueTurns(activeSession, scenario))); } catch (JsonProcessingException ignored) { return "[]"; } } + private String annotationsJson(ActiveSession activeSession, ScenarioSeed scenario) { + try { + return objectMapper.writeValueAsString(generateAnnotations(maskDialogue(dialogueTurns(activeSession, scenario)), scenario)); + } catch (JsonProcessingException ignored) { + return "[]"; + } + } + + private List readAnnotations(String json, List dialogue, 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 AnnotationResponse( + item.path("turnIndex").asInt(result.size() + 1), + item.path("sourceText").asText(), + item.path("issue").asText(), + item.path("rewrite").asText(), + item.path("sopPoint").asText() + )); + } + if (!result.isEmpty()) { + return result; + } + } + } catch (JsonProcessingException ignored) { + // Fall through to generated annotations. + } + } + return generateAnnotations(dialogue, scenario); + } + + private List generateAnnotations(List dialogue, ScenarioSeed scenario) { + List result = new ArrayList<>(); + for (DialogueResponse turn : dialogue) { + if (!"trainee".equals(turn.role()) || isBlank(turn.text())) { + continue; + } + String text = turn.text().trim(); + result.add(new AnnotationResponse( + result.size() + 1, + text, + annotationIssue(text), + annotationRewrite(), + firstNonBlank(scenario.goal(), scenario.strategy()) + )); + } + if (result.isEmpty()) { + result.add(new AnnotationResponse( + 1, + "本场暂无员工文本回复", + "未形成可标注话术", + annotationRewrite(), + firstNonBlank(scenario.goal(), scenario.strategy()) + )); + } + return result; + } + + private List sanitizeAnnotations(List annotations, ScenarioSeed scenario) { + List result = new ArrayList<>(); + for (AnnotationResponse annotation : annotations) { + if (annotation == null) { + continue; + } + result.add(new AnnotationResponse( + annotation.turnIndex() == null ? result.size() + 1 : annotation.turnIndex(), + maskSensitiveText(firstNonBlank(annotation.sourceText(), "本轮话术未填写")), + firstNonBlank(annotation.issue(), "主管补充标注"), + firstNonBlank(annotation.rewrite(), annotationRewrite()), + firstNonBlank(annotation.sopPoint(), firstNonBlank(scenario.goal(), scenario.strategy())) + )); + } + return result.isEmpty() ? generateAnnotations(List.of(), scenario) : result; + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException ignored) { + return "[]"; + } + } + + private List storedAnnotations(String sessionId, String fallbackJson, ScenarioSeed scenario) { + List rows = jdbcTemplate.query(""" + SELECT scenario_id, dialogue_json, annotations_json + FROM aihr_practice_session + WHERE tenant_id = ? AND session_id = ? + LIMIT 1 + """, (rs, rowNum) -> new SessionAnnotationSource( + rs.getString("scenario_id"), + rs.getString("dialogue_json"), + rs.getString("annotations_json") + ), TENANT_ID, sessionId); + if (rows.isEmpty()) { + return readAnnotations(fallbackJson, List.of(), scenario); + } + SessionAnnotationSource source = rows.get(0); + ScenarioSeed storedScenario = resolveScenario(source.scenarioId(), sessionId); + List dialogue = readDialogue(source.dialogueJson(), storedScenario); + return readAnnotations(firstNonBlank(source.annotationsJson(), fallbackJson), dialogue, storedScenario); + } + + private void replaceAnnotationRows(String sessionId, List annotations) { + if (isBlank(sessionId)) { + return; + } + ensureAnnotationTable(); + String id = sessionId.trim(); + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update("DELETE FROM aihr_practice_annotation WHERE tenant_id = ? AND session_id = ?", TENANT_ID, id); + for (AnnotationResponse annotation : annotations) { + if (annotation == null) { + continue; + } + jdbcTemplate.update(""" + INSERT INTO aihr_practice_annotation + (tenant_id, session_id, turn_index, source_text, issue_text, rewrite_text, sop_point, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + TENANT_ID, + id, + annotation.turnIndex(), + annotation.sourceText(), + annotation.issue(), + annotation.rewrite(), + annotation.sopPoint(), + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ); + } + } + + private void replaceAudioRows(String sessionId, ActiveSession session) { + if (isBlank(sessionId) || session == null) { + return; + } + List urls = snapshot(session.traineeAudioUrls()); + if (urls.stream().allMatch(this::isBlank)) { + return; + } + List ossIds = snapshotLong(session.traineeAudioOssIds()); + ensureAudioTable(); + String id = sessionId.trim(); + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update("DELETE FROM aihr_practice_audio WHERE tenant_id = ? AND session_id = ?", TENANT_ID, id); + for (int i = 0; i < urls.size(); i++) { + String url = urls.get(i); + if (isBlank(url)) { + continue; + } + jdbcTemplate.update(""" + INSERT INTO aihr_practice_audio + (tenant_id, session_id, turn_index, role, oss_id, audio_url, create_time, update_time) + VALUES (?, ?, ?, 'trainee', ?, ?, ?, ?) + """, + TENANT_ID, + id, + i + 1, + i < ossIds.size() ? ossIds.get(i) : null, + url.trim(), + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ); + } + } + + private String calibrationDimensionsJson(ScoreSnapshot score, CalibrationRequest request) { + Map data = new LinkedHashMap<>(); + data.put("original", Map.of( + "total", score.total(), + "compliance", score.compliance(), + "emotion", score.emotion(), + "communication", score.communication(), + "marketing", score.marketing() + )); + Map corrected = new LinkedHashMap<>(); + corrected.put("total", request == null || request.correctedTotal() == null ? score.total() : clampScore(request.correctedTotal())); + corrected.put("compliance", request == null || request.correctedCompliance() == null ? score.compliance() : clampScore(request.correctedCompliance())); + corrected.put("emotion", request == null || request.correctedEmotion() == null ? score.emotion() : clampScore(request.correctedEmotion())); + corrected.put("communication", request == null || request.correctedCommunication() == null ? score.communication() : clampScore(request.correctedCommunication())); + corrected.put("marketing", request == null || request.correctedMarketing() == null ? score.marketing() : clampScore(request.correctedMarketing())); + data.put("corrected", corrected); + return writeJson(data); + } + + private String annotationIssue(String text) { + if (hasRedFlag(text)) { + return "命中红线,存在推责、绝对承诺或情绪对抗风险"; + } + if (!text.contains("责任") && !text.contains("反馈") && !text.contains("跟进") && !text.contains("时间")) { + return "缺少责任人、处理节点或回访承诺"; + } + return "表达基本可用,可继续补充SOP依据和复盘节点"; + } + + private String annotationRewrite() { + return "建议改写:我先承接您的诉求,再按SOP确认责任人、处理节点和首次反馈时间;如节点变化,会主动同步进展。"; + } + private List readDialogue(String json, ScenarioSeed scenario) { if (!isBlank(json)) { try { @@ -470,7 +1757,12 @@ public class AihrPracticeSeedService { result.add(new DialogueResponse( item.path("role").asText(), item.path("label").asText(), - item.path("text").asText() + item.path("text").asText(), + item.path("audioUrl").asText(""), + item.hasNonNull("emotion") ? item.path("emotion").asInt() : null, + item.hasNonNull("trust") ? item.path("trust").asInt() : null, + item.hasNonNull("redFlag") ? item.path("redFlag").asBoolean() : null, + item.path("coachHint").asText("") )); } if (!result.isEmpty()) { @@ -487,15 +1779,37 @@ public class AihrPracticeSeedService { private List dialogueTurns(ActiveSession activeSession, ScenarioSeed scenario) { List turns = new ArrayList<>(); List replies = snapshot(activeSession == null ? null : activeSession.traineeReplies()); + List audioUrls = snapshot(activeSession == null ? null : activeSession.traineeAudioUrls()); List customerLines = snapshot(activeSession == null ? null : activeSession.customerLines()); + List evidenceList = snapshotEvidence(activeSession == null ? null : activeSession.turnEvidence()); for (int i = 0; i < scenario.rounds().size(); i++) { RoundSeed round = scenario.rounds().get(i); + TurnEvidence evidence = i < evidenceList.size() ? evidenceList.get(i) : null; String customerLine = i < customerLines.size() && !isBlank(customerLines.get(i)) ? customerLines.get(i) : round.customer(); turns.add(new DialogueResponse("customer", "AI业主", customerLine)); if (i < replies.size() && !isBlank(replies.get(i))) { - turns.add(new DialogueResponse("trainee", "员工话术", replies.get(i))); + turns.add(new DialogueResponse( + "trainee", + "员工话术", + replies.get(i), + i < audioUrls.size() ? audioUrls.get(i) : "", + evidence == null ? null : evidence.emotion(), + evidence == null ? null : evidence.trust(), + evidence == null ? null : evidence.redFlag(), + evidence == null ? "" : evidence.coachHint() + )); } - turns.add(new DialogueResponse("coach", "AI教练", round.coachHint())); + String coachHint = evidence == null || isBlank(evidence.coachHint()) ? round.coachHint() : evidence.coachHint(); + turns.add(new DialogueResponse( + "coach", + "AI教练", + coachHint, + "", + evidence == null ? null : evidence.emotion(), + evidence == null ? null : evidence.trust(), + evidence == null ? null : evidence.redFlag(), + coachHint + )); } return turns; } @@ -510,6 +1824,51 @@ public class AihrPracticeSeedService { } } + private static List snapshotLong(List source) { + if (source == null) { + return List.of(); + } + synchronized (source) { + return new ArrayList<>(source); + } + } + + private static List snapshotEvidence(List source) { + if (source == null) { + return List.of(); + } + synchronized (source) { + return new ArrayList<>(source); + } + } + + private List maskDialogue(List dialogue) { + return dialogue.stream() + .map(turn -> new DialogueResponse( + turn.role(), + turn.label(), + maskSensitiveText(turn.text()), + firstNonBlank(turn.audioUrl(), ""), + turn.emotion(), + turn.trust(), + turn.redFlag(), + firstNonBlank(turn.coachHint(), "") + )) + .toList(); + } + + private String maskSensitiveText(String text) { + if (text == null || text.isBlank()) { + return text; + } + String masked = text; + masked = masked.replaceAll("(? scores, String label) { for (DimensionResponse score : scores) { if (label.equals(score.label()) && score.value() instanceof Number) { @@ -552,8 +1911,11 @@ public class AihrPracticeSeedService { `dim_marketing` int DEFAULT NULL COMMENT '营销分', `mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写', `ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评', + `review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议', + `incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分', `summary` varchar(1000) DEFAULT NULL COMMENT '摘要', `dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON', + `annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON', `trust_curve` varchar(100) DEFAULT NULL COMMENT '信任曲线', `status` varchar(30) DEFAULT '待复盘' COMMENT '状态', `started_time` datetime DEFAULT NULL COMMENT '开始时间', @@ -567,16 +1929,225 @@ public class AihrPracticeSeedService { ) 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`"); + ensurePracticeColumn("annotations_json", "ALTER TABLE aihr_practice_session ADD COLUMN `annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON' AFTER `dialogue_json`"); + ensurePracticeColumn("review_advice", "ALTER TABLE aihr_practice_session ADD COLUMN `review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议' AFTER `ai_comment`"); + ensurePracticeColumn("incentive_point", "ALTER TABLE aihr_practice_session ADD COLUMN `incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分' AFTER `review_advice`"); practiceTableReady = true; } } + private void ensureAssignmentTable() { + if (assignmentTableReady) { + return; + } + synchronized (this) { + if (assignmentTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `ext_party_id` varchar(100) NOT NULL COMMENT '员工外部主体或手机号', + `trainee_name` varchar(100) DEFAULT NULL COMMENT '员工姓名', + `scenario_id` varchar(80) NOT NULL COMMENT '场景ID', + `scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称', + `source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual', + `reason` varchar(500) DEFAULT NULL COMMENT '派发原因', + `status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态', + `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答', + `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈', + `correct` tinyint DEFAULT NULL COMMENT '是否答对', + `finished_time` datetime DEFAULT NULL COMMENT '完成时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_assignment_party` (`tenant_id`, `ext_party_id`, `status`, `create_time`), + KEY `idx_aihr_assignment_source` (`tenant_id`, `source`, `status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务'; + """); + ensureColumn("aihr_practice_assignment", "answer_text", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答' AFTER `status`"); + ensureColumn("aihr_practice_assignment", "feedback", "ALTER TABLE aihr_practice_assignment ADD COLUMN `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈' AFTER `answer_text`"); + ensureColumn("aihr_practice_assignment", "correct", "ALTER TABLE aihr_practice_assignment ADD COLUMN `correct` tinyint DEFAULT NULL COMMENT '是否答对' AFTER `feedback`"); + ensureColumn("aihr_practice_assignment", "finished_time", "ALTER TABLE aihr_practice_assignment ADD COLUMN `finished_time` datetime DEFAULT NULL COMMENT '完成时间' AFTER `correct`"); + assignmentTableReady = true; + } + } + + private void ensureScenarioTable() { + if (scenarioTableReady) { + return; + } + synchronized (this) { + if (scenarioTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', + `scenario_name` varchar(100) NOT NULL COMMENT '场景名称', + `position` varchar(80) NOT NULL COMMENT '岗位', + `scenario_type` varchar(50) NOT NULL COMMENT '场景类型', + `project_type` varchar(80) DEFAULT NULL COMMENT '项目类型', + `difficulty` tinyint DEFAULT 1 COMMENT '难度', + `customer_persona` varchar(500) DEFAULT NULL COMMENT '业主人设', + `opening_text` varchar(500) DEFAULT NULL COMMENT '开场白', + `explicit_need` varchar(300) DEFAULT NULL COMMENT '显性诉求', + `hidden_need` varchar(300) DEFAULT NULL COMMENT '隐性诉求', + `irritants` varchar(500) DEFAULT NULL COMMENT '雷点', + `delights` varchar(500) DEFAULT NULL COMMENT '爽点', + `sop_refs` varchar(500) DEFAULT NULL COMMENT 'SOP引用', + `success_criteria` varchar(500) DEFAULT NULL COMMENT '成功条件', + `strategy` varchar(500) DEFAULT NULL COMMENT '训练策略', + `goal` varchar(500) DEFAULT NULL COMMENT '训练目标', + `trainee_name` varchar(100) DEFAULT '张三' COMMENT '默认学员', + `trust_start` int DEFAULT 60 COMMENT '起始信任', + `trust_end` int DEFAULT 80 COMMENT '目标信任', + `rounds_json` text DEFAULT NULL COMMENT '回合seed JSON', + `enabled` tinyint DEFAULT 1 COMMENT '是否启用', + `sort_order` int DEFAULT 0 COMMENT '排序', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_scenario` (`tenant_id`, `scenario_code`), + KEY `idx_aihr_practice_scenario_filter` (`tenant_id`, `position`, `scenario_type`, `enabled`, `sort_order`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练场景'; + """); + scenarioTableReady = true; + } + } + + private void ensureCalibrationTable() { + if (calibrationTableReady) { + return; + } + synchronized (this) { + if (calibrationTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_practice_calibration` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', + `reviewer` varchar(100) DEFAULT NULL COMMENT '校准人', + `original_score` int DEFAULT NULL COMMENT 'AI原始总分', + `corrected_score` int DEFAULT NULL COMMENT '人工校准总分', + `corrected_dimensions_json` text DEFAULT NULL COMMENT '校准维度JSON', + `reason` varchar(500) DEFAULT NULL COMMENT '校准原因', + `status` varchar(30) DEFAULT '已记录' COMMENT '状态', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_calibration_session` (`tenant_id`, `session_id`, `create_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 评分校准记录'; + """); + calibrationTableReady = true; + } + } + + private void ensureAnnotationTable() { + if (annotationTableReady) { + return; + } + synchronized (this) { + if (annotationTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_practice_annotation` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', + `turn_index` int NOT NULL COMMENT '话术序号', + `source_text` varchar(1000) DEFAULT NULL COMMENT '原话', + `issue_text` varchar(1000) DEFAULT NULL COMMENT '问题点', + `rewrite_text` varchar(1000) DEFAULT NULL COMMENT '推荐改写', + `sop_point` varchar(1000) DEFAULT NULL COMMENT '命中或漏掉的SOP点', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_annotation` (`tenant_id`, `session_id`, `turn_index`), + KEY `idx_aihr_practice_annotation_session` (`tenant_id`, `session_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练逐句标注'; + """); + annotationTableReady = true; + } + } + + private void ensureAudioTable() { + if (audioTableReady) { + return; + } + synchronized (this) { + if (audioTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_practice_audio` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', + `turn_index` int NOT NULL COMMENT '话术序号', + `role` varchar(30) NOT NULL COMMENT '音频角色', + `oss_id` bigint DEFAULT NULL COMMENT 'OSS文件ID', + `audio_url` varchar(1000) DEFAULT NULL COMMENT '音频URL', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_audio` (`tenant_id`, `session_id`, `turn_index`, `role`), + KEY `idx_aihr_practice_audio_session` (`tenant_id`, `session_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练音频'; + """); + audioTableReady = true; + } + } + + private void ensurePromptTemplateTable() { + if (promptTemplateTableReady) { + return; + } + synchronized (this) { + if (promptTemplateTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_prompt_template` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `code` varchar(80) NOT NULL COMMENT '模板编码', + `name` varchar(100) NOT NULL COMMENT '模板名称', + `category` varchar(50) NOT NULL COMMENT '业务分类', + `output_format` varchar(50) NOT NULL COMMENT '输出文体', + `system_prompt` text DEFAULT NULL COMMENT '系统提示词', + `template` text NOT NULL COMMENT '用户提示词模板', + `variables` varchar(1000) DEFAULT NULL COMMENT '变量清单', + `version` varchar(30) DEFAULT 'v1' COMMENT '版本', + `enabled` tinyint DEFAULT 1 COMMENT '是否启用', + `description` varchar(500) DEFAULT NULL COMMENT '说明', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_prompt_template` (`tenant_id`, `code`, `output_format`, `version`), + KEY `idx_aihr_prompt_template_enabled` (`tenant_id`, `code`, `enabled`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 提示词模板'; + """); + promptTemplateTableReady = true; + } + } + private void ensurePracticeColumn(String column, String ddl) { + ensureColumn("aihr_practice_session", column, ddl); + } + + private void ensureColumn(String table, 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); + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? + """, Integer.class, table, column); if (exists == null || exists == 0) { jdbcTemplate.execute(ddl); } @@ -586,9 +2157,51 @@ public class AihrPracticeSeedService { return value == null || value.isBlank(); } + private String normalizeScenarioCode(String value) { + String code = isBlank(value) ? "scenario-" + System.currentTimeMillis() : value.trim(); + code = code.replaceAll("[^A-Za-z0-9_-]", "-").replaceAll("-{2,}", "-"); + if (code.length() > 80) { + code = code.substring(0, 80); + } + return code.isBlank() ? "scenario-" + System.currentTimeMillis() : code; + } + + private int clampScore(Integer value) { + if (value == null) { + return 0; + } + return Math.max(0, Math.min(100, value)); + } + + private String firstNonBlank(String first, String second) { + return isBlank(first) ? second : first.trim(); + } + + private List splitOrDefault(String text, List fallback) { + if (isBlank(text)) { + return fallback; + } + List items = new ArrayList<>(); + for (String item : text.split("[、;;,,]")) { + if (!isBlank(item)) { + items.add(item.trim()); + } + if (items.size() >= 3) { + break; + } + } + return items.isEmpty() ? fallback : items; + } + private ScenarioSeed resolveScenario(String scenarioId, String sessionId) { - if (scenarioId != null && scenarios.containsKey(scenarioId)) { - return scenarios.get(scenarioId); + if (!isBlank(scenarioId)) { + Optional dbScenario = dbScenarioSeed(scenarioId); + if (dbScenario.isPresent()) { + return dbScenario.get(); + } + if (scenarios.containsKey(scenarioId)) { + return scenarios.get(scenarioId); + } } if (sessionId != null) { for (Map.Entry entry : scenarios.entrySet()) { @@ -600,6 +2213,93 @@ public class AihrPracticeSeedService { return scenarios.get("complaint-water"); } + private Optional dbScenarioSeed(String scenarioId) { + if (isBlank(scenarioId)) { + return Optional.empty(); + } + try { + List rows = jdbcTemplate.query(""" + SELECT scenario_code, scenario_name, scenario_type, project_type, customer_persona, + opening_text, success_criteria, strategy, goal, trainee_name, trust_start, trust_end, + rounds_json, delights + FROM aihr_practice_scenario + WHERE tenant_id = ? AND scenario_code = ? AND enabled = 1 + LIMIT 1 + """, this::mapScenarioSeed, TENANT_ID, scenarioId.trim()); + return rows.stream().findFirst(); + } catch (DataAccessException e) { + return Optional.empty(); + } + } + + private ScenarioSeed mapScenarioSeed(ResultSet rs, int rowNum) throws SQLException { + String code = rs.getString("scenario_code"); + String name = rs.getString("scenario_name"); + String goal = firstNonBlank(rs.getString("goal"), rs.getString("success_criteria")); + return new ScenarioSeed( + code, + name, + firstNonBlank(rs.getString("trainee_name"), "张三"), + firstNonBlank(rs.getString("customer_persona"), "高情绪、需要明确反馈的业主"), + firstNonBlank(rs.getString("project_type"), rs.getString("scenario_type")), + firstNonBlank(rs.getString("strategy"), "按SOP先承接情绪,再给责任人和反馈节点。"), + firstNonBlank(goal, "完成安抚、确认诉求、明确下一步。"), + intValue(rs, "trust_start", 60), + intValue(rs, "trust_end", 80), + jsonRounds(rs.getString("rounds_json"), rs.getString("opening_text")), + defaultScores(code), + code.contains("fee") ? 82 : 84, + "建议先承接情绪,再说明责任人、处理节点和反馈方式,避免口头承诺未经确认的结果。", + firstNonBlank(rs.getString("delights"), "能承接诉求并给出明确闭环。") + ); + } + + private int intValue(ResultSet rs, String column, int fallback) throws SQLException { + Object value = rs.getObject(column); + return value instanceof Number number ? number.intValue() : fallback; + } + + private List jsonRounds(String roundsJson, String openingText) { + if (!isBlank(roundsJson)) { + try { + JsonNode root = objectMapper.readTree(roundsJson); + if (root.isArray()) { + List rounds = new ArrayList<>(); + for (JsonNode node : root) { + String customer = firstNonBlank(node.path("customer").asText(), openingText); + if (!isBlank(customer)) { + rounds.add(new RoundSeed( + customer, + firstNonBlank(node.path("coachHint").asText(), "补上责任人、反馈时间和SOP依据。"), + node.path("emotion").asInt(70), + node.path("trust").asInt(75) + )); + } + } + if (!rounds.isEmpty()) { + return rounds; + } + } + } catch (JsonProcessingException ignored) { + // Fall through to a deterministic seed round. + } + } + return List.of( + new RoundSeed(firstNonBlank(openingText, "这个问题你们物业准备怎么处理?"), "先承接情绪,再确认具体诉求。", 72, 70), + new RoundSeed("我想知道接下来谁负责、什么时候反馈。", "补上责任人、处理节点和回访时间。", 62, 82) + ); + } + + private List defaultScores(String scenarioCode) { + boolean feeScenario = !isBlank(scenarioCode) && scenarioCode.contains("fee"); + return List.of( + new DimensionResponse("合规", feeScenario ? 82 : 84, "SOP关键点覆盖"), + new DimensionResponse("沟通", 84, "承诺与表达清晰度"), + new DimensionResponse("情绪", 82, "安抚与承接能力"), + new DimensionResponse("营销", feeScenario ? 80 : 76, "政策解释与办理引导") + ); + } + private int normalizeRoundIndex(Integer roundIndex, int size) { if (roundIndex == null || roundIndex < 0) { return 0; @@ -701,11 +2401,39 @@ 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 CompetencySnapshot(Integer completed, Integer score, Integer pendingReview, Integer compliance, Integer communication, Integer emotion, Integer incentivePoints) { + } + + private record SessionAnnotationSource(String scenarioId, String dialogueJson, String annotationsJson) { + } + + private record ScoreSnapshot(Integer total, Integer compliance, Integer emotion, Integer communication, Integer marketing) { + } + + private record CalibrationPair(int original, int corrected) { + } + + private record CalibrationStats(int total, int matched) { + } + + private record ResolvedTurn(String customerText, Integer emotion, Integer trust, String coachHint, boolean redFlag) { + } + + private record DailyDrillSeed(String scenarioId, String question, String referenceAnswer) { + } + + private record DailyDrillParts(String question, String referenceAnswer) { + } + + private record DailyDrillScore(boolean correct, int score, String feedback) { + } + + private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) { } private record ActiveSession(String scenarioId, String trainee, String extPartyId, boolean mobile, LocalDateTime startedAt, - List traineeReplies, List customerLines) { + List traineeReplies, List traineeAudioUrls, List traineeAudioOssIds, + List customerLines, List turnEvidence) { } private record PracticeResult(Integer total, List scores, String rewrite, String summary) { 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 c0ac6dba..52c7953e 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 @@ -23,6 +23,8 @@ import org.dromara.aihr.domain.AihrSopDto.RecordResponse; 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.SopReviewRequest; +import org.dromara.aihr.domain.AihrSopDto.SopReviewResponse; import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest; import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse; import org.dromara.aihr.domain.AihrSopDto.UploadResponse; @@ -98,6 +100,8 @@ public class AihrSopSeedService { private final String importRootConfig; private final ScheduledExecutorService scheduledExecutorService; private final AihrVideoService videoService; + private volatile boolean knowledgeGapTableReady; + private volatile boolean sopReviewTableReady; public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService, @Value("${aihr.import.root:}") String importRootConfig, @@ -114,12 +118,15 @@ public class AihrSopSeedService { String requestedCategory = request == null ? "" : request.category(); SopSeed seed = seeds.getOrDefault(requestedCategory, seeds.get("投诉处理 SOP")); String category = isBlank(requestedCategory) ? seed.category() : requestedCategory; + String position = firstNonBlank(request == null ? null : request.position(), "生活顾问"); + String source = normalizeSearchSource(request == null ? null : request.source()); String queryText = request == null || request.queryText() == null || request.queryText().isBlank() ? seed.question() : request.queryText(); SearchResponse dbResponse = dbSearch(dbCategory(category), queryText, request == null ? null : request.limit()); if (dbResponse != null && !dbResponse.snippets().isEmpty()) { - return dbResponse; + return withReviewId(dbResponse, source); } - return new SearchResponse( + recordKnowledgeGap(queryText, category, position, source); + return withReviewId(new SearchResponse( queryText, seed.category(), seed.answer(), @@ -129,8 +136,30 @@ public class AihrSopSeedService { seed.training(), records(), List.of(), - List.of() + List.of(), + null + ), source); + } + + public SopReviewResponse reviewSearch(Long id, SopReviewRequest request) { + if (id == null) { + return null; + } + ensureSopReviewTable(); + int updated = jdbcTemplate.update(""" + UPDATE aihr_sop_answer_review + SET usable = ?, reviewer = ?, note = ?, status = '已评审', reviewed_time = ?, update_time = ? + WHERE tenant_id = ? AND id = ? + """, + request != null && Boolean.TRUE.equals(request.usable()), + firstNonBlank(request == null ? null : request.reviewer(), "人工评审"), + firstNonBlank(request == null ? null : request.note(), ""), + Timestamp.valueOf(java.time.LocalDateTime.now()), + Timestamp.valueOf(java.time.LocalDateTime.now()), + TENANT_ID, + id ); + return updated == 0 ? null : sopReview(id); } public SummaryCardResponse summaryCard(SummaryCardRequest request) { @@ -1630,6 +1659,184 @@ public class AihrSopSeedService { } } + private void recordKnowledgeGap(String queryText, String category, String position, String source) { + if (isBlank(queryText)) { + return; + } + try { + ensureKnowledgeGapTable(); + jdbcTemplate.update(""" + INSERT INTO aihr_knowledge_gap + (tenant_id, query_text, category, position, source, status, create_time) + VALUES (?, ?, ?, ?, ?, '待补充', ?) + """, + TENANT_ID, + queryText.trim(), + firstNonBlank(category, "sop"), + firstNonBlank(position, "生活顾问"), + normalizeSearchSource(source), + Timestamp.valueOf(java.time.LocalDateTime.now()) + ); + } catch (DataAccessException ignored) { + // Gap logging is additive and must not break SOP search fallback. + } + } + + private SearchResponse withReviewId(SearchResponse response, String source) { + Long reviewId = createSopReview(response, source); + return new SearchResponse( + response.queryText(), + response.category(), + response.answer(), + response.reference(), + response.docs(), + response.snippets(), + response.training(), + response.records(), + response.keyPoints(), + response.cautions(), + reviewId + ); + } + + private Long createSopReview(SearchResponse response, String source) { + try { + ensureSopReviewTable(); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(""" + INSERT INTO aihr_sop_answer_review + (tenant_id, query_text, category, answer_text, reference_text, snippet_count, source, status, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?) + """, Statement.RETURN_GENERATED_KEYS); + ps.setString(1, TENANT_ID); + ps.setString(2, truncate(response.queryText(), 1000)); + ps.setString(3, firstNonBlank(response.category(), "sop")); + ps.setString(4, truncate(response.answer(), 2000)); + ps.setString(5, truncate(response.reference(), 1000)); + ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size()); + ps.setString(7, normalizeSearchSource(source)); + Timestamp now = Timestamp.valueOf(java.time.LocalDateTime.now()); + ps.setTimestamp(8, now); + ps.setTimestamp(9, now); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + return key == null ? null : key.longValue(); + } catch (DataAccessException e) { + return null; + } + } + + private SopReviewResponse sopReview(Long id) { + List rows = jdbcTemplate.query(""" + SELECT id, usable, status, reviewed_time + FROM aihr_sop_answer_review + WHERE tenant_id = ? AND id = ? + LIMIT 1 + """, (rs, rowNum) -> { + Timestamp reviewedTime = rs.getTimestamp("reviewed_time"); + return new SopReviewResponse( + rs.getLong("id"), + rs.getObject("usable") == null ? null : rs.getBoolean("usable"), + rs.getString("status"), + reviewedTime == null ? "" : reviewedTime.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + ); + }, TENANT_ID, id); + return rows.isEmpty() ? null : rows.get(0); + } + + public SopReviewStats sopReviewStats() { + ensureSopReviewTable(); + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable + FROM aihr_sop_answer_review + WHERE tenant_id = ? AND status = '已评审' + """, (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), TENANT_ID); + } + + private void ensureSopReviewTable() { + if (sopReviewTableReady) { + return; + } + synchronized (this) { + if (sopReviewTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `query_text` varchar(1000) NOT NULL COMMENT '问题', + `category` varchar(100) DEFAULT NULL COMMENT '知识分类', + `answer_text` varchar(2000) DEFAULT NULL COMMENT '回答', + `reference_text` varchar(1000) DEFAULT NULL COMMENT '引用', + `snippet_count` int DEFAULT 0 COMMENT '命中片段数', + `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源', + `usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用', + `reviewer` varchar(100) DEFAULT NULL COMMENT '评审人', + `note` varchar(500) DEFAULT NULL COMMENT '评审备注', + `status` varchar(30) DEFAULT '待评审' COMMENT '状态', + `reviewed_time` datetime DEFAULT NULL COMMENT '评审时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_sop_answer_review_status` (`tenant_id`, `status`, `create_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审'; + """); + ensureColumn("aihr_sop_answer_review", "source", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源' AFTER `snippet_count`"); + sopReviewTableReady = true; + } + } + + private void ensureKnowledgeGapTable() { + if (knowledgeGapTableReady) { + return; + } + synchronized (this) { + if (knowledgeGapTableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `query_text` varchar(1000) NOT NULL COMMENT '未命中问题', + `category` varchar(100) DEFAULT NULL COMMENT '知识分类', + `position` varchar(80) DEFAULT NULL COMMENT '岗位', + `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源', + `status` varchar(30) DEFAULT '待补充' COMMENT '状态', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_knowledge_gap_status` (`tenant_id`, `status`, `create_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识缺口日志'; + """); + knowledgeGapTableReady = true; + } + } + + private void ensureColumn(String table, String column, String ddl) { + Integer exists = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? + """, Integer.class, table, column); + if (exists == null || exists == 0) { + jdbcTemplate.execute(ddl); + } + } + + private String normalizeSearchSource(String value) { + if (isBlank(value)) { + return "knowledge_search"; + } + String source = value.trim().replaceAll("[^A-Za-z0-9_-]", "_"); + if (source.length() > 50) { + source = source.substring(0, 50); + } + return source.isBlank() ? "knowledge_search" : source; + } + private List dbHits(String category, String queryText, Integer limit) { int limitValue = limitValue(limit); List keywordHits = likeHits(category, queryText, limitValue); @@ -1806,7 +2013,8 @@ public class AihrSopSeedService { List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"), records(), digest != null ? digest.keyPoints() : List.of(), - digest != null ? digest.cautions() : List.of() + digest != null ? digest.cautions() : List.of(), + null ); } @@ -1901,7 +2109,8 @@ public class AihrSopSeedService { 你是物业一线员工的SOP信息图总结助手。只返回 JSON,不要 markdown、代码块或解释。 输出格式固定为: {"title":"XX总结卡","steps":[{"label":"步骤名","desc":"一句话"}],"objections":[{"concern":"异议","response":"应对"}],"scripts":["可直接开口的完整话术"],"reminders":["注意事项"]} - 字段约束:title ≤14字;steps 3-5条,label ≤6字,desc ≤20字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。 + steps 必须且只允许 3 条,label 依次固定为「问题」「建议」「标准」:问题写当前要处理的核心问题;建议写下一步动作;标准写SOP依据或红线。 + 字段约束:title ≤14字;每个 desc ≤32字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。 硬约束:只能基于给定片段内容归纳,不得编造片段中没有的流程、时限、承诺、金额、政策或话术。 """); @@ -2824,6 +3033,9 @@ public class AihrSopSeedService { ) { } + public record SopReviewStats(int total, int usable) { + } + private static final class CategorySummary { private final String name; private int files; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java index c2ffc63f..4ec1ade3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java @@ -28,7 +28,7 @@ import java.util.UUID; public class AihrSpeechService { private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15); - private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5); private static final String DEFAULT_TTS_VOICE = "anna"; private final AihrModelSeedService modelService; diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index a549e33f..95055ec9 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -17,8 +17,11 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` ( `dim_marketing` int DEFAULT NULL COMMENT '营销分', `mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写', `ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评', + `review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议', + `incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分', `summary` varchar(1000) DEFAULT NULL COMMENT '摘要', `dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON', + `annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON', `trust_curve` varchar(100) DEFAULT NULL COMMENT '信任曲线', `status` varchar(30) DEFAULT '待复盘' COMMENT '状态', `started_time` datetime DEFAULT NULL COMMENT '开始时间', @@ -30,3 +33,352 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` ( 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 对练记录'; + +CREATE TABLE IF NOT EXISTS `aihr_practice_annotation` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', + `turn_index` int NOT NULL COMMENT '话术序号', + `source_text` varchar(1000) DEFAULT NULL COMMENT '原话', + `issue_text` varchar(1000) DEFAULT NULL COMMENT '问题点', + `rewrite_text` varchar(1000) DEFAULT NULL COMMENT '推荐改写', + `sop_point` varchar(1000) DEFAULT NULL COMMENT '命中或漏掉的SOP点', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_annotation` (`tenant_id`, `session_id`, `turn_index`), + KEY `idx_aihr_practice_annotation_session` (`tenant_id`, `session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练逐句标注'; + +CREATE TABLE IF NOT EXISTS `aihr_practice_audio` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', + `turn_index` int NOT NULL COMMENT '话术序号', + `role` varchar(30) NOT NULL COMMENT '音频角色', + `oss_id` bigint DEFAULT NULL COMMENT 'OSS文件ID', + `audio_url` varchar(1000) DEFAULT NULL COMMENT '音频URL', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_audio` (`tenant_id`, `session_id`, `turn_index`, `role`), + KEY `idx_aihr_practice_audio_session` (`tenant_id`, `session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练音频'; + +CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `ext_party_id` varchar(100) NOT NULL COMMENT '员工外部主体或手机号', + `trainee_name` varchar(100) DEFAULT NULL COMMENT '员工姓名', + `scenario_id` varchar(80) NOT NULL COMMENT '场景ID', + `scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称', + `source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual', + `reason` varchar(500) DEFAULT NULL COMMENT '派发原因', + `status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态', + `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答', + `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈', + `correct` tinyint DEFAULT NULL COMMENT '是否答对', + `finished_time` datetime DEFAULT NULL COMMENT '完成时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_assignment_party` (`tenant_id`, `ext_party_id`, `status`, `create_time`), + KEY `idx_aihr_assignment_source` (`tenant_id`, `source`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务'; + +CREATE TABLE IF NOT EXISTS `aihr_practice_calibration` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', + `reviewer` varchar(100) DEFAULT NULL COMMENT '校准人', + `original_score` int DEFAULT NULL COMMENT 'AI原始总分', + `corrected_score` int DEFAULT NULL COMMENT '人工校准总分', + `corrected_dimensions_json` text DEFAULT NULL COMMENT '校准维度JSON', + `reason` varchar(500) DEFAULT NULL COMMENT '校准原因', + `status` varchar(30) DEFAULT '已记录' COMMENT '状态', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_calibration_session` (`tenant_id`, `session_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 评分校准记录'; + +CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', + `scenario_name` varchar(100) NOT NULL COMMENT '场景名称', + `position` varchar(80) NOT NULL COMMENT '岗位', + `scenario_type` varchar(50) NOT NULL COMMENT '场景类型', + `project_type` varchar(80) DEFAULT NULL COMMENT '项目类型', + `difficulty` tinyint DEFAULT 1 COMMENT '难度', + `customer_persona` varchar(500) DEFAULT NULL COMMENT '业主人设', + `opening_text` varchar(500) DEFAULT NULL COMMENT '开场白', + `explicit_need` varchar(300) DEFAULT NULL COMMENT '显性诉求', + `hidden_need` varchar(300) DEFAULT NULL COMMENT '隐性诉求', + `irritants` varchar(500) DEFAULT NULL COMMENT '雷点', + `delights` varchar(500) DEFAULT NULL COMMENT '爽点', + `sop_refs` varchar(500) DEFAULT NULL COMMENT 'SOP引用', + `success_criteria` varchar(500) DEFAULT NULL COMMENT '成功条件', + `strategy` varchar(500) DEFAULT NULL COMMENT '训练策略', + `goal` varchar(500) DEFAULT NULL COMMENT '训练目标', + `trainee_name` varchar(100) DEFAULT '张三' COMMENT '默认学员', + `trust_start` int DEFAULT 60 COMMENT '起始信任', + `trust_end` int DEFAULT 80 COMMENT '目标信任', + `rounds_json` text DEFAULT NULL COMMENT '回合seed JSON', + `enabled` tinyint DEFAULT 1 COMMENT '是否启用', + `sort_order` int DEFAULT 0 COMMENT '排序', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_scenario` (`tenant_id`, `scenario_code`), + KEY `idx_aihr_practice_scenario_filter` (`tenant_id`, `position`, `scenario_type`, `enabled`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练场景'; + +CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码', + `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', + `rubric_name` varchar(100) NOT NULL COMMENT 'Rubric名称', + `version` varchar(30) DEFAULT 'v1' COMMENT '版本', + `enabled` tinyint DEFAULT 1 COMMENT '是否启用', + `description` varchar(500) DEFAULT NULL COMMENT '说明', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_rubric` (`tenant_id`, `rubric_code`), + KEY `idx_aihr_practice_rubric_scenario` (`tenant_id`, `scenario_code`, `enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练评分标准'; + +CREATE TABLE IF NOT EXISTS `aihr_practice_rubric_dimension` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码', + `dimension_code` varchar(50) NOT NULL COMMENT '维度编码', + `dimension_name` varchar(50) NOT NULL COMMENT '维度名称', + `weight` decimal(5,2) NOT NULL COMMENT '权重', + `description` varchar(500) DEFAULT NULL COMMENT '评分说明', + `sort_order` int DEFAULT 0 COMMENT '排序', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_rubric_dimension` (`tenant_id`, `rubric_code`, `dimension_code`), + KEY `idx_aihr_practice_rubric_dimension` (`tenant_id`, `rubric_code`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练评分维度'; + +CREATE TABLE IF NOT EXISTS `aihr_prompt_template` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `code` varchar(80) NOT NULL COMMENT '模板编码', + `name` varchar(100) NOT NULL COMMENT '模板名称', + `category` varchar(50) NOT NULL COMMENT '业务分类', + `output_format` varchar(50) NOT NULL COMMENT '输出文体', + `system_prompt` text DEFAULT NULL COMMENT '系统提示词', + `template` text NOT NULL COMMENT '用户提示词模板', + `variables` varchar(1000) DEFAULT NULL COMMENT '变量清单', + `version` varchar(30) DEFAULT 'v1' COMMENT '版本', + `enabled` tinyint DEFAULT 1 COMMENT '是否启用', + `description` varchar(500) DEFAULT NULL COMMENT '说明', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_prompt_template` (`tenant_id`, `code`, `output_format`, `version`), + KEY `idx_aihr_prompt_template_enabled` (`tenant_id`, `code`, `enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 提示词模板'; + +CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `query_text` varchar(1000) NOT NULL COMMENT '未命中问题', + `category` varchar(100) DEFAULT NULL COMMENT '知识分类', + `position` varchar(80) DEFAULT NULL COMMENT '岗位', + `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源', + `status` varchar(30) DEFAULT '待补充' COMMENT '状态', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_knowledge_gap_status` (`tenant_id`, `status`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识缺口日志'; + +CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `query_text` varchar(1000) NOT NULL COMMENT '问题', + `category` varchar(100) DEFAULT NULL COMMENT '知识分类', + `answer_text` varchar(2000) DEFAULT NULL COMMENT '回答', + `reference_text` varchar(1000) DEFAULT NULL COMMENT '引用', + `snippet_count` int DEFAULT 0 COMMENT '命中片段数', + `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源', + `usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用', + `reviewer` varchar(100) DEFAULT NULL COMMENT '评审人', + `note` varchar(500) DEFAULT NULL COMMENT '评审备注', + `status` varchar(30) DEFAULT '待评审' COMMENT '状态', + `reviewed_time` datetime DEFAULT NULL COMMENT '评审时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_sop_answer_review_status` (`tenant_id`, `status`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审'; + +INSERT INTO `aihr_practice_scenario` + (`tenant_id`, `scenario_code`, `scenario_name`, `position`, `scenario_type`, `project_type`, `difficulty`, + `customer_persona`, `opening_text`, `explicit_need`, `hidden_need`, `irritants`, `delights`, `sop_refs`, + `success_criteria`, `strategy`, `goal`, `trainee_name`, `trust_start`, `trust_end`, `rounds_json`, `enabled`, `sort_order`, `create_time`, `update_time`) +VALUES +('000000', 'complaint-water', '客服应对投诉', '生活顾问', '投诉处理', '住宅', 2, + '高情绪、追问进度、质疑服务的业主', '你们说今天处理,到现在都没人联系我,这个车库还怎么停?', '投诉车库积水未处理', '想确认物业是否持续跟进', '被敷衍、只讲流程、推给工程', '先安抚、给责任人、给首次反馈时间', '投诉处理SOP;高情绪沟通话术', + '先承接情绪,确认点位,给责任人与首次反馈时间,说明升级路径', '仅在漏关键步骤时提示', '训练学员先安抚、再确认诉求,并给出责任人和首次反馈时间。', '张三', 68, 82, + '[{"customer":"你们说今天处理,到现在都没人联系我,这个车库还怎么停?","coachHint":"命中安抚、责任人、首次反馈时间后继续追问处理承诺。","emotion":76,"trust":75},{"customer":"我不要流程,我要知道如果今天还处理不好怎么办。","coachHint":"补上升级路径和兜底承诺,可以进入评分。","emotion":64,"trust":82}]', 1, 10, NOW(), NOW()), +('000000', 'complaint-noise', '楼上噪音投诉', '生活顾问', '投诉处理', '住宅', 2, + '长期受扰、睡眠受影响、对物业不信任的业主', '楼上每天晚上拖椅子,你们物业到底管不管?', '投诉夜间噪音', '希望物业持续跟踪而不是一次性劝说', '让业主自己协商、说没有执法权、不给反馈节点', '先共情睡眠影响、承诺核实、给回访时间', '噪音投诉SOP;邻里协调SOP', + '记录时间段和房号,说明核实方式,约定首次沟通和回访时间', '提示不要先讲权限边界', '训练生活顾问在权限边界内给出可执行跟进。', '张三', 60, 80, + '[{"customer":"楼上每天晚上拖椅子,你们物业到底管不管?","coachHint":"先承接睡眠受影响,再收集时间段和楼栋房号。","emotion":78,"trust":66},{"customer":"你别又说去问问,我要看到结果。","coachHint":"给出上门/电话沟通和回访节点,避免空泛承诺。","emotion":70,"trust":80}]', 1, 20, NOW(), NOW()), +('000000', 'complaint-attitude', '服务态度投诉', '生活顾问', '投诉处理', '住宅', 1, + '对前台态度不满、要求道歉的业主', '你们前台刚才那是什么态度?我要投诉。', '投诉服务态度', '希望被尊重并看到内部改进', '替同事辩解、追问业主责任、让业主冷静', '先道歉、复述问题、承诺核查和反馈', '服务投诉SOP;客户情绪安抚', + '表达歉意,记录经过,明确核查责任人和反馈时限', '提示先道歉不等于先定责', '训练生活顾问把道歉、核查和后续反馈拆开说清。', '张三', 62, 82, + '[{"customer":"你们前台刚才那是什么态度?我要投诉。","coachHint":"先为体验道歉,再记录时间、地点和涉及岗位。","emotion":74,"trust":68},{"customer":"你是不是想包庇你同事?","coachHint":"强调会客观核查并按节点反馈,不做现场定责。","emotion":66,"trust":82}]', 1, 30, NOW(), NOW()), +('000000', 'fee-collection', '催缴沟通技巧', '生活顾问', '催费/停车费', '住宅', 2, + '拒绝缴费、质疑服务、要求先整改的业主', '服务做成这样还让我缴费?你先把问题解决了再说。', '物业费催缴', '希望服务问题被负责到底', '压缴费、忽略服务问题、威胁停服务', '承接问题、争议复核、缴费与整改并行', '催缴沟通SOP;费用争议处理', + '拆分费用和服务问题,说明复核、整改跟进和缴费节点', '提示服务改进与缴费并行', '训练学员把费用沟通和服务整改拆开处理,避免激化争议。', '张三', 61, 76, + '[{"customer":"服务做成这样还让我缴费?你先把问题解决了再说。","coachHint":"表达方向正确,但要补争议项复核和责任人。","emotion":72,"trust":68},{"customer":"那我怎么知道你不是随便记一下?","coachHint":"补上主管复核和节点同步,可以进入评分。","emotion":66,"trust":76}]', 1, 40, NOW(), NOW()), +('000000', 'fee-parking', '停车费催缴', '生活顾问', '催费/停车费', '住宅', 2, + '对停车费涨价敏感、担心权益受损的业主', '停车费又催,我车位入口问题一直没解决,凭什么交?', '停车费缴费提醒', '希望停车体验问题同步解决', '只重复收费规则、否认问题、催得太硬', '先记录停车问题、说明费用依据、给处理节点', '停车费催缴SOP;停车场服务SOP', + '说明费用依据,记录入口问题,给整改反馈时间和缴费办理方式', '提示先服务后收费说明', '训练收费季停车费沟通时的共情和政策表达。', '张三', 58, 78, + '[{"customer":"停车费又催,我车位入口问题一直没解决,凭什么交?","coachHint":"先确认入口问题,再说明费用依据和复核方式。","emotion":75,"trust":65},{"customer":"你说的优惠和截止时间到底是什么?","coachHint":"把折扣、截止时间和办理路径一次说清。","emotion":62,"trust":78}]', 1, 50, NOW(), NOW()), +('000000', 'fee-prepay-discount', '预交折扣沟通', '生活顾问', '催费/停车费', '住宅', 2, + '精打细算、关注优惠边界和历史欠费的业主', '你们说预交有折扣,那我以前欠的怎么算?', '预交折扣咨询', '希望弄清优惠、欠费和办理路径', '含糊优惠口径、承诺无法兑现、回避欠费', '先说明以系统账单为准、再给办理路径', '预交折扣SOP;历史欠费沟通', + '解释预交折扣、历史欠费处理口径和办理材料', '提示金额必须以系统账单为准', '训练收费季预交优惠和历史欠费的稳妥表达。', '张三', 65, 84, + '[{"customer":"你们说预交有折扣,那我以前欠的怎么算?","coachHint":"先说明金额以系统账单为准,避免口头承诺具体金额。","emotion":60,"trust":72},{"customer":"我要今天就知道能省多少钱。","coachHint":"给出查询路径和反馈时间,不现场编金额。","emotion":58,"trust":84}]', 1, 60, NOW(), NOW()), +('000000', 'repair-leak-followup', '漏水报修跟进', '生活顾问', '报修跟进', '住宅', 2, + '家中受损、担心维修拖延和赔偿的业主', '我家天花还在滴水,报修两天了怎么没人来?', '漏水报修跟进', '希望明确上门时间和后续责任', '只说已派单、不给时限、回避损失记录', '确认安全、给上门时间、记录受损情况', '报修跟进SOP;漏水应急SOP', + '确认漏水现状,安排上门节点,记录损失并同步责任人', '提示先排安全风险', '训练报修跟进中的时限、责任人和证据记录。', '张三', 57, 81, + '[{"customer":"我家天花还在滴水,报修两天了怎么没人来?","coachHint":"先确认是否仍在漏水和用电安全,再给上门安排。","emotion":77,"trust":64},{"customer":"如果家具泡坏了谁负责?","coachHint":"说明先记录现场和照片,后续按责任认定处理。","emotion":69,"trust":81}]', 1, 70, NOW(), NOW()), +('000000', 'repair-elevator-delay', '电梯故障跟进', '生活顾问', '报修跟进', '住宅', 3, + '住高层、老人出行受影响、要求明确恢复时间的业主', '电梯坏了一上午,老人怎么下楼?你们给个准话。', '电梯故障跟进', '希望知道预计恢复和临时协助', '只说维保在路上、没有临时方案、不给播报', '先共情出行影响、说明维保进展、提供临时协助', '电梯故障SOP;应急协助SOP', + '说明维保进展、预计反馈时间、老人出行临时协助和公告节奏', '提示不要承诺未经确认的恢复时间', '训练高影响报修的进展播报和临时协助。', '张三', 55, 80, + '[{"customer":"电梯坏了一上午,老人怎么下楼?你们给个准话。","coachHint":"先承接老人出行影响,再说明维保进展和下一次反馈。","emotion":80,"trust":62},{"customer":"你就说几点能好。","coachHint":"不能编恢复时间,要承诺确认后反馈并给临时协助。","emotion":72,"trust":80}]', 1, 80, NOW(), NOW()), +('000000', 'repair-public-light', '公区照明维修', '生活顾问', '报修跟进', '住宅', 1, + '晚上回家担心安全、希望小问题快处理的业主', '楼道灯坏了三天,晚上黑漆漆的。', '公区照明维修', '希望确认维修时间', '轻视小问题、不给工单、让业主等', '记录点位、给工单和处理时限', '公区维修SOP;夜间安全巡查', + '记录楼栋楼层点位,说明派单和反馈时间', '提示给出工单闭环', '训练轻量报修也要闭环表达。', '张三', 66, 86, + '[{"customer":"楼道灯坏了三天,晚上黑漆漆的。","coachHint":"记录准确点位,并给派单和预计反馈时间。","emotion":62,"trust":76},{"customer":"别又登记完没下文。","coachHint":"补充工单编号或责任人和回访时间。","emotion":56,"trust":86}]', 1, 90, NOW(), NOW()), +('000000', 'emergency-water-outage', '突发停水求助', '生活顾问', '突发求助', '住宅', 2, + '家中有老人小孩、对突发停水焦虑的业主', '家里突然没水了,孩子还要洗澡,怎么回事?', '突发停水求助', '希望知道原因和临时供水办法', '不知道原因就乱猜、只让等待、不给通知渠道', '先核实范围、同步排查、提供临时取水信息', '停水应急SOP;突发通知SOP', + '核实是否区域性停水,说明排查进展、通知渠道和临时方案', '提示未知原因时说排查中', '训练突发问题的原因核实和临时安置表达。', '张三', 60, 83, + '[{"customer":"家里突然没水了,孩子还要洗澡,怎么回事?","coachHint":"先核实楼栋范围,不要猜测原因。","emotion":73,"trust":68},{"customer":"那现在怎么办?总不能一直等。","coachHint":"给临时取水点或下一次公告时间。","emotion":65,"trust":83}]', 1, 100, NOW(), NOW()), +('000000', 'emergency-elder-fall', '老人摔倒求助', '生活顾问', '突发求助', '住宅', 3, + '情绪慌张、需要立即协助的家属', '我爸在楼下摔倒了,你们快来帮忙!', '老人摔倒求助', '希望物业迅速到场并联系救助', '先追责、慢吞吞登记、忽略急救安全', '先确认位置和意识状态、派人到场、建议急救电话', '突发救助SOP;急救协助SOP', + '确认位置和安全状态,派人到场,提醒拨打急救电话并联系家属', '提示物业协助不替代专业急救', '训练高紧急求助中的优先级和措辞。', '张三', 52, 79, + '[{"customer":"我爸在楼下摔倒了,你们快来帮忙!","coachHint":"先问清准确位置和意识状态,同时安排人员到场。","emotion":88,"trust":60},{"customer":"你们到了能不能处理?","coachHint":"说明会协助保护现场和联系急救,不替代医生判断。","emotion":78,"trust":79}]', 1, 110, NOW(), NOW()), +('000000', 'emergency-security', '陌生人尾随求助', '生活顾问', '突发求助', '住宅', 3, + '夜间回家、感到被尾随、需要安全协助的业主', '我刚进小区感觉有人跟着我,你们保安在哪里?', '安全求助', '希望马上获得陪同和安全确认', '要求业主自己确认、轻描淡写、泄露巡逻盲区', '先确认位置、安排安保、保持通话或引导到安全点', '安全巡逻SOP;突发求助SOP', + '确认实时位置,安排安保接应,引导到明亮公共区域并保留记录', '提示不要透露安防盲区', '训练夜间安全求助的接警式沟通。', '张三', 54, 82, + '[{"customer":"我刚进小区感觉有人跟着我,你们保安在哪里?","coachHint":"先确认当前位置和是否安全,安排安保接应。","emotion":86,"trust":62},{"customer":"我要不要直接回家?","coachHint":"引导到明亮公共区域或岗亭,保持联系。","emotion":76,"trust":82}]', 1, 120, NOW(), NOW()) +ON DUPLICATE KEY UPDATE + `scenario_name` = VALUES(`scenario_name`), + `position` = VALUES(`position`), + `scenario_type` = VALUES(`scenario_type`), + `project_type` = VALUES(`project_type`), + `difficulty` = VALUES(`difficulty`), + `customer_persona` = VALUES(`customer_persona`), + `opening_text` = VALUES(`opening_text`), + `explicit_need` = VALUES(`explicit_need`), + `hidden_need` = VALUES(`hidden_need`), + `irritants` = VALUES(`irritants`), + `delights` = VALUES(`delights`), + `sop_refs` = VALUES(`sop_refs`), + `success_criteria` = VALUES(`success_criteria`), + `strategy` = VALUES(`strategy`), + `goal` = VALUES(`goal`), + `trainee_name` = VALUES(`trainee_name`), + `trust_start` = VALUES(`trust_start`), + `trust_end` = VALUES(`trust_end`), + `rounds_json` = VALUES(`rounds_json`), + `enabled` = VALUES(`enabled`), + `sort_order` = VALUES(`sort_order`), + `update_time` = VALUES(`update_time`); + +INSERT INTO `aihr_practice_rubric` + (`tenant_id`, `rubric_code`, `scenario_code`, `rubric_name`, `version`, `enabled`, `description`, `create_time`, `update_time`) +SELECT + `tenant_id`, + CONCAT('rubric-', `scenario_code`), + `scenario_code`, + CONCAT(`scenario_name`, ' Rubric'), + 'v1', + 1, + '生活顾问试点四维评分标准', + NOW(), + NOW() +FROM `aihr_practice_scenario` +WHERE `tenant_id` = '000000' +ON DUPLICATE KEY UPDATE + `rubric_name` = VALUES(`rubric_name`), + `scenario_code` = VALUES(`scenario_code`), + `enabled` = VALUES(`enabled`), + `description` = VALUES(`description`), + `update_time` = VALUES(`update_time`); + +INSERT INTO `aihr_practice_rubric_dimension` + (`tenant_id`, `rubric_code`, `dimension_code`, `dimension_name`, `weight`, `description`, `sort_order`, `create_time`, `update_time`) +SELECT + r.`tenant_id`, + r.`rubric_code`, + d.`dimension_code`, + d.`dimension_name`, + CASE + WHEN s.`scenario_type` = '催费/停车费' AND d.`dimension_code` = 'compliance' THEN 0.30 + WHEN s.`scenario_type` = '催费/停车费' AND d.`dimension_code` = 'marketing' THEN 0.20 + ELSE d.`weight` + END, + d.`description`, + d.`sort_order`, + NOW(), + NOW() +FROM `aihr_practice_rubric` r +JOIN `aihr_practice_scenario` s + ON s.`tenant_id` = r.`tenant_id` AND s.`scenario_code` = r.`scenario_code` +JOIN ( + SELECT 'compliance' AS `dimension_code`, '合规' AS `dimension_name`, 0.35 AS `weight`, 'SOP关键点、权限边界、承诺口径' AS `description`, 10 AS `sort_order` + UNION ALL SELECT 'communication', '沟通', 0.25, '诉求确认、信息结构、反馈节点', 20 + UNION ALL SELECT 'emotion', '情绪', 0.25, '共情承接、降温、信任恢复', 30 + UNION ALL SELECT 'marketing', '营销', 0.15, '收费季转化、政策解释、办理引导', 40 +) d +WHERE r.`tenant_id` = '000000' +ON DUPLICATE KEY UPDATE + `dimension_name` = VALUES(`dimension_name`), + `weight` = VALUES(`weight`), + `description` = VALUES(`description`), + `sort_order` = VALUES(`sort_order`), + `update_time` = VALUES(`update_time`); + +INSERT INTO `aihr_prompt_template` + (`tenant_id`, `code`, `name`, `category`, `output_format`, `system_prompt`, `template`, `variables`, `version`, `enabled`, `description`, `create_time`, `update_time`) +VALUES +('000000', 'summary_card', 'SOP总结卡', 'knowledge', 'json_card', + '你是物业一线员工的SOP总结卡助手。只返回JSON,不要markdown。固定输出title、steps、objections、scripts、reminders。steps必须且只包含问题、建议、标准三项。', + '员工问题:{{queryText}}。知识片段:{{context}}。请生成可拆分展示的总结卡,问题写员工当前要处理的核心问题,建议写下一步动作,标准写SOP依据和红线。', + 'queryText,context', 'v1', 1, '总结卡固定问题/建议/标准三段式', NOW(), NOW()), +('000000', 'mentor_rewrite', '导师改写', 'practice', 'text', + '你是物业管家带教师傅。只基于本场对话给出一段更稳妥的话术,不评价人格。', + '场景:{{scenarioName}}。目标:{{goal}}。员工原话:{{traineeText}}。请改写成可直接说出口的版本,80字以内。', + 'scenarioName,goal,traineeText', 'v1', 1, '训练后标杆话术生成', NOW(), NOW()), +('000000', 'review_annotation', '逐句标注', 'practice', 'json_annotation', + '你是物业AI陪练复盘教练。只返回JSON数组,每项包含quote、problem、rewrite、sopPoint。', + '场景:{{scenarioName}}。Rubric:{{rubric}}。对话:{{dialogue}}。请只标注员工关键句,指出问题、推荐改写和命中或漏掉的SOP点。', + 'scenarioName,rubric,dialogue', 'v1', 1, 'finish后逐句标注', NOW(), NOW()), +('000000', 'prep_card', '训练前预习卡', 'practice', 'json_prep', + '你是物业培训教练。只返回JSON,字段固定为keyPoints、redLines、scripts。', + '场景:{{scenarioName}}。SOP引用:{{sopRefs}}。成功条件:{{successCriteria}}。请生成训练前预习卡:要点3条、红线3条、可说话术2条。', + 'scenarioName,sopRefs,successCriteria', 'v1', 1, '训练前卡固定三栏结构', NOW(), NOW()), +('000000', 'supervision_image_analysis_maintenance', '图片督导分析', 'supervision', 'json_card', + '你是物业现场督导助手。只返回JSON,字段固定为problem、suggestion、standard。', + '图片/视频描述:{{visualText}}。相关标准:{{standard}}。请按问题、建议、标准三段输出,不要扩写无依据内容。', + 'visualText,standard', 'v1', 1, '督导图片分析三段式', NOW(), NOW()) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `category` = VALUES(`category`), + `system_prompt` = VALUES(`system_prompt`), + `template` = VALUES(`template`), + `variables` = VALUES(`variables`), + `enabled` = VALUES(`enabled`), + `description` = VALUES(`description`), + `update_time` = VALUES(`update_time`); diff --git a/docs/AI陪练二期开发推进计划.md b/docs/AI陪练二期开发推进计划.md index bb2eee30..fbf07212 100644 --- a/docs/AI陪练二期开发推进计划.md +++ b/docs/AI陪练二期开发推进计划.md @@ -330,10 +330,10 @@ **Steps** -- [ ] 增加 `aihr_practice_scenario`、`aihr_practice_rubric`、`aihr_practice_rubric_dimension`。 -- [ ] 增加 `aihr_prompt_template`,内置 `summary_card`、`mentor_rewrite`、`review_annotation`、`prep_card`、`supervision_image_analysis_maintenance` 等模板。 -- [ ] 写入 12 条生活顾问场景 seed。 -- [ ] reset 后确认场景、rubric 和 prompt template 表有数据。 +- [x] 增加 `aihr_practice_scenario`、`aihr_practice_rubric`、`aihr_practice_rubric_dimension`。 +- [x] 增加 `aihr_prompt_template`,内置 `summary_card`、`mentor_rewrite`、`review_annotation`、`prep_card`、`supervision_image_analysis_maintenance` 等模板。 +- [x] 写入 12 条生活顾问场景 seed。 +- [x] reset 后确认场景、rubric 和 prompt template 表有数据。 ### Task 2: 场景读取替换 seed @@ -345,9 +345,9 @@ **Steps** -- [ ] `start` 优先按 `scenarioId` 读数据库场景。 -- [ ] 数据库无场景时回退现有 seed。 -- [ ] `turn/finish` 保持当前契约不破坏前端。 +- [x] `start` 优先按 `scenarioId` 读数据库场景。 +- [x] 数据库无场景时回退现有 seed。 +- [x] `turn/finish` 保持当前契约不破坏前端。 ### Task 3: 训练前卡和求助 @@ -360,9 +360,9 @@ **Steps** -- [ ] 开始训练前展示场景预习卡。 -- [ ] 训练中加“求助”入口,用场景 category/SOP 引用查知识库。 -- [ ] 求助失败显示明确错误,不兜假答案。 +- [x] 开始训练前展示场景预习卡。 +- [x] 训练中加“求助”入口,用场景 category/SOP 引用查知识库。 +- [x] 求助失败显示明确错误,不兜假答案。 ### Task 4: 动态情绪和红线纠偏 @@ -375,9 +375,9 @@ **Steps** -- [ ] LLM turn 输出增加 `emotion/trust/redFlag/coachHint`。 -- [ ] 前端展示情绪条和红线纠偏。 -- [ ] 解析失败时回退现有 coachHint,不阻断训练。 +- [x] LLM turn 输出增加 `emotion/trust/redFlag/coachHint`。 +- [x] 前端展示情绪条和红线纠偏。 +- [x] 解析失败时回退现有 coachHint,不阻断训练。 ### Task 5: 回放与逐句标注 @@ -390,9 +390,9 @@ **Steps** -- [ ] 对练音频写 `sys_oss`/MinIO 并关联 session。 -- [ ] 主管复盘详情显示音频回放。 -- [ ] finish 后生成逐句标注,刷新不重复生成。 +- [x] 对练音频写 `sys_oss`/MinIO 并关联 session。 +- [x] 主管复盘详情显示音频回放。 +- [x] finish 后生成逐句标注,刷新不重复生成。 ### Task 6: 画像与主管派发 @@ -405,11 +405,11 @@ **Steps** -- [ ] 画像补训练时长、贡献度、测评分、AI 等级。 -- [ ] 主管端补预警列表。 -- [ ] 主管可派发专项训练,员工端今日安排可见。 -- [ ] 主管端可记录复盘建议并派发下一步训练。 -- [ ] 员工端展示初/中/高级成长路径进度,管理端可导出评级证据包。 +- [x] 画像补训练时长、贡献度、测评分、AI 等级。 +- [x] 主管端补预警列表。 +- [x] 主管可派发专项训练,员工端今日安排可见。 +- [x] 主管端可记录复盘建议并派发下一步训练。 +- [x] 员工端展示初/中/高级成长路径进度,管理端可导出评级证据包。 ## 11. 二期完成定义 diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index e9078968..e5572717 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -15,7 +15,7 @@ | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,映射并刷新 `aihr_org_snapshot`;默认 `replaceExisting=true` 覆盖本租户快照,先用 `{"dryRun":true}` 预检;外部接口未配置时保留 SQL seed | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) | | 移动端三端首页 `/h5/user`、`/h5/candidate`、`/h5/supervisor` | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 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`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化 | +| 移动端员工训练闭环 | 复用 `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`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化;复盘标记可带 `incentivePoint` 写入 `incentive_point`,用于贡献度和试点 CSV | | 移动端候选人闭环 | 面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;资料 `POST /api/aihr/mobile/candidate/materials`、`GET /api/aihr/mobile/candidate/materials`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;面试拉题/评分走真实模型优先;资料上传写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 在 AI 面试页标记 `已通过/已驳回`,候选人端列表同步状态 | ## 后端落点 @@ -126,8 +126,8 @@ curl -fsS http://127.0.0.1:8080/api/aihr/org/sync \ - 资料处理页面:`frontend/src/views/knowledge/processing.vue` - 模型配置 API 文件:`frontend/src/api/aihr/model.ts` - 模型配置页面:`frontend/src/views/system/model/index.vue` -- 移动端独立工程:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`;当前三端首页已按高保真原型实现,未登录先走手机号短信登录,登录后 API 优先请求 `/api/aihr/mobile/home/{role}`,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。 -- 员工端训练:任务卡“开始训练”复用管理端三角色对练 API,完成两轮后展示评分和导师改写;记录写入 `aihr_practice_session`,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。 +- 移动端独立工程:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`;员工端子路由为 `/h5/user/training`(训练列表)和 `/h5/user/practice`(对练页)。当前三端首页已按高保真原型实现,未登录先走手机号短信登录,登录后 API 优先请求 `/api/aihr/mobile/home/{role}`,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。 +- 员工端训练:底部“练”进入 `/h5/user/training`,首屏展示场景模拟、开始练习和最近练过,每日三题后置;点“开始练习”切换到 `/h5/user/practice`,复用管理端三角色对练 API,完成两轮后展示评分和导师改写;记录写入 `aihr_practice_session`,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。 - 候选人端:`/h5/candidate` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示 OSS ID、资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。 - 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。 diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index 51244f33..dce43e7e 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -15,13 +15,25 @@ | 顺序 | 页面 | 点击路径 | 必须看到 | |---|---|---|---| | 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” | -| 2 | 移动端三端 `http://127.0.0.1:5174/h5/user`、`/h5/candidate`、`/h5/supervisor` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端首页为「师傅区(头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图(问/练/学/报四色块)→我的成长摘要」,点“开始训练”完成两轮提交,再打开主管端;点快捷 chip 或「问」可文本/语音查 SOP,结果页点「生成总结卡·约10秒」弹出信息图卡(四步法/异议应对/开口话术/关键提醒+听一遍/开始跟读);候选人端点“开始面试/面试练习”和“补充资料”;管理端 `/recruit/interview` 审核候选资料 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;底部 tab 整页切换(员工端为 今日/练/问/我,切换回顶),滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页(返回首页按钮),无死按钮;主管端完训人数、“待复盘对练”计数和待复盘列表增加;训练页语音输入在浏览器录音失败时应切到“选择语音”而不是死按钮;点进待复盘记录可看评分、话术、导师改写,并可标记已复盘;候选人端可拉题/答题/评分,资料上传后显示“已提交资料”、`OSS ...`、`待审核`;HR 审核后候选人端显示 `已通过/已驳回`;接口不可用时走本地 fallback | +| 2 | 移动端三端 `http://127.0.0.1:5174/h5/user`、`/h5/candidate`、`/h5/supervisor` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端首页为「师傅区(头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图(问/练/学/报四色块)→我的成长摘要」;点底部“练”进入 `/h5/user/training`,首屏必须先看到场景模拟、开始练习和最近练过,每日三题在下方;点“开始练习”必须切到 `/h5/user/practice` 对练页,完成两轮提交,再打开主管端;点快捷 chip 或「问」可文本/语音查 SOP,结果页点「生成总结卡·约10秒」弹出信息图卡(四步法/异议应对/开口话术/关键提醒+听一遍/开始跟读);候选人端点“开始面试/面试练习”和“补充资料”;管理端 `/recruit/interview` 审核候选资料 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;底部 tab 整页切换(员工端为 今日/练/问/我,切换回顶),滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页(返回首页按钮),无死按钮;主管端完训人数、“待复盘对练”计数和待复盘列表增加;训练页语音输入在浏览器录音失败时应切到“选择语音”而不是死按钮;点进待复盘记录可看评分、话术、导师改写,并可标记已复盘;候选人端可拉题/答题/评分,资料上传后显示“已提交资料”、`OSS ...`、`待审核`;HR 审核后候选人端显示 `已通过/已驳回`;接口不可用时走本地 fallback | | 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 | | 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 | | 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环,案例列表新增真实转写生成的记录;视频仍用样片兜底 | | 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 | | 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 | +## AI陪练二期增量脚本 + +| 顺序 | 页面 | 点击路径 | 必须看到 | +|---|---|---|---| +| 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 | +| 2 | 员工端 `/h5/user` | 今日安排 → 每日三题作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 | +| 3 | 主管端 `/h5/supervisor` | 预警/待复盘 → 进入复盘详情 → 听回放/看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;员工端今日安排出现专项/再练任务 | +| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 导出试点 CSV | 导出包含训练次数、完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率 | +| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 | + +试点正式验收前,用 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门;样本不足时应失败,不用静态演示 marker 代替真实试点数据。 + ## 录屏兜底 录屏只保留一条完整路径,不录框架后台配置。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 9412ad22..fe1e0068 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -33,6 +33,10 @@ AIHR_ORG_SYNC_ACCESS_TOKEN= AIHR_ORG_SYNC_CLIENT_ID= AIHR_ORG_SYNC_CLIENT_SECRET= AIHR_ORG_SYNC_SIGNING_SECRET= +# 试点成本闸门:超预算时改 false,chat/asr/tts 会走现有文本兜底 +AIHR_AI_RUNTIME_ENABLED=true +AIHR_AI_CHAT_ENABLED=true +AIHR_AI_SPEECH_ENABLED=true ``` `application-dev.yml` 只保留占位和默认值,不提交真实短信密钥。dev 环境不配阿里云短信也能登录移动端:验证码固定 `123456`。 @@ -134,7 +138,7 @@ curl -k -s https://peilian.njzhmj.top/h5/supervisor | sed -n '1,20p' - 案例沉淀:进入 `/knowledge/cases`,上传真实语音 → “AI 整理” → “送审” → “入库”,应看到“已完成闭环”和新增案例记录;上传必须先完成 ASR 转写。 - SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。 - 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;可用少量文件验证“批量导入/选择目录/服务端导入”。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。服务端导入读取 `./.data/import` 下的相对目录,启动后台任务并在页面显示进度,运行中任务可点“取消”;目录导入的重试粒度是同目录重新导入,批量上传的重试粒度是单文件。 -- 移动端员工训练:进入 `http://127.0.0.1:5174/h5/user`,手机号登录(dev 验证码固定 `123456`)。员工端首页为「师傅区(mentor.png 头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图四色块→我的成长摘要」(视觉基准 `feedback/2026070423/design/`),底部 tab(今日/练/问/我)整页切换,滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页。“开始训练” → 两次“填入建议回复 / 提交本轮”,应看到评分、导师改写、训练历史和能力画像;切到主管端后完训人数与“待复盘对练”计数增加,并展示待复盘列表;点进单条可看评分、话术、导师改写,并可标记已复盘。训练页有“语音输入”和“播报”按钮,需启用 asr/tts 模型后生效;iOS/WebView 若无法直接访问麦克风,会切到“选择语音”并用系统录音/音频文件继续走 ASR,否则降级文本。快捷 chip 或「问」打开查 SOP 面板:文本/语音提问 → 轻概要(回答+要点+注意)+依据折叠;点「生成总结卡·约10秒」弹出信息图卡(四步法/异议/话术/提醒+听一遍/开始跟读),需 chat 模型;语音需 asr 模型。 +- 移动端员工训练:进入 `http://127.0.0.1:5174/h5/user`,手机号登录(dev 验证码固定 `123456`)。员工端首页为「师傅区(mentor.png 头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图四色块→我的成长摘要」(视觉基准 `feedback/2026070423/design/`),底部 tab(今日/练/问/我)整页切换,滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页。点底部“练”进入 `http://127.0.0.1:5174/h5/user/training`,首屏应先看到场景模拟、开始练习和最近练过,每日三题在下方;点“开始练习”进入 `http://127.0.0.1:5174/h5/user/practice`,两次“填入建议回复 / 提交本轮”后应看到评分、导师改写、训练历史和能力画像;切到主管端后完训人数与“待复盘对练”计数增加,并展示待复盘列表;点进单条可看评分、话术、导师改写,并可标记已复盘。训练页有“语音输入”和“播报”按钮,需启用 asr/tts 模型后生效;iOS/WebView 若无法直接访问麦克风,会切到“选择语音”并用系统录音/音频文件继续走 ASR,否则降级文本。快捷 chip 或「问」打开查 SOP 面板:文本/语音提问 → 轻概要(回答+要点+注意)+依据折叠;点「生成总结卡·约10秒」弹出信息图卡(四步法/异议/话术/提醒+听一遍/开始跟读),需 chat 模型;语音需 asr 模型。 - 移动端候选人:进入 `http://127.0.0.1:5174/h5/candidate`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到“已提交资料”、`OSS ...`、`待审核`。再进入管理端 `/recruit/interview` 底部“候选资料审核”点“通过/驳回”,候选人端刷新后应显示新状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。 - 真 LLM 激活:在 `/system/model` 给供应商填 api_host/api_key 并启用 `category=chat` 模型后,三角色对练的客户回复与评分即为真实 LLM 生成;再启用 `category=asr/tts`(如硅基流动 SenseVoice/CosyVoice2)语音路径生效。未配置时全链路自动回退 seed。 diff --git a/frontend/src/api/aihr/practice.ts b/frontend/src/api/aihr/practice.ts index c048afc2..debdf407 100644 --- a/frontend/src/api/aihr/practice.ts +++ b/frontend/src/api/aihr/practice.ts @@ -12,6 +12,43 @@ export type PracticeStartRequest = { mode: string; }; +export type PracticeScenarioResponse = { + id: string; + name: string; + position: string; + scenarioType: string; + projectType: string; + difficulty: number; + customerPersona: string; + openingText: string; + explicitNeed: string; + hiddenNeed: string; + irritants: string; + delights: string; + sopRefs: string; + successCriteria: string; + strategy: string; + goal: string; + enabled: boolean; + rubric?: { + code: string; + name: string; + version: string; + dimensions: Array<{ code: string; name: string; weight: number; description: string }>; + }; +}; + +export type PracticeScenarioEnabledRequest = { + enabled: boolean; +}; + +export type PracticePrepCardResponse = { + keyPoints: string[]; + redLines: string[]; + scripts: string[]; + promptVersion: string; +}; + export type PracticeStartResponse = { sessionId: string; scenarioId: string; @@ -34,6 +71,7 @@ export type PracticeTurnRequest = { roundIndex: number; traineeText: string; traineeAudioUrl?: string; + traineeAudioOssId?: number; }; export type PracticeTurnResponse = { @@ -42,6 +80,7 @@ export type PracticeTurnResponse = { emotion: number; trust: number; coachHint: string; + redFlag: boolean; roundIndex: number; finished: boolean; }; @@ -70,6 +109,78 @@ export type PracticeFinishResponse = { }>; }; +export type PracticeCalibrationRequest = { + reviewer?: string; + correctedTotal?: number; + correctedCompliance?: number; + correctedEmotion?: number; + correctedCommunication?: number; + correctedMarketing?: number; + reason?: string; +}; + +export type PracticeCalibrationResponse = { + id: number; + sessionId: string; + originalScore: number; + correctedScore: number; + reviewer: string; + reason: string; + status: string; + createTime: string; +}; + +export type PromptTemplateResponse = { + id: number; + code: string; + name: string; + category: string; + outputFormat: string; + systemPrompt: string; + template: string; + variables: string; + version: string; + enabled: boolean; + description: string; + updateTime: string; +}; + +export type PromptTemplateRequest = { + code: string; + name: string; + category: string; + outputFormat: string; + systemPrompt: string; + template: string; + variables: string; + version: string; + enabled: boolean; + description: string; +}; + +export function listPracticeScenarios(params?: { position?: string; scenarioType?: string }): Promise> { + return request({ + url: '/api/train/practice/scenarios', + method: 'get', + params + }); +} + +export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise> { + return request({ + url: `/api/train/practice/scenarios/${id}/enabled`, + method: 'patch', + data + }); +} + +export function getPracticePrepCard(id: string): Promise> { + return request({ + url: `/api/train/practice/scenarios/${id}/prep-card`, + method: 'get' + }); +} + export function startPracticeSession(data: PracticeStartRequest): Promise> { return request({ url: '/api/train/practice/start', @@ -96,3 +207,44 @@ export function finishPracticeSession(data: PracticeFinishRequest): Promise> { + return request({ + url: `/api/train/practice/sessions/${sessionId}/calibration`, + method: 'post', + data, + headers: { repeatSubmit: false } + }); +} + +export function exportPracticePilotCsv(): Promise { + return request({ + url: '/api/train/practice/export', + method: 'get', + responseType: 'blob' + }) as unknown as Promise; +} + +export function listPromptTemplates(params?: { category?: string; code?: string; enabled?: boolean }): Promise> { + return request({ + url: '/api/aihr/prompt-templates', + method: 'get', + params + }); +} + +export function createPromptTemplate(data: PromptTemplateRequest): Promise> { + return request({ + url: '/api/aihr/prompt-templates', + method: 'post', + data + }); +} + +export function updatePromptTemplate(id: number, data: PromptTemplateRequest): Promise> { + return request({ + url: `/api/aihr/prompt-templates/${id}`, + method: 'put', + data + }); +} diff --git a/frontend/src/api/aihr/sop.ts b/frontend/src/api/aihr/sop.ts index cd6395d0..58df7261 100644 --- a/frontend/src/api/aihr/sop.ts +++ b/frontend/src/api/aihr/sop.ts @@ -36,6 +36,16 @@ export type SopSearchResponse = { snippets: SopSnippet[]; training: string[]; records: SopRecord[]; + keyPoints?: string[]; + cautions?: string[]; + reviewId?: number; +}; + +export type SopReviewResponse = { + id: number; + usable: boolean | null; + status: string; + reviewedAt: string; }; export type SopUploadResponse = { @@ -49,7 +59,7 @@ export type SopUploadResponse = { snippets: SopSnippet[]; }; -export function searchKnowledgeSop(data: { queryText: string; category: string; limit: number }): Promise> { +export function searchKnowledgeSop(data: { queryText: string; category: string; position?: string; source?: string; limit: number }): Promise> { return request({ url: '/api/knowledge/search', method: 'post', @@ -68,6 +78,15 @@ export function uploadKnowledgeDoc(data: FormData): Promise> { + return request({ + url: `/api/knowledge/reviews/${id}`, + method: 'post', + data, + headers: { repeatSubmit: false } + }); +} + export type UploadEnqueueResponse = { itemId: number; batchId: string; diff --git a/frontend/src/views/knowledge/sop.vue b/frontend/src/views/knowledge/sop.vue index 9ed85c56..f4d6ec9a 100644 --- a/frontend/src/views/knowledge/sop.vue +++ b/frontend/src/views/knowledge/sop.vue @@ -50,6 +50,13 @@ AI 回答

{{ answerText }}

引用:{{ referenceText }} +
+ {{ reviewMessage }} +
+ 可用 + 不可用 +
+
点击检索后展示命中文档、原文片段和带引用的 AI 回答。
@@ -105,7 +112,7 @@ import { ElMessage } from 'element-plus'; import type { UploadFile } from 'element-plus'; import { computed, ref } from 'vue'; import { useRouter } from 'vue-router'; -import { searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop'; +import { reviewKnowledgeSop, searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop'; const router = useRouter(); @@ -203,6 +210,8 @@ const apiMode = ref<'seed' | 'api' | 'fallback'>('seed'); const apiResult = ref(null); const uploading = ref(false); const uploadedRecords = ref([]); +const reviewSaving = ref(false); +const reviewStatus = ref(''); const activeSeed = computed(() => sopSeeds[selectedCategory.value]); const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer); @@ -220,6 +229,12 @@ const flowStatus = computed(() => { if (searched.value) return { label: `已命中 ${snippets.value.length} 段`, type: 'pill-primary' }; return { label: '可引用 SOP 内容', type: 'pill-success' }; }); +const canReview = computed(() => apiMode.value === 'api' && Boolean(apiResult.value?.reviewId)); +const reviewMessage = computed(() => { + if (reviewStatus.value) return reviewStatus.value; + if (canReview.value) return `待评审样本 #${apiResult.value?.reviewId}`; + return '后端检索后可评审'; +}); const seedRecords = [ { @@ -257,10 +272,12 @@ function selectCategory(item: Category) { trainingGenerated.value = false; apiMode.value = 'seed'; apiResult.value = null; + reviewStatus.value = ''; } async function runSearch() { trainingGenerated.value = false; + reviewStatus.value = ''; try { const { data } = await searchKnowledgeSop({ queryText: queryText.value, @@ -279,6 +296,28 @@ async function runSearch() { } } +async function markReview(usable: boolean) { + const reviewId = apiResult.value?.reviewId; + if (!reviewId) { + ElMessage.warning('请先完成一次后端 SOP 检索'); + return; + } + reviewSaving.value = true; + try { + const { data } = await reviewKnowledgeSop(reviewId, { + usable, + reviewer: '管理端评审', + note: usable ? '回答可用于试点问答' : '回答需补充或修正' + }); + reviewStatus.value = `${data.status}:${data.usable ? '可用' : '不可用'} ${data.reviewedAt}`; + ElMessage.success('SOP 问答评审已记录'); + } catch { + ElMessage.error('SOP 问答评审失败'); + } finally { + reviewSaving.value = false; + } +} + async function handleDocSelected(uploadFile: UploadFile) { const raw = uploadFile.raw; if (!raw) return; @@ -442,6 +481,19 @@ async function generateTraining() { } } +.review-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 12px; + + small { + color: #657184; + font-size: 12px; + } +} + .empty-state, .training-card { padding: 14px; diff --git a/frontend/src/views/train/practice.vue b/frontend/src/views/train/practice.vue index fa2cef23..5f4782e5 100644 --- a/frontend/src/views/train/practice.vue +++ b/frontend/src/views/train/practice.vue @@ -8,6 +8,7 @@
{{ apiPill.text }} {{ flowPill.text }} + 导出试点CSV 返回首页 重置演示
@@ -30,7 +31,7 @@
训练场景 - +
@@ -42,6 +43,18 @@
+
+
+ Rubric 预览 + {{ selectedScenario.scores.length }} 个维度 +
+

+ {{ score.label }} + {{ score.value }} + {{ score.note }} +

+
+
业主信任度 @@ -50,6 +63,14 @@
+
+
+ 情绪波动 + {{ emotionScore }}% +
+
+
+
开始对练 {{ showGoal ? '收起目标' : '查看目标' }} @@ -59,6 +80,16 @@ 训练目标

{{ selectedScenario.goal }}

+ +
+ 训练前预习卡 + {{ prepLoading ? '加载中' : prepCard?.promptVersion }} + +
@@ -101,8 +132,16 @@
继续一轮 + {{ sopHelpLoading ? '查询中' : '求助 SOP' }} 结束并评分
+ +
+ SOP 求助 +

{{ sopHelp.answer }}

+

{{ sopHelpMessage }}

+ {{ sopHelp.reference }} +
@@ -129,6 +168,24 @@ 导师改写

{{ displayRewrite }}

+
+
+ 人工校准 + {{ calibrationMessage || (canCalibrate ? '用于 M5 分档一致率样本' : '真实 API 评分后可记录') }} +
+ + +
+ 记录校准样本 +
+
@@ -147,15 +204,130 @@ + +
+
+
+ +

提示词模板

+
+ {{ promptTemplates.length }} 个模板 +
+ +
+
+ + + + + + + + + + + + + +
+ + +
+ {{ promptFormMode }} + 新建模板 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 刷新 + 保存模板 +
+
+
+