From 9ace0cb5c94961b2276c0fd5b274d10a01d8edf1 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 05:51:45 +0800 Subject: [PATCH] feat(case): persist source audio for learning detail --- .../org/dromara/aihr/domain/AihrCaseDto.java | 6 +- .../dromara/aihr/service/AihrCaseService.java | 61 ++++++++++++++++--- .../aihr/service/AihrCaseServiceTest.java | 35 +++++++++-- backend/script/sql/aihr_practice_mysql8.sql | 21 +++++++ docs/API_INTEGRATION.md | 2 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 2 +- docs/DEMO_ACCEPTANCE.md | 2 +- mobile-uni/src/pages/user/cases/index.vue | 16 ++++- mobile-uni/src/types/api.ts | 4 ++ scripts/demo-check.sh | 4 ++ 10 files changed, 133 insertions(+), 20 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java index 778c7c5d..6ed0dbcc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java @@ -10,7 +10,8 @@ public final class AihrCaseDto { public record UploadRequest(String fileName, String projectExtOrgId) { } - public record UploadResponse(String caseId, String fileName, String transcript, List tags, String source) { + public record UploadResponse(String caseId, String fileName, String transcript, List tags, String source, + String mediaUrl, Long mediaOssId) { } public record OrganizeRequest(String caseId) { @@ -38,6 +39,7 @@ public final class AihrCaseDto { } public record DetailResponse(String caseId, String fileName, String title, String type, String status, - String time, String owner, String transcript, String summary) { + String time, String owner, String transcript, String summary, + String mediaUrl, Long mediaOssId) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java index 3df83103..070f4453 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java @@ -13,6 +13,8 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse; import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse; import org.dromara.aihr.domain.AihrCaseDto.TagResponse; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.service.ISysOssService; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -40,6 +42,7 @@ public class AihrCaseService { private final AihrModelSeedService modelService; private final ObjectMapper objectMapper; private final JdbcTemplate jdbcTemplate; + private final ISysOssService ossService; private final Map cases = new ConcurrentHashMap<>(); private volatile boolean tableReady; @@ -54,11 +57,13 @@ public class AihrCaseService { String transcript = speechService.transcribe(file.getBytes(), fileName, contentType) .orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败")); transcript = maskSensitiveText(transcript); + MediaRef media = storeSourceAudio(file); String caseId = "case-" + UUID.randomUUID(); - CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null); + CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null, + media.ossId(), media.url()); cases.put(caseId, state); saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript); - return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr"); + return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", media.url(), media.ossId()); } catch (IllegalStateException e) { throw e; } catch (Exception e) { @@ -70,7 +75,8 @@ public class AihrCaseService { public OrganizeResponse organize(OrganizeRequest request, List projectScopes) { CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes); CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state)); - CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary()); + CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary(), + state.mediaOssId(), state.mediaUrl()); cases.put(state.id(), next); saveCase(next, "已整理", caseTitle(summary.summary(), state.fileName()), summary.aiSummary()); return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source()); @@ -142,7 +148,7 @@ public class AihrCaseService { args.addAll(projectScopes); } String sql = """ - SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary + SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary, media_url, media_oss_id FROM aihr_case_record WHERE tenant_id = '000000' AND case_id = ? """ + projectWhere + " LIMIT 1"; @@ -155,7 +161,9 @@ public class AihrCaseService { rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT), firstNonBlank(rs.getString("owner"), "培训组"), firstNonBlank(rs.getString("transcript"), ""), - firstNonBlank(rs.getString("summary"), "") + firstNonBlank(rs.getString("summary"), ""), + firstNonBlank(rs.getString("media_url"), ""), + rs.getObject("media_oss_id", Long.class) ), args.toArray()).stream().findFirst().orElse(null); } @@ -210,8 +218,8 @@ public class AihrCaseService { ensureCaseTable(); jdbcTemplate.update(""" INSERT INTO aihr_case_record - (tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, owner, create_time, update_time) - VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, '培训组', now(), now()) + (tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, media_oss_id, media_url, owner, create_time, update_time) + VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '培训组', now(), now()) ON DUPLICATE KEY UPDATE file_name = VALUES(file_name), project_ext_org_id = VALUES(project_ext_org_id), @@ -220,6 +228,8 @@ public class AihrCaseService { status = VALUES(status), transcript = VALUES(transcript), summary = VALUES(summary), + media_oss_id = VALUES(media_oss_id), + media_url = VALUES(media_url), update_time = now() """, state.id(), @@ -229,7 +239,9 @@ public class AihrCaseService { primaryTag(state.tags()), status, truncate(maskSensitiveText(state.transcript()), 4000), - truncate(maskSensitiveText(summary), 1000) + truncate(maskSensitiveText(summary), 1000), + state.mediaOssId(), + truncate(state.mediaUrl(), 500) ); } @@ -253,6 +265,8 @@ public class AihrCaseService { `status` varchar(30) DEFAULT '已转写' COMMENT '状态', `transcript` text DEFAULT NULL COMMENT 'ASR转写', `summary` varchar(1000) DEFAULT NULL COMMENT '摘要', + `media_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID', + `media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址', `owner` varchar(80) DEFAULT '培训组' COMMENT '负责人', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', @@ -261,6 +275,8 @@ public class AihrCaseService { KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录'; """); + addCaseColumn("media_oss_id", "ALTER TABLE aihr_case_record ADD COLUMN media_oss_id bigint DEFAULT NULL COMMENT '原始音频OSS文件ID'"); + addCaseColumn("media_url", "ALTER TABLE aihr_case_record ADD COLUMN media_url varchar(500) DEFAULT NULL COMMENT '原始音频访问地址'"); tableReady = true; } } @@ -368,6 +384,7 @@ public class AihrCaseService { ensureCaseTable(); List persisted = jdbcTemplate.query(""" SELECT case_id, file_name, project_ext_org_id, transcript, type + , media_oss_id, media_url FROM aihr_case_record WHERE tenant_id = '000000' AND case_id = ? LIMIT 1 @@ -377,7 +394,9 @@ public class AihrCaseService { firstNonBlank(rs.getString("project_ext_org_id"), ""), firstNonBlank(rs.getString("transcript"), ""), tagsFromText(firstNonBlank(rs.getString("type"), "现场案例")), - null + null, + rs.getObject("media_oss_id", Long.class), + firstNonBlank(rs.getString("media_url"), "") ), normalizedCaseId); state = persisted.stream().findFirst().orElse(null); if (state != null) { @@ -397,6 +416,23 @@ public class AihrCaseService { return String.join(",", Collections.nCopies(size, "?")); } + private MediaRef storeSourceAudio(MultipartFile file) { + // Unit tests can construct this service without the system OSS bean; production always wires it. + if (ossService == null) { + return new MediaRef(null, ""); + } + SysOssVo oss = ossService.upload(file); + return new MediaRef(oss.getOssId(), firstNonBlank(oss.getUrl(), "")); + } + + private void addCaseColumn(String column, String sql) { + try { + jdbcTemplate.execute(sql); + } catch (DataAccessException e) { + log.debug("case table column already exists or cannot be altered: {}", column); + } + } + private static List tagsFromText(String text) { String source = clean(text); List tags = new ArrayList<>(); @@ -477,10 +513,15 @@ public class AihrCaseService { String projectExtOrgId, String transcript, List tags, - List summary + List summary, + Long mediaOssId, + String mediaUrl ) { } + private record MediaRef(Long ossId, String url) { + } + private record CaseSummary(List summary, List tags, String aiSummary, String source) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java index 31e03eb4..295a0a18 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java @@ -2,6 +2,8 @@ package org.dromara.aihr.service; import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.service.ISysOssService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; @@ -32,7 +34,7 @@ class AihrCaseServiceTest { } }; CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate(); - AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate); + AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, null); UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null); @@ -44,11 +46,36 @@ class AihrCaseServiceTest { assertFalse(response.transcript().contains("2-304")); } + @Test + @Tag("dev") + void uploadPersistsSourceAudioReferenceWhenOssIsAvailable() { + AihrSpeechService speechService = new AihrSpeechService(null, new ObjectMapper()) { + @Override + public Optional transcribe(byte[] audio, String filename, String contentType) { + return Optional.of("现场投诉已完成跟进"); + } + }; + SysOssVo oss = new SysOssVo(); + oss.setOssId(123L); + oss.setUrl("https://oss.local/cases/case.webm"); + ISysOssService ossService = mock(ISysOssService.class); + when(ossService.upload(org.mockito.ArgumentMatchers.any(org.springframework.web.multipart.MultipartFile.class))).thenReturn(oss); + CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate(); + AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, ossService); + + UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null); + + assertEquals(123L, response.mediaOssId()); + assertEquals("https://oss.local/cases/case.webm", response.mediaUrl()); + assertEquals(123L, jdbcTemplate.updateArgs[8]); + assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[9]); + } + @Test @Tag("dev") void curateCanReloadCaseAfterServiceRestart() { PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate(); - AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate); + AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null); var response = service.curate(new org.dromara.aihr.domain.AihrCaseDto.CurateRequest( "persisted-case", "可复用培训案例", 1 @@ -65,7 +92,7 @@ class AihrCaseServiceTest { @Tag("dev") void detailReturnsPersistedCaseForAuthorizedProject() { PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate(); - AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate); + AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null); var detail = service.detail("persisted-case", List.of("P1")); @@ -86,7 +113,7 @@ class AihrCaseServiceTest { @Test @Tag("dev") void systemUploadRequiresExplicitProject() { - AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), new CaseJdbcTemplate()); + AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), new CaseJdbcTemplate(), null); assertThrows(IllegalArgumentException.class, () -> service.upload( new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "", null diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index 127d585a..233bc0aa 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -1,6 +1,27 @@ -- 物业 AI HR 对练记录最小表结构。 -- ponytail: one session table covers history, review list and profile aggregation for MVP. +CREATE TABLE IF NOT EXISTS `aihr_case_record` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `case_id` varchar(80) NOT NULL COMMENT '案例ID', + `file_name` varchar(255) DEFAULT NULL COMMENT '来源文件', + `project_ext_org_id` varchar(100) DEFAULT NULL COMMENT '项目外部组织ID', + `title` varchar(120) DEFAULT NULL COMMENT '标题', + `type` varchar(80) DEFAULT NULL COMMENT '案例类型', + `status` varchar(30) DEFAULT '已转写' COMMENT '状态', + `transcript` text DEFAULT NULL COMMENT 'ASR转写', + `summary` varchar(1000) DEFAULT NULL COMMENT '摘要', + `media_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID', + `media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址', + `owner` varchar(80) DEFAULT '培训组' COMMENT '负责人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_case_record_case` (`tenant_id`, `case_id`), + KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录'; + CREATE TABLE IF NOT EXISTS `aihr_practice_session` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index f3b29fc2..b7b9429c 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -10,7 +10,7 @@ | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | -| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;移动端可查看持久化转写/摘要详情;管理端系统用户保留全局运营视图;视频样片、学习点和主管点评仍未接入 | +| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/upload` 改为 multipart 真实语音上传并走 ASR,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;移动端可查看持久化转写/摘要并回放原始音频;管理端系统用户保留全局运营视图;预渲染视频样片、学习点和主管点评仍未接入 | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;`position-sop` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 | diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 979ce58d..cc531648 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -18,7 +18,7 @@ | 候选人面试与 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` | 每日三题现支持文字或录音转写,语音 `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 用户的读取按项目范围校验,写入接口已增加“主管/项目经理/系统用户”门禁,普通员工不能提交、整理或入库案例;原始音频现在上传到 `sys_oss` 并在 `aihr_case_record.media_oss_id/media_url` 留痕,员工详情可回放;本轮补上管理端上传的项目选择与后端无项目拒绝,避免案例落成员工不可见的无范围记录;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的学习点/主管点评模型,不能用音频回放冒充完整案例学习闭环 | ## 3. 未完成项与优先级 diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index 246dd42e..e3cd747b 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -18,7 +18,7 @@ | 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;员工端从今日页进入问师傅、练习、学习、案例素材和个人页;候选首页按真实状态进入独立 `/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;主管首页进入独立团队画像、指派、复盘列表和复盘详情 | 员工端显示训练评分、历史和画像;候选面试页可真实拉题/答题/评分,资料页显示审核三态,进度页展示办理步骤,预习页从正式知识库返回依据;主管端待复盘列表、复盘详情、专项指派和团队范围均为独立页面且普通员工无主管权限 | | 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 | | 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 | -| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看案例详情 | 已完成语音转写、整理、入库和按项目权限查看持久化转写/摘要详情;稳定媒体 URL、视频样片、学习点和主管点评尚未形成,不把“查看样片”作为当前完成证据 | +| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看案例详情/回放原始音频 | 已完成语音转写、原始音频 OSS 留痕、整理、入库和按项目权限查看持久化转写/摘要详情;预渲染视频样片、学习点和主管点评尚未形成,不把音频回放当作“查看样片”完成证据 | | 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 | | 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 | diff --git a/mobile-uni/src/pages/user/cases/index.vue b/mobile-uni/src/pages/user/cases/index.vue index 58a5dc1b..0a145fdc 100644 --- a/mobile-uni/src/pages/user/cases/index.vue +++ b/mobile-uni/src/pages/user/cases/index.vue @@ -104,7 +104,11 @@ {{ caseDetail.transcript || '暂无转写内容' }} 案例摘要 {{ caseDetail.summary || '暂无案例摘要' }} - 当前版本提供转写、摘要和状态详情;视频样片、学习点与主管点评待正式媒体和业务字段接入。 + + 原始音频 + + 当前版本已保留原始音频回放;预渲染视频样片、学习点与主管点评待正式媒体和业务字段接入。 @@ -369,6 +373,16 @@ onShow(() => { font-weight: 700; } +.case-media-block { + display: flex; + flex-direction: column; + gap: 8px; +} + +.case-audio { + width: 100%; +} + .tag-row { display: flex; flex-wrap: wrap; diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index 55cb5d38..4cae38da 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -386,6 +386,8 @@ export interface CaseDetailResponse { owner: string; transcript: string; summary: string; + mediaUrl?: string; + mediaOssId?: number; } export interface CaseUploadResponse { @@ -394,6 +396,8 @@ export interface CaseUploadResponse { transcript: string; tags: CaseTag[]; source: string; + mediaUrl?: string; + mediaOssId?: number; } export interface CaseOrganizeResponse { diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 5fe0c5b6..225830c7 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -452,6 +452,10 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "aihr_knowledge_gap" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "仅项目负责人或主管可提交案例素材" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "position_level IN ('主管', '项目经理')" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "media_oss_id" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "storeSourceAudio" +contains mobile-uni/src/pages/user/cases/index.vue "case-audio" +contains backend/script/sql/aihr_practice_mysql8.sql "aihr_case_record" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "prompt_version" contains backend/script/sql/aihr_practice_mysql8.sql "prompt_version" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "review_id"