feat(aihr): add voice answers to daily drills
This commit is contained in:
+14
-3
@@ -261,7 +261,9 @@ public final class AihrPracticeDto {
|
|||||||
String referenceAnswer,
|
String referenceAnswer,
|
||||||
String userAnswer,
|
String userAnswer,
|
||||||
String feedback,
|
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(
|
public record PromptTemplateRequest(
|
||||||
|
|||||||
+20
-8
@@ -913,7 +913,7 @@ public class AihrPracticeSeedService {
|
|||||||
args.add(limit);
|
args.add(limit);
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
|
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
|
FROM aihr_practice_assignment
|
||||||
%s
|
%s
|
||||||
ORDER BY create_time DESC, id DESC
|
ORDER BY create_time DESC, id DESC
|
||||||
@@ -1587,7 +1587,7 @@ public class AihrPracticeSeedService {
|
|||||||
ensureDailyDrills(party);
|
ensureDailyDrills(party);
|
||||||
return jdbcTemplate.query("""
|
return jdbcTemplate.query("""
|
||||||
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
|
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
|
FROM aihr_practice_assignment
|
||||||
WHERE tenant_id = ?
|
WHERE tenant_id = ?
|
||||||
AND (? = '' OR ext_party_id = ?)
|
AND (? = '' OR ext_party_id = ?)
|
||||||
@@ -1625,7 +1625,7 @@ public class AihrPracticeSeedService {
|
|||||||
args.add(normalizeLimit(limit));
|
args.add(normalizeLimit(limit));
|
||||||
return jdbcTemplate.query("""
|
return jdbcTemplate.query("""
|
||||||
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
|
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
|
FROM aihr_practice_assignment
|
||||||
WHERE tenant_id = ?
|
WHERE tenant_id = ?
|
||||||
AND ext_party_id IN (%s)
|
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);
|
""", TENANT_ID, extPartyId, traineeName, scenario.id(), firstNonBlank(request == null ? null : request.scenarioName(), scenario.name()), source, reason, createdAt, createdAt);
|
||||||
List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
|
List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
|
||||||
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
|
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
|
FROM aihr_practice_assignment
|
||||||
WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ?
|
WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ?
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""", this::mapAssignment, TENANT_ID, extPartyId, scenario.id(), source, reason);
|
""", 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) {
|
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"));
|
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 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());
|
DailyDrillScore score = scoreDailyDrill(answer, parts.referenceAnswer());
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE aihr_practice_assignment
|
UPDATE aihr_practice_assignment
|
||||||
SET status = '已完成',
|
SET status = '已完成',
|
||||||
answer_text = ?,
|
answer_text = ?,
|
||||||
|
answer_audio_url = ?,
|
||||||
|
answer_audio_oss_id = ?,
|
||||||
feedback = ?,
|
feedback = ?,
|
||||||
correct = ?,
|
correct = ?,
|
||||||
finished_time = ?,
|
finished_time = ?,
|
||||||
@@ -1771,6 +1775,8 @@ public class AihrPracticeSeedService {
|
|||||||
WHERE tenant_id = ? AND id = ? AND source = 'daily'
|
WHERE tenant_id = ? AND id = ? AND source = 'daily'
|
||||||
""",
|
""",
|
||||||
answer,
|
answer,
|
||||||
|
audioUrl,
|
||||||
|
audioOssId,
|
||||||
score.feedback(),
|
score.feedback(),
|
||||||
score.correct() ? 1 : 0,
|
score.correct() ? 1 : 0,
|
||||||
Timestamp.valueOf(now),
|
Timestamp.valueOf(now),
|
||||||
@@ -1778,7 +1784,7 @@ public class AihrPracticeSeedService {
|
|||||||
TENANT_ID,
|
TENANT_ID,
|
||||||
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<PromptTemplateResponse> promptTemplates(String category, String code, Boolean enabled) {
|
public List<PromptTemplateResponse> promptTemplates(String category, String code, Boolean enabled) {
|
||||||
@@ -2248,7 +2254,9 @@ public class AihrPracticeSeedService {
|
|||||||
"daily".equals(source) && "已完成".equals(status) ? daily.referenceAnswer() : null,
|
"daily".equals(source) && "已完成".equals(status) ? daily.referenceAnswer() : null,
|
||||||
rs.getString("answer_text"),
|
rs.getString("answer_text"),
|
||||||
"daily".equals(source) ? stripReferenceAnswer(rs.getString("feedback")) : rs.getString("feedback"),
|
"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 '派发原因',
|
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
|
||||||
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
|
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
|
||||||
`answer_text` varchar(1000) DEFAULT NULL 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 '每日三题反馈',
|
`feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈',
|
||||||
`correct` tinyint DEFAULT NULL COMMENT '是否答对',
|
`correct` tinyint DEFAULT NULL COMMENT '是否答对',
|
||||||
`finished_time` datetime 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 对练派发任务';
|
) 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", "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", "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`");
|
ensureColumn("aihr_practice_assignment", "finished_time", "ALTER TABLE aihr_practice_assignment ADD COLUMN `finished_time` datetime DEFAULT NULL COMMENT '完成时间' AFTER `correct`");
|
||||||
assignmentTableReady = true;
|
assignmentTableReady = true;
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` (
|
|||||||
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
|
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
|
||||||
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
|
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
|
||||||
`answer_text` varchar(1000) DEFAULT NULL 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 '每日三题反馈',
|
`feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈',
|
||||||
`correct` tinyint DEFAULT NULL COMMENT '是否答对',
|
`correct` tinyint DEFAULT NULL COMMENT '是否答对',
|
||||||
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ curl -fsS "$API_BASE/api/aihr/org/sync" \
|
|||||||
- 模型配置 API 文件:`frontend/src/api/aihr/model.ts`
|
- 模型配置 API 文件:`frontend/src/api/aihr/model.ts`
|
||||||
- 模型配置页面:`frontend/src/views/system/model/index.vue`
|
- 模型配置页面:`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`。
|
- 用户侧工程:`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` 底部展示“候选资料审核”,支持通过/驳回。
|
- 候选人端:`/h5/#/pages/candidate/index/index` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。
|
||||||
- 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。
|
- 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
| 训练音频对象权限 | `AihrMobileController`、`AihrPracticeSeedService`;`GET /api/aihr/mobile/oss/{ossId}` | 已按员工本人、主管项目范围和系统用户区分音频读取权限;本地真实 HTTP 验证:归属员工 200,其他员工 403;生产正式主管范围仍需组织同步数据支撑 |
|
| 训练音频对象权限 | `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 继续通过独立审核接口处理;需继续保留人工复核证据 |
|
| 候选人面试与 HR 复核 | `mobile-uni/src/pages/candidate/*`、`frontend/src/views/recruit/interview.vue` | 工程闭环已在位;候选人资料移动端上传/列表现在只接受 APP 候选人本人身份,HR 继续通过独立审核接口处理;需继续保留人工复核证据 |
|
||||||
| SOP/RAG/资料处理 | `frontend/src/views/knowledge/*`、`backend/.../AihrSopSeedService.java` | 搜索、引用、反馈、异步处理和评审入口已在位 |
|
| 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 用户的读取按项目范围校验,写入接口已增加“主管/项目经理/系统用户”门禁,普通员工不能提交、整理或入库案例;本轮补上管理端上传的项目选择与后端无项目拒绝,避免案例落成员工不可见的无范围记录;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的案例详情/学习点模型,不能用假播放器宣称案例学习闭环完成 |
|
| 案例上传/整理/筛选 | `backend/.../AihrCaseService.java`、`mobile-uni/src/pages/user/cases`、`frontend/src/views/knowledge/cases.vue` | 上传与整理已在位,案例状态在内存缺失时可从 `aihr_case_record` 回读恢复;APP 用户的读取按项目范围校验,写入接口已增加“主管/项目经理/系统用户”门禁,普通员工不能提交、整理或入库案例;本轮补上管理端上传的项目选择与后端无项目拒绝,避免案例落成员工不可见的无范围记录;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的案例详情/学习点模型,不能用假播放器宣称案例学习闭环完成 |
|
||||||
|
|
||||||
## 3. 未完成项与优先级
|
## 3. 未完成项与优先级
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| 顺序 | 页面 | 点击路径 | 必须看到 |
|
| 顺序 | 页面 | 点击路径 | 必须看到 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 |
|
| 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` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 |
|
| 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 |
|
||||||
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 |
|
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 |
|
||||||
| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 |
|
| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 |
|
||||||
|
|||||||
@@ -90,6 +90,24 @@
|
|||||||
:disabled="item.status === '已完成'"
|
:disabled="item.status === '已完成'"
|
||||||
placeholder="写一句你的处理话术"
|
placeholder="写一句你的处理话术"
|
||||||
/>
|
/>
|
||||||
|
<view class="daily-voice-actions">
|
||||||
|
<button
|
||||||
|
class="secondary-button"
|
||||||
|
:class="{ 'recording-active': recording && recordingAssignmentId === item.id }"
|
||||||
|
:disabled="Boolean(answeringId) || transcribing || (recording && recordingAssignmentId !== item.id) || item.status === '已完成'"
|
||||||
|
@click="() => toggleRecording(item.id)"
|
||||||
|
>
|
||||||
|
{{ recording && recordingAssignmentId === item.id ? '停止录音' : '录音转写' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ghost-button"
|
||||||
|
:disabled="Boolean(answeringId) || recording || transcribing || item.status === '已完成'"
|
||||||
|
@click="() => pickAudio(item.id)"
|
||||||
|
>
|
||||||
|
{{ transcribing ? '转写中' : '选音频' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="dailyAudioUrls[item.id]" class="voice-state">已附带语音回放</text>
|
||||||
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
|
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
|
||||||
{{ item.status === '已完成' ? '已完成' : '提交' }}
|
{{ item.status === '已完成' ? '已完成' : '提交' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -159,10 +177,10 @@
|
|||||||
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
|
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
|
||||||
/>
|
/>
|
||||||
<view class="reply-actions">
|
<view class="reply-actions">
|
||||||
<button class="secondary-button" :class="{ 'recording-active': recording }" :disabled="busy || transcribing" @click="toggleRecording">
|
<button class="secondary-button" :class="{ 'recording-active': recording }" :disabled="busy || transcribing" @click="() => toggleRecording()">
|
||||||
{{ recording ? '停止录音' : '录音转写' }}
|
{{ recording ? '停止录音' : '录音转写' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="pickAudio">
|
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="() => pickAudio()">
|
||||||
{{ transcribing ? '转写中' : '选音频' }}
|
{{ transcribing ? '转写中' : '选音频' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
|
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
|
||||||
@@ -289,12 +307,15 @@ const redFlagActive = ref(false);
|
|||||||
const assignments = ref<PracticeAssignment[]>([]);
|
const assignments = ref<PracticeAssignment[]>([]);
|
||||||
const practiceHistory = ref<PracticeRecord[]>([]);
|
const practiceHistory = ref<PracticeRecord[]>([]);
|
||||||
const drillAnswers = ref<Record<number, string>>({});
|
const drillAnswers = ref<Record<number, string>>({});
|
||||||
|
const dailyAudioUrls = ref<Record<number, string>>({});
|
||||||
|
const dailyAudioOssIds = ref<Record<number, number | undefined>>({});
|
||||||
const assignmentLoading = ref(false);
|
const assignmentLoading = ref(false);
|
||||||
const answeringId = ref<number | null>(null);
|
const answeringId = ref<number | null>(null);
|
||||||
const activeAssignmentId = ref<number | undefined>();
|
const activeAssignmentId = ref<number | undefined>();
|
||||||
const highlightedAssignmentId = ref<number | null>(null);
|
const highlightedAssignmentId = ref<number | null>(null);
|
||||||
const loggedIn = ref(false);
|
const loggedIn = ref(false);
|
||||||
const recording = ref(false);
|
const recording = ref(false);
|
||||||
|
const recordingAssignmentId = ref<number | null>(null);
|
||||||
const transcribing = ref(false);
|
const transcribing = ref(false);
|
||||||
const draftAudioUrl = ref('');
|
const draftAudioUrl = ref('');
|
||||||
const draftAudioOssId = ref<number | undefined>();
|
const draftAudioOssId = ref<number | undefined>();
|
||||||
@@ -359,6 +380,8 @@ const clearPracticeData = () => {
|
|||||||
assignments.value = [];
|
assignments.value = [];
|
||||||
practiceHistory.value = [];
|
practiceHistory.value = [];
|
||||||
drillAnswers.value = {};
|
drillAnswers.value = {};
|
||||||
|
dailyAudioUrls.value = {};
|
||||||
|
dailyAudioOssIds.value = {};
|
||||||
prepCard.value = null;
|
prepCard.value = null;
|
||||||
prepLoading.value = false;
|
prepLoading.value = false;
|
||||||
turns.value = [];
|
turns.value = [];
|
||||||
@@ -383,6 +406,7 @@ const clearPracticeData = () => {
|
|||||||
trustScore.value = 0;
|
trustScore.value = 0;
|
||||||
redFlagActive.value = false;
|
redFlagActive.value = false;
|
||||||
answeringId.value = null;
|
answeringId.value = null;
|
||||||
|
recordingAssignmentId.value = null;
|
||||||
activeAssignmentId.value = undefined;
|
activeAssignmentId.value = undefined;
|
||||||
highlightedAssignmentId.value = null;
|
highlightedAssignmentId.value = null;
|
||||||
transcribing.value = false;
|
transcribing.value = false;
|
||||||
@@ -422,6 +446,7 @@ const cancelRecording = () => {
|
|||||||
mediaRecorder.value = null;
|
mediaRecorder.value = null;
|
||||||
stopMediaTracks();
|
stopMediaTracks();
|
||||||
recording.value = false;
|
recording.value = false;
|
||||||
|
recordingAssignmentId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
|
const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
|
||||||
@@ -435,12 +460,37 @@ const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: numb
|
|||||||
message.value = '语音已转成文字,请确认后提交';
|
message.value = '语音已转成文字,请确认后提交';
|
||||||
};
|
};
|
||||||
|
|
||||||
const transcribeBlob = async (blob: Blob) => {
|
const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
|
||||||
|
const text = (result.text || '').trim();
|
||||||
|
if (!text) throw new Error('语音没有识别到文字');
|
||||||
|
const current = (drillAnswers.value[assignmentId] || '').trim();
|
||||||
|
drillAnswers.value = {
|
||||||
|
...drillAnswers.value,
|
||||||
|
[assignmentId]: current ? `${current}\n${text}` : text
|
||||||
|
};
|
||||||
|
dailyAudioUrls.value = {
|
||||||
|
...dailyAudioUrls.value,
|
||||||
|
[assignmentId]: result.audioUrl || ''
|
||||||
|
};
|
||||||
|
const ossId = Number(result.ossId);
|
||||||
|
dailyAudioOssIds.value = {
|
||||||
|
...dailyAudioOssIds.value,
|
||||||
|
[assignmentId]: Number.isFinite(ossId) ? ossId : undefined
|
||||||
|
};
|
||||||
|
message.value = '每日一练语音已转成文字,请确认后提交';
|
||||||
|
};
|
||||||
|
|
||||||
|
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
|
||||||
if (transcribing.value) return;
|
if (transcribing.value) return;
|
||||||
transcribing.value = true;
|
transcribing.value = true;
|
||||||
message.value = '语音转写中';
|
message.value = '语音转写中';
|
||||||
try {
|
try {
|
||||||
applyAsrResult(await transcribeSpeechBlob(blob));
|
const result = await transcribeSpeechBlob(blob);
|
||||||
|
if (assignmentId === undefined) {
|
||||||
|
applyAsrResult(result);
|
||||||
|
} else {
|
||||||
|
applyDailyAsrResult(assignmentId, result);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, '语音转写失败');
|
handleError(error, '语音转写失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -448,14 +498,19 @@ const transcribeBlob = async (blob: Blob) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickAudio = async () => {
|
const pickAudio = async (assignmentId?: number) => {
|
||||||
if (busy.value || recording.value || transcribing.value) return;
|
if (busy.value || recording.value || transcribing.value) return;
|
||||||
if (!requireLogin()) return;
|
if (!requireLogin()) return;
|
||||||
transcribing.value = true;
|
transcribing.value = true;
|
||||||
message.value = '语音转写中';
|
message.value = '语音转写中';
|
||||||
try {
|
try {
|
||||||
const file = await chooseSpeechAudio();
|
const file = await chooseSpeechAudio();
|
||||||
applyAsrResult(await transcribeSpeechFile(file));
|
const result = await transcribeSpeechFile(file);
|
||||||
|
if (assignmentId === undefined) {
|
||||||
|
applyAsrResult(result);
|
||||||
|
} else {
|
||||||
|
applyDailyAsrResult(assignmentId, result);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, '语音转写失败');
|
handleError(error, '语音转写失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -463,11 +518,11 @@ const pickAudio = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async (assignmentId?: number) => {
|
||||||
if (busy.value || transcribing.value) return;
|
if (busy.value || transcribing.value) return;
|
||||||
if (!requireLogin()) return;
|
if (!requireLogin()) return;
|
||||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||||
await pickAudio();
|
await pickAudio(assignmentId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -476,6 +531,7 @@ const startRecording = async () => {
|
|||||||
const chunks: Blob[] = [];
|
const chunks: Blob[] = [];
|
||||||
mediaStream.value = stream;
|
mediaStream.value = stream;
|
||||||
mediaRecorder.value = recorder;
|
mediaRecorder.value = recorder;
|
||||||
|
recordingAssignmentId.value = assignmentId ?? null;
|
||||||
recorder.ondataavailable = (event) => {
|
recorder.ondataavailable = (event) => {
|
||||||
if (event.data?.size) chunks.push(event.data);
|
if (event.data?.size) chunks.push(event.data);
|
||||||
};
|
};
|
||||||
@@ -488,13 +544,16 @@ const startRecording = async () => {
|
|||||||
message.value = '未录到音频,请改用文字输入';
|
message.value = '未录到音频,请改用文字输入';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void transcribeBlob(blob);
|
const targetAssignmentId = assignmentId;
|
||||||
|
recordingAssignmentId.value = null;
|
||||||
|
void transcribeBlob(blob, targetAssignmentId);
|
||||||
};
|
};
|
||||||
recorder.start();
|
recorder.start();
|
||||||
recording.value = true;
|
recording.value = true;
|
||||||
message.value = '正在录音';
|
message.value = '正在录音';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await pickAudio();
|
recordingAssignmentId.value = null;
|
||||||
|
await pickAudio(assignmentId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,12 +564,12 @@ const stopRecording = () => {
|
|||||||
recorder.stop();
|
recorder.stop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleRecording = () => {
|
const toggleRecording = (assignmentId?: number) => {
|
||||||
if (recording.value) {
|
if (recording.value) {
|
||||||
stopRecording();
|
stopRecording();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void startRecording();
|
void startRecording(assignmentId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleError = (error: unknown, fallback: string) => {
|
const handleError = (error: unknown, fallback: string) => {
|
||||||
@@ -543,6 +602,14 @@ const loadAssignments = async () => {
|
|||||||
practiceHistory.value = [];
|
practiceHistory.value = [];
|
||||||
}
|
}
|
||||||
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
|
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
|
||||||
|
dailyAudioUrls.value = Object.fromEntries(
|
||||||
|
assignments.value.filter((item) => item.audioUrl).map((item) => [item.id, item.audioUrl || ''])
|
||||||
|
);
|
||||||
|
dailyAudioOssIds.value = Object.fromEntries(
|
||||||
|
assignments.value
|
||||||
|
.filter((item) => item.audioOssId !== undefined && item.audioOssId !== null)
|
||||||
|
.map((item) => [item.id, Number(item.audioOssId)])
|
||||||
|
);
|
||||||
consumePendingAssignment();
|
consumePendingAssignment();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, '今日任务加载失败');
|
handleError(error, '今日任务加载失败');
|
||||||
@@ -581,17 +648,28 @@ const consumePendingAssignment = () => {
|
|||||||
const submitDaily = async (item: PracticeAssignment) => {
|
const submitDaily = async (item: PracticeAssignment) => {
|
||||||
if (answeringId.value) return;
|
if (answeringId.value) return;
|
||||||
const answer = (drillAnswers.value[item.id] || '').trim();
|
const answer = (drillAnswers.value[item.id] || '').trim();
|
||||||
if (!answer) {
|
const audioUrl = dailyAudioUrls.value[item.id] || undefined;
|
||||||
message.value = '请先填写处理话术';
|
const audioOssId = dailyAudioOssIds.value[item.id];
|
||||||
|
if (!answer && !audioUrl) {
|
||||||
|
message.value = '请先输入或录制处理话术';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
answeringId.value = item.id;
|
answeringId.value = item.id;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
try {
|
try {
|
||||||
const result = await answerDailyDrill(item.id, answer);
|
const result = await answerDailyDrill(item.id, answer, audioUrl, audioOssId);
|
||||||
assignments.value = assignments.value.map((row) =>
|
assignments.value = assignments.value.map((row) =>
|
||||||
row.id === item.id
|
row.id === item.id
|
||||||
? { ...row, status: result.status, userAnswer: answer, feedback: result.feedback, referenceAnswer: result.referenceAnswer, correct: result.correct }
|
? {
|
||||||
|
...row,
|
||||||
|
status: result.status,
|
||||||
|
userAnswer: answer,
|
||||||
|
feedback: result.feedback,
|
||||||
|
referenceAnswer: result.referenceAnswer,
|
||||||
|
correct: result.correct,
|
||||||
|
audioUrl: result.audioUrl || audioUrl,
|
||||||
|
audioOssId: result.audioOssId || audioOssId
|
||||||
|
}
|
||||||
: row
|
: row
|
||||||
);
|
);
|
||||||
message.value = `每日三题得分 ${result.score}`;
|
message.value = `每日三题得分 ${result.score}`;
|
||||||
@@ -1041,6 +1119,19 @@ onShow(() => {
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.daily-voice-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-voice-actions button {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.daily-textarea {
|
.daily-textarea {
|
||||||
min-height: 82px;
|
min-height: 82px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,11 +174,11 @@ export const createPracticeAssignment = (data: PracticeAssignmentRequest) =>
|
|||||||
timeout: 15000
|
timeout: 15000
|
||||||
});
|
});
|
||||||
|
|
||||||
export const answerDailyDrill = (id: number, answer: string) =>
|
export const answerDailyDrill = (id: number, answer: string, audioUrl?: string, audioOssId?: number) =>
|
||||||
apiRequest<DailyDrillAnswerResponse>({
|
apiRequest<DailyDrillAnswerResponse>({
|
||||||
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
|
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { answer },
|
data: { answer, audioUrl, audioOssId },
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ export interface PracticeAssignment {
|
|||||||
userAnswer?: string;
|
userAnswer?: string;
|
||||||
feedback?: string;
|
feedback?: string;
|
||||||
correct?: boolean | null;
|
correct?: boolean | null;
|
||||||
|
audioUrl?: string;
|
||||||
|
audioOssId?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PracticeAssignmentRequest {
|
export interface PracticeAssignmentRequest {
|
||||||
@@ -314,6 +316,8 @@ export interface DailyDrillAnswerResponse {
|
|||||||
score: number;
|
score: number;
|
||||||
feedback: string;
|
feedback: string;
|
||||||
referenceAnswer: string;
|
referenceAnswer: string;
|
||||||
|
audioUrl?: string;
|
||||||
|
audioOssId?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PracticeAlert {
|
export interface PracticeAlert {
|
||||||
|
|||||||
@@ -411,6 +411,10 @@ contains frontend/src/views/train/practice.vue "practice_help"
|
|||||||
contains mobile-uni/src/pages/user/practice/index.vue "loadHelp"
|
contains mobile-uni/src/pages/user/practice/index.vue "loadHelp"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "turnEvidence"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "turnEvidence"
|
||||||
contains mobile-uni/src/pages/user/practice/index.vue "每日三题"
|
contains mobile-uni/src/pages/user/practice/index.vue "每日三题"
|
||||||
|
contains mobile-uni/src/pages/user/practice/index.vue "每日一练语音已转成文字"
|
||||||
|
contains mobile-uni/src/pages/user/practice/index.vue "dailyAudioOssIds"
|
||||||
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "answer_audio_oss_id"
|
||||||
|
contains backend/script/sql/aihr_practice_mysql8.sql "answer_audio_url"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/satisfaction"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/satisfaction"
|
||||||
contains backend/script/sql/aihr_practice_mysql8.sql "satisfaction_score"
|
contains backend/script/sql/aihr_practice_mysql8.sql "satisfaction_score"
|
||||||
|
|||||||
Reference in New Issue
Block a user