diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java index 642e82b2..0f7a0a5d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java @@ -14,6 +14,7 @@ import org.dromara.aihr.domain.AihrCaseDto.DetailResponse; import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest; import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse; import org.dromara.aihr.domain.AihrCaseDto.RecordResponse; +import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; import org.dromara.aihr.service.AihrCaseService; import org.dromara.common.core.domain.R; @@ -84,6 +85,15 @@ public class AihrCaseController { return detail == null ? R.fail("案例不存在或无权访问") : R.ok(detail); } + @PostMapping("/records/{caseId}/review") + public R review(@PathVariable String caseId, @RequestBody ReviewRequest request) { + if (!canManageCases()) { + return R.fail("仅项目负责人或主管可提交案例点评"); + } + caseService.review(caseId, request, currentProjectScopes()); + return R.ok(); + } + @GetMapping("/capabilities") public R capabilities() { return R.ok(new CapabilitiesResponse(canManageCases(), canViewCases())); 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 0fd7bf58..0d942fc0 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 @@ -17,12 +17,16 @@ public final class AihrCaseDto { public record OrganizeRequest(String caseId) { } - public record OrganizeResponse(String caseId, List summary, List tags, String aiSummary, String source) { + public record OrganizeResponse(String caseId, List summary, List tags, + List learningPoints, String aiSummary, String source) { } public record CurateRequest(String caseId, String criteria, Integer limit) { } + public record ReviewRequest(String comment) { + } + public record CurateResponse(List selectedCaseIds, List records, String sampleHint) { } @@ -43,6 +47,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 mediaUrl, Long mediaOssId) { + String mediaUrl, Long mediaOssId, List learningPoints, + String supervisorComment) { } } 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 dc111449..edcb3d44 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 @@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrCaseDto.DetailResponse; import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest; import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse; import org.dromara.aihr.domain.AihrCaseDto.RecordResponse; +import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest; import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse; import org.dromara.aihr.domain.AihrCaseDto.TagResponse; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; @@ -73,7 +74,7 @@ public class AihrCaseService { mediaOssId = media.ossId(); caseId = "case-" + UUID.randomUUID(); CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null, - media.ossId(), media.url(), currentCaseOwner()); + List.of(), "", media.ossId(), media.url(), currentCaseOwner()); cases.put(caseId, state); saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript); return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", media.url(), media.ossId()); @@ -90,11 +91,12 @@ 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)); + List learningPoints = learningPointsFromSummary(summary.summary()); CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary(), - state.mediaOssId(), state.mediaUrl(), state.owner()); + learningPoints, state.supervisorComment(), state.mediaOssId(), state.mediaUrl(), state.owner()); 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()); + return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), learningPoints, summary.aiSummary(), summary.source()); } public CurateResponse curate(CurateRequest request, List projectScopes) { @@ -117,6 +119,19 @@ public class AihrCaseService { return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,已进入案例库。"); } + public void review(String caseId, ReviewRequest request, List projectScopes) { + CaseState state = requireCase(caseId, projectScopes); + String comment = truncate(maskSensitiveText(request == null ? "" : request.comment()), 1000); + jdbcTemplate.update(""" + UPDATE aihr_case_record + SET supervisor_comment = ?, update_time = now() + WHERE tenant_id = ? AND case_id = ? + """, comment, tenantId(), state.id()); + cases.computeIfPresent(state.id(), (id, current) -> new CaseState(current.id(), current.fileName(), current.projectExtOrgId(), + current.transcript(), current.tags(), current.summary(), current.learningPoints(), comment, + current.mediaOssId(), current.mediaUrl(), current.owner())); + } + public List records(Integer limit, List projectScopes) { ensureCaseTable(); if (projectScopes != null && projectScopes.isEmpty()) { @@ -166,7 +181,8 @@ public class AihrCaseService { } String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'"; String sql = """ - SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary, media_url, media_oss_id + SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary, media_url, media_oss_id, + learning_points, supervisor_comment FROM aihr_case_record WHERE tenant_id = ? AND case_id = ? """ + projectWhere + publishedWhere + " LIMIT 1"; @@ -184,7 +200,9 @@ public class AihrCaseService { firstNonBlank(rs.getString("transcript"), ""), firstNonBlank(rs.getString("summary"), ""), resolveMediaUrl(mediaOssId, rs.getString("media_url")), - mediaOssId + mediaOssId, + parseLearningPoints(rs.getString("learning_points")), + firstNonBlank(rs.getString("supervisor_comment"), "") ); }, args.toArray()).stream().findFirst().orElse(null); } @@ -240,8 +258,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, media_oss_id, media_url, owner, create_time, update_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + (tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, learning_points, supervisor_comment, media_oss_id, media_url, owner, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) ON DUPLICATE KEY UPDATE file_name = VALUES(file_name), project_ext_org_id = VALUES(project_ext_org_id), @@ -250,6 +268,8 @@ public class AihrCaseService { status = VALUES(status), transcript = VALUES(transcript), summary = VALUES(summary), + learning_points = VALUES(learning_points), + supervisor_comment = COALESCE(NULLIF(VALUES(supervisor_comment), ''), supervisor_comment), media_oss_id = VALUES(media_oss_id), media_url = VALUES(media_url), update_time = now() @@ -263,6 +283,8 @@ public class AihrCaseService { status, truncate(maskSensitiveText(state.transcript()), 4000), truncate(maskSensitiveText(summary), 1000), + learningPointsJson(state.learningPoints()), + firstNonBlank(state.supervisorComment(), ""), state.mediaOssId(), truncate(state.mediaUrl(), 500), firstNonBlank(state.owner(), "培训组") @@ -289,6 +311,8 @@ public class AihrCaseService { `status` varchar(30) DEFAULT '已转写' COMMENT '状态', `transcript` text DEFAULT NULL COMMENT 'ASR转写', `summary` varchar(1000) DEFAULT NULL COMMENT '摘要', + `learning_points` text DEFAULT NULL COMMENT '案例学习点JSON', + `supervisor_comment` 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 '负责人', @@ -301,6 +325,8 @@ public class AihrCaseService { """); 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 '原始音频访问地址'"); + addCaseColumn("learning_points", "ALTER TABLE aihr_case_record ADD COLUMN learning_points text DEFAULT NULL COMMENT '案例学习点JSON'"); + addCaseColumn("supervisor_comment", "ALTER TABLE aihr_case_record ADD COLUMN supervisor_comment varchar(1000) DEFAULT NULL COMMENT '主管点评'"); tableReady = true; } } @@ -411,8 +437,8 @@ public class AihrCaseService { if (state == null && !normalizedCaseId.isBlank()) { ensureCaseTable(); List persisted = jdbcTemplate.query(""" - SELECT case_id, file_name, project_ext_org_id, transcript, type - , media_oss_id, media_url, owner + SELECT case_id, file_name, project_ext_org_id, transcript, type, + media_oss_id, media_url, learning_points, supervisor_comment, owner FROM aihr_case_record WHERE tenant_id = ? AND case_id = ? LIMIT 1 @@ -423,6 +449,8 @@ public class AihrCaseService { firstNonBlank(rs.getString("transcript"), ""), tagsFromText(firstNonBlank(rs.getString("type"), "现场案例")), null, + parseLearningPoints(rs.getString("learning_points")), + firstNonBlank(rs.getString("supervisor_comment"), ""), rs.getObject("media_oss_id", Long.class), firstNonBlank(rs.getString("media_url"), ""), firstNonBlank(rs.getString("owner"), "培训组") @@ -526,6 +554,53 @@ public class AihrCaseService { return tags.isEmpty() ? "现场案例" : tags.get(0).text(); } + static List learningPointsFromSummary(List summary) { + if (summary == null || summary.isEmpty()) { + return List.of(); + } + return summary.stream() + .filter(item -> item != null && !clean(item.text()).isBlank()) + .filter(item -> Set.of("处理", "结果", "亮点").contains(clean(item.label()))) + .map(item -> clean(item.label()) + ":" + truncate(maskSensitiveText(item.text()), 160)) + .limit(5) + .toList(); + } + + private List parseLearningPoints(String value) { + String json = clean(value); + if (json.isBlank()) { + return List.of(); + } + try { + JsonNode node = objectMapper.readTree(json); + if (!node.isArray()) { + return List.of(); + } + List points = new ArrayList<>(); + node.forEach(item -> { + String point = clean(item.asText("")); + if (!point.isBlank() && points.size() < 5) { + points.add(maskSensitiveText(point)); + } + }); + return points; + } catch (Exception e) { + return List.of(); + } + } + + private String learningPointsJson(List points) { + try { + return objectMapper.writeValueAsString(points == null ? List.of() : points.stream() + .filter(point -> !clean(point).isBlank()) + .map(point -> truncate(maskSensitiveText(point), 180)) + .limit(5) + .toList()); + } catch (Exception e) { + return "[]"; + } + } + private static String tagType(int index) { return switch (index % 3) { case 0 -> "pill-success"; @@ -592,6 +667,8 @@ public class AihrCaseService { String transcript, List tags, List summary, + List learningPoints, + String supervisorComment, Long mediaOssId, String mediaUrl, String owner 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 42a922e7..1dbbf5f5 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,7 @@ package org.dromara.aihr.service; import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; +import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.service.ISysOssService; import org.junit.jupiter.api.Tag; @@ -70,8 +71,40 @@ class AihrCaseServiceTest { assertEquals(123L, response.mediaOssId()); assertEquals("https://oss.local/cases/case.webm", response.mediaUrl()); - assertEquals(123L, jdbcTemplate.updateArgs[9]); - assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[10]); + assertEquals(123L, jdbcTemplate.updateArgs[11]); + assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[12]); + } + + @Test + @Tag("dev") + void learningPointsUseOnlyActionResultAndHighlightSummaryItems() { + List points = AihrCaseService.learningPointsFromSummary(List.of( + new org.dromara.aihr.domain.AihrCaseDto.SummaryResponse("背景", "业主提出问题"), + new org.dromara.aihr.domain.AihrCaseDto.SummaryResponse("处理", "确认责任人和回访时限"), + new org.dromara.aihr.domain.AihrCaseDto.SummaryResponse("结果", "问题已跟进"), + new org.dromara.aihr.domain.AihrCaseDto.SummaryResponse("亮点", "记录了后续节点") + )); + + assertEquals(List.of("处理:确认责任人和回访时限", "结果:问题已跟进", "亮点:记录了后续节点"), points); + } + + @Test + @Tag("dev") + void supervisorReviewMasksSensitiveCommentBeforePersisting() { + AihrSpeechService speechService = new AihrSpeechService(null, new ObjectMapper()) { + @Override + public Optional transcribe(byte[] audio, String filename, String contentType) { + return Optional.of("现场投诉已完成跟进"); + } + }; + CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate(); + AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, null); + UploadResponse upload = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null); + + service.review(upload.caseId(), new ReviewRequest("请联系13900001111并查看2-304"), null); + + assertEquals("请联系1390****1111并查看2-****", jdbcTemplate.updateArgs[0]); + assertEquals(upload.caseId(), jdbcTemplate.updateArgs[2]); } @Test diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index a40e05c7..a673a5a3 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS `aihr_case_record` ( `status` varchar(30) DEFAULT '已转写' COMMENT '状态', `transcript` text DEFAULT NULL COMMENT 'ASR转写', `summary` varchar(1000) DEFAULT NULL COMMENT '摘要', + `learning_points` text DEFAULT NULL COMMENT '案例学习点JSON', + `supervisor_comment` 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 '负责人', diff --git a/backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql b/backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql new file mode 100644 index 00000000..3d1e9c3b --- /dev/null +++ b/backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql @@ -0,0 +1,41 @@ +-- AIHR 案例学习点/主管点评迁移(MySQL 8.x) +-- 仅补齐案例学习字段,不写入业务内容;可重复执行。 + +SET @aihr_case_table_exists := ( + SELECT COUNT(*) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'aihr_case_record' +); + +SET @aihr_learning_points_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'aihr_case_record' + AND COLUMN_NAME = 'learning_points' +); +SET @aihr_learning_points_ddl := IF( + @aihr_case_table_exists = 1 AND @aihr_learning_points_exists = 0, + 'ALTER TABLE aihr_case_record ADD COLUMN `learning_points` text DEFAULT NULL COMMENT ''案例学习点JSON'' AFTER `summary`', + 'SELECT 1' +); +PREPARE aihr_learning_points_stmt FROM @aihr_learning_points_ddl; +EXECUTE aihr_learning_points_stmt; +DEALLOCATE PREPARE aihr_learning_points_stmt; + +SET @aihr_supervisor_comment_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'aihr_case_record' + AND COLUMN_NAME = 'supervisor_comment' +); +SET @aihr_supervisor_comment_ddl := IF( + @aihr_case_table_exists = 1 AND @aihr_supervisor_comment_exists = 0, + 'ALTER TABLE aihr_case_record ADD COLUMN `supervisor_comment` varchar(1000) DEFAULT NULL COMMENT ''主管点评'' AFTER `learning_points`', + 'SELECT 1' +); +PREPARE aihr_supervisor_comment_stmt FROM @aihr_supervisor_comment_ddl; +EXECUTE aihr_supervisor_comment_stmt; +DEALLOCATE PREPARE aihr_supervisor_comment_stmt; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index c539ff51..5be9bedf 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -11,7 +11,7 @@ | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);移动端 `turn/finish` 校验当前手机号与启动会话归属,未知或他人会话拒绝;模型未配置或调用失败自动回退 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}`,成功返回 OSS `audioUrl + ossId`,OSS 上传失败才回退 `inlineAudioUrl` dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问;正常 TTS 响应不重复携带完整 base64 音频 | -| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情均可回放原始音频;预渲染视频样片、学习点和主管点评仍未接入 | +| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `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` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `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 d548e2c7..e90a29c0 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -14,6 +14,7 @@ - 2026-07-14 23:16 生产训练 schema 只读复核:生产缺少 `aihr_practice_session` 的 `dim_task_completion/dim_response_timeliness/response_latency_ms`、`aihr_practice_assignment` 的 `score/score_mode`、SOP 评审的 `prompt_version/requester_ext_party_id` 和组织快照 `hire_date`;对应版本化迁移已存在,但尚未在线上执行。严格门禁现增加会话与每日题字段计数,避免发布后依赖运行时懒迁移。 - 2026-07-14 23:18 生产 Prompt 内容只读复核:生产 `aihr_prompt_template` 表存在但内置模板数量为 `0`;现新增 `aihr_20260714_prompt_template_seed_mysql8.sql`,按唯一键幂等补齐 6 个内置模板,不覆盖同 code/output_format/version 的自定义模板。未写入生产数据。 - 2026-07-14 23:25 生产迁移顺序固化:新增 `docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md`,明确场景/Rubric 基础表、场景与 SOP/Prompt 内置内容、音频/案例媒体、五维与字段迁移的执行顺序,并提供迁移后只读 schema/模板核验 SQL;未执行生产写入,也不把 seed/schema 完成等同于正式试点通过。 +- 2026-07-14 案例学习闭环补齐:案例整理结果现在从真实摘要提取并持久化 `learning_points`,主管可通过 `/api/knowledge/case/records/{caseId}/review` 提交脱敏点评,管理端和员工端详情可查看;新增 `aihr_20260714_case_learning_mysql8.sql`,未新增视频占位资源,也未修改生产数据。 更新时间:2026-07-14 diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 0f037a15..3b50b953 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -30,6 +30,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_case_media_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_daily_drill_score_mode_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_sop_requester_identity_mysql8.sql @@ -56,7 +57,7 @@ SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND ( - (table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url')) + (table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url', 'learning_points', 'supervisor_comment')) OR (table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion', 'dim_response_timeliness', 'response_latency_ms')) OR (table_name = 'aihr_practice_assignment' AND column_name IN ('score', 'score_mode')) OR (table_name = 'aihr_sop_answer_review' AND column_name IN ('prompt_version', 'requester_ext_party_id')) diff --git a/frontend/src/api/aihr/case.ts b/frontend/src/api/aihr/case.ts index f1f0d899..8581f6a1 100644 --- a/frontend/src/api/aihr/case.ts +++ b/frontend/src/api/aihr/case.ts @@ -38,6 +38,8 @@ export type CaseDetailResponse = { summary: string; mediaUrl?: string; mediaOssId?: number | null; + learningPoints: string[]; + supervisorComment: string; }; export type CaseUploadResponse = { @@ -52,6 +54,7 @@ export type CaseOrganizeResponse = { caseId: string; summary: CaseSummaryItem[]; tags: CaseTag[]; + learningPoints: string[]; aiSummary: string; source: string; }; @@ -102,3 +105,12 @@ export function getCaseDetail(caseId: string): Promise> { + return request({ + url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/review`, + method: 'post', + data: { comment }, + headers: { repeatSubmit: false } + }); +} diff --git a/frontend/src/views/knowledge/cases.vue b/frontend/src/views/knowledge/cases.vue index cc16978f..d150e3f3 100644 --- a/frontend/src/views/knowledge/cases.vue +++ b/frontend/src/views/knowledge/cases.vue @@ -144,11 +144,24 @@ 案例摘要

{{ caseDetail.summary || '暂无案例摘要' }}

+
+ 学习点 +
    +
  • {{ point }}
  • +
+
+
+ 主管点评 + +
+ 保存点评 +
+
原始音频
- + @@ -165,6 +178,7 @@ import { getCaseDetail, getCaseRecords, organizeKnowledgeCase, + reviewKnowledgeCase, uploadCaseAudio, type CaseDetailResponse, type CaseRecord, @@ -192,6 +206,8 @@ const recordsLoading = ref(true); const recordsFallback = ref(false); const detailVisible = ref(false); const caseDetail = ref(null); +const supervisorComment = ref(''); +const reviewSaving = ref(false); const projectExtOrgId = ref(''); const projectOptions = ref([]); const projectsLoading = ref(false); @@ -301,12 +317,27 @@ async function openCaseDetail(record: CaseRecord) { try { const { data } = await getCaseDetail(record.caseId); caseDetail.value = data; + supervisorComment.value = data.supervisorComment || ''; detailVisible.value = true; } catch { ElMessage.error('案例详情加载失败'); } } +async function submitSupervisorReview() { + if (!caseDetail.value || reviewSaving.value) return; + reviewSaving.value = true; + try { + await reviewKnowledgeCase(caseDetail.value.caseId, supervisorComment.value.trim()); + caseDetail.value.supervisorComment = supervisorComment.value.trim(); + ElMessage.success('主管点评已保存'); + } catch { + ElMessage.error('主管点评保存失败'); + } finally { + reviewSaving.value = false; + } +} + async function handleAudioPicked(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; @@ -713,6 +744,19 @@ onMounted(() => { } } +.learning-points { + margin: 8px 0 0; + padding-left: 20px; + color: #445064; + line-height: 1.65; +} + +.detail-actions { + display: flex; + justify-content: flex-end; + margin-top: 10px; +} + .case-audio { display: block; width: 100%; diff --git a/mobile-uni/src/pages/user/cases/index.vue b/mobile-uni/src/pages/user/cases/index.vue index 40dbacbd..33cde0d8 100644 --- a/mobile-uni/src/pages/user/cases/index.vue +++ b/mobile-uni/src/pages/user/cases/index.vue @@ -58,6 +58,13 @@ {{ item.label }} {{ item.text }} + + 学习点 + + • + {{ point }} + + {{ organizeResult.aiSummary }} @@ -109,6 +116,17 @@ {{ caseDetail.transcript || '暂无转写内容' }} 案例摘要 {{ caseDetail.summary || '暂无案例摘要' }} + + 学习点 + + • + {{ point }} + + + + 主管点评 + {{ caseDetail.supervisorComment }} + 原始音频