From 9bbfd575a3c4f76bc82d1cb4ac377da2c78bc3f1 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 05:44:26 +0800 Subject: [PATCH] feat(aihr): add voice answers to daily drills --- .../dromara/aihr/domain/AihrPracticeDto.java | 17 ++- .../aihr/service/AihrPracticeSeedService.java | 28 ++-- backend/script/sql/aihr_practice_mysql8.sql | 2 + docs/API_INTEGRATION.md | 2 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 2 +- docs/DEMO_ACCEPTANCE.md | 2 +- mobile-uni/src/pages/user/practice/index.vue | 123 +++++++++++++++--- mobile-uni/src/services/practice.ts | 4 +- mobile-uni/src/types/api.ts | 4 + scripts/demo-check.sh | 4 + 10 files changed, 156 insertions(+), 32 deletions(-) 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 4a292829..1d939df5 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 @@ -261,7 +261,9 @@ public final class AihrPracticeDto { String referenceAnswer, String userAnswer, String feedback, - Boolean correct + Boolean correct, + String audioUrl, + Long audioOssId ) { } @@ -276,10 +278,19 @@ public final class AihrPracticeDto { ) { } - public record DailyDrillAnswerRequest(String answer) { + public record DailyDrillAnswerRequest(String answer, String audioUrl, Long audioOssId) { } - public record DailyDrillAnswerResponse(Long id, String status, Boolean correct, Integer score, String feedback, String referenceAnswer) { + public record DailyDrillAnswerResponse( + Long id, + String status, + Boolean correct, + Integer score, + String feedback, + String referenceAnswer, + String audioUrl, + Long audioOssId + ) { } public record PromptTemplateRequest( 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 a58d0033..12888908 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 @@ -913,7 +913,7 @@ public class AihrPracticeSeedService { args.add(limit); String sql = """ SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, - answer_text, feedback, correct + answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct FROM aihr_practice_assignment %s ORDER BY create_time DESC, id DESC @@ -1587,7 +1587,7 @@ public class AihrPracticeSeedService { 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 + answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct FROM aihr_practice_assignment WHERE tenant_id = ? AND (? = '' OR ext_party_id = ?) @@ -1625,7 +1625,7 @@ public class AihrPracticeSeedService { args.add(normalizeLimit(limit)); return jdbcTemplate.query(""" SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, - answer_text, feedback, correct + answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct FROM aihr_practice_assignment WHERE tenant_id = ? AND ext_party_id IN (%s) @@ -1694,13 +1694,13 @@ public class AihrPracticeSeedService { """, TENANT_ID, extPartyId, traineeName, scenario.id(), firstNonBlank(request == null ? null : request.scenarioName(), scenario.name()), source, reason, createdAt, createdAt); List rows = jdbcTemplate.query(""" SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, - answer_text, feedback, correct + answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct FROM aihr_practice_assignment WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ? ORDER BY id DESC LIMIT 1 """, this::mapAssignment, TENANT_ID, extPartyId, scenario.id(), source, reason); - 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); + return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null) : rows.get(0); } public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) { @@ -1758,12 +1758,16 @@ public class AihrPracticeSeedService { } DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id")); String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), "")); + String audioUrl = firstNonBlank(request == null ? null : request.audioUrl(), ""); + Long audioOssId = request == null ? null : request.audioOssId(); DailyDrillScore score = scoreDailyDrill(answer, parts.referenceAnswer()); LocalDateTime now = LocalDateTime.now(); jdbcTemplate.update(""" UPDATE aihr_practice_assignment SET status = '已完成', answer_text = ?, + answer_audio_url = ?, + answer_audio_oss_id = ?, feedback = ?, correct = ?, finished_time = ?, @@ -1771,6 +1775,8 @@ public class AihrPracticeSeedService { WHERE tenant_id = ? AND id = ? AND source = 'daily' """, answer, + audioUrl, + audioOssId, score.feedback(), score.correct() ? 1 : 0, Timestamp.valueOf(now), @@ -1778,7 +1784,7 @@ public class AihrPracticeSeedService { TENANT_ID, id ); - return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer()); + return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer(), audioUrl, audioOssId); } public List promptTemplates(String category, String code, Boolean enabled) { @@ -2248,7 +2254,9 @@ public class AihrPracticeSeedService { "daily".equals(source) && "已完成".equals(status) ? daily.referenceAnswer() : null, rs.getString("answer_text"), "daily".equals(source) ? stripReferenceAnswer(rs.getString("feedback")) : rs.getString("feedback"), - correctValue == null ? null : rs.getBoolean("correct") + correctValue == null ? null : rs.getBoolean("correct"), + rs.getString("answer_audio_url"), + rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id") ); } @@ -3057,6 +3065,8 @@ public class AihrPracticeSeedService { `reason` varchar(500) DEFAULT NULL COMMENT '派发原因', `status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态', `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答', + `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址', + `answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号', `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈', `correct` tinyint DEFAULT NULL COMMENT '是否答对', `finished_time` datetime DEFAULT NULL COMMENT '完成时间', @@ -3068,7 +3078,9 @@ public class AihrPracticeSeedService { ) 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", "answer_audio_url", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址' AFTER `answer_text`"); + ensureColumn("aihr_practice_assignment", "answer_audio_oss_id", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号' AFTER `answer_audio_url`"); + ensureColumn("aihr_practice_assignment", "feedback", "ALTER TABLE aihr_practice_assignment ADD COLUMN `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈' AFTER `answer_audio_oss_id`"); 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; diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index 39e4f5ac..127d585a 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -78,6 +78,8 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` ( `reason` varchar(500) DEFAULT NULL COMMENT '派发原因', `status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态', `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答', + `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址', + `answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号', `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈', `correct` tinyint DEFAULT NULL COMMENT '是否答对', `finished_time` datetime DEFAULT NULL COMMENT '完成时间', diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 6b8c37cd..f3b29fc2 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -138,7 +138,7 @@ curl -fsS "$API_BASE/api/aihr/org/sync" \ - 模型配置 API 文件:`frontend/src/api/aihr/model.ts` - 模型配置页面:`frontend/src/views/system/model/index.vue` - 用户侧工程:`mobile-uni/`,路由为 `/h5/#/pages/user/today/index`、`/h5/#/pages/user/practice/index`、`/h5/#/pages/user/sop/index`、`/h5/#/pages/user/profile/index`、`/h5/#/pages/candidate/index/index`、`/h5/#/pages/supervisor/index/index`。未登录先走手机号短信登录,登录后 API 优先请求真实业务接口,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。 -- 员工端训练:底部“练”进入 `/h5/#/pages/user/practice/index`,展示今日任务、每日三题、专项训练、训练前预习卡和训练对话;完成后记录写入 `aihr_practice_session`,员工端可提交 1-5 分训练体验和可选意见,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。 +- 员工端训练:底部“练”进入 `/h5/#/pages/user/practice/index`,展示今日任务、每日三题、专项训练、训练前预习卡和训练对话;每日三题支持文字或录音转写,作答接口 `/api/aihr/mobile/practice/assignments/{id}/answer` 可携带 `answer/audioUrl/audioOssId`,语音地址会随作业记录保存;完成后记录写入 `aihr_practice_session`,员工端可提交 1-5 分训练体验和可选意见,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。 - 候选人端:`/h5/#/pages/candidate/index/index` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。 - 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 905afa1d..979ce58d 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -17,7 +17,7 @@ | 训练音频对象权限 | `AihrMobileController`、`AihrPracticeSeedService`;`GET /api/aihr/mobile/oss/{ossId}` | 已按员工本人、主管项目范围和系统用户区分音频读取权限;本地真实 HTTP 验证:归属员工 200,其他员工 403;生产正式主管范围仍需组织同步数据支撑 | | 候选人面试与 HR 复核 | `mobile-uni/src/pages/candidate/*`、`frontend/src/views/recruit/interview.vue` | 工程闭环已在位;候选人资料移动端上传/列表现在只接受 APP 候选人本人身份,HR 继续通过独立审核接口处理;需继续保留人工复核证据 | | SOP/RAG/资料处理 | `frontend/src/views/knowledge/*`、`backend/.../AihrSopSeedService.java` | 搜索、引用、反馈、异步处理和评审入口已在位 | -| 对练/每日题/主管带教 | `backend/.../AihrPracticeSeedService.java`、`mobile-uni/src/pages/user/practice` | 主闭环已在位;低分再练、错题归因仍需更明确的用户可见闭环 | +| 对练/每日题/主管带教 | `backend/.../AihrPracticeSeedService.java`、`mobile-uni/src/pages/user/practice` | 每日三题现支持文字或录音转写,语音 `audioUrl/ossId` 随作业提交并持久化;普通陪练、即时反馈、低分再练、错题归因和主管带教已在位,正式窗口仍需验证真实音频与低分样本 | | 案例上传/整理/筛选 | `backend/.../AihrCaseService.java`、`mobile-uni/src/pages/user/cases`、`frontend/src/views/knowledge/cases.vue` | 上传与整理已在位,案例状态在内存缺失时可从 `aihr_case_record` 回读恢复;APP 用户的读取按项目范围校验,写入接口已增加“主管/项目经理/系统用户”门禁,普通员工不能提交、整理或入库案例;本轮补上管理端上传的项目选择与后端无项目拒绝,避免案例落成员工不可见的无范围记录;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的案例详情/学习点模型,不能用假播放器宣称案例学习闭环完成 | ## 3. 未完成项与优先级 diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index 6da98e32..246dd42e 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -27,7 +27,7 @@ | 顺序 | 页面 | 点击路径 | 必须看到 | |---|---|---|---| | 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 | -| 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 | +| 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题文字或录音作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈且语音有回放留痕;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 | | 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 | | 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 | | 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 | diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index 1aa22ac1..166d2f91 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -90,6 +90,24 @@ :disabled="item.status === '已完成'" placeholder="写一句你的处理话术" /> + + + + + 已附带语音回放 @@ -159,10 +177,10 @@ placeholder="先承接业主情绪,再说明处理动作和反馈时间。" /> - -