feat(aihr): complete case learning review loop

This commit is contained in:
2026-07-14 23:41:59 +08:00
parent 4fc394e993
commit 0bfaee7d22
16 changed files with 296 additions and 20 deletions
@@ -14,6 +14,7 @@ import org.dromara.aihr.domain.AihrCaseDto.DetailResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest; import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse; import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
import org.dromara.aihr.domain.AihrCaseDto.RecordResponse; 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.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.service.AihrCaseService; import org.dromara.aihr.service.AihrCaseService;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
@@ -84,6 +85,15 @@ public class AihrCaseController {
return detail == null ? R.fail("案例不存在或无权访问") : R.ok(detail); return detail == null ? R.fail("案例不存在或无权访问") : R.ok(detail);
} }
@PostMapping("/records/{caseId}/review")
public R<Void> review(@PathVariable String caseId, @RequestBody ReviewRequest request) {
if (!canManageCases()) {
return R.fail("仅项目负责人或主管可提交案例点评");
}
caseService.review(caseId, request, currentProjectScopes());
return R.ok();
}
@GetMapping("/capabilities") @GetMapping("/capabilities")
public R<CapabilitiesResponse> capabilities() { public R<CapabilitiesResponse> capabilities() {
return R.ok(new CapabilitiesResponse(canManageCases(), canViewCases())); return R.ok(new CapabilitiesResponse(canManageCases(), canViewCases()));
@@ -17,12 +17,16 @@ public final class AihrCaseDto {
public record OrganizeRequest(String caseId) { public record OrganizeRequest(String caseId) {
} }
public record OrganizeResponse(String caseId, List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary, String source) { public record OrganizeResponse(String caseId, List<SummaryResponse> summary, List<TagResponse> tags,
List<String> learningPoints, String aiSummary, String source) {
} }
public record CurateRequest(String caseId, String criteria, Integer limit) { public record CurateRequest(String caseId, String criteria, Integer limit) {
} }
public record ReviewRequest(String comment) {
}
public record CurateResponse(List<String> selectedCaseIds, List<RecordResponse> records, String sampleHint) { public record CurateResponse(List<String> selectedCaseIds, List<RecordResponse> records, String sampleHint) {
} }
@@ -43,6 +47,7 @@ public final class AihrCaseDto {
public record DetailResponse(String caseId, String fileName, String title, String type, String status, 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) { String mediaUrl, Long mediaOssId, List<String> learningPoints,
String supervisorComment) {
} }
} }
@@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrCaseDto.DetailResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest; import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse; import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
import org.dromara.aihr.domain.AihrCaseDto.RecordResponse; 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.SummaryResponse;
import org.dromara.aihr.domain.AihrCaseDto.TagResponse; import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
@@ -73,7 +74,7 @@ public class AihrCaseService {
mediaOssId = media.ossId(); mediaOssId = media.ossId();
caseId = "case-" + UUID.randomUUID(); 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(), currentCaseOwner()); List.of(), "", media.ossId(), media.url(), currentCaseOwner());
cases.put(caseId, state); cases.put(caseId, state);
saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript); saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript);
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", media.url(), media.ossId()); 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<String> projectScopes) { public OrganizeResponse organize(OrganizeRequest request, List<String> projectScopes) {
CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes); CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes);
CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state)); CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state));
List<String> learningPoints = learningPointsFromSummary(summary.summary());
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(), state.owner()); learningPoints, state.supervisorComment(), state.mediaOssId(), state.mediaUrl(), state.owner());
cases.put(state.id(), next); cases.put(state.id(), next);
saveCase(next, "已整理", caseTitle(summary.summary(), state.fileName()), summary.aiSummary()); 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<String> projectScopes) { public CurateResponse curate(CurateRequest request, List<String> projectScopes) {
@@ -117,6 +119,19 @@ public class AihrCaseService {
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,已进入案例库。"); return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,已进入案例库。");
} }
public void review(String caseId, ReviewRequest request, List<String> 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<RecordResponse> records(Integer limit, List<String> projectScopes) { public List<RecordResponse> records(Integer limit, List<String> projectScopes) {
ensureCaseTable(); ensureCaseTable();
if (projectScopes != null && projectScopes.isEmpty()) { if (projectScopes != null && projectScopes.isEmpty()) {
@@ -166,7 +181,8 @@ public class AihrCaseService {
} }
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'"; String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'";
String sql = """ 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 FROM aihr_case_record
WHERE tenant_id = ? AND case_id = ? WHERE tenant_id = ? AND case_id = ?
""" + projectWhere + publishedWhere + " LIMIT 1"; """ + projectWhere + publishedWhere + " LIMIT 1";
@@ -184,7 +200,9 @@ public class AihrCaseService {
firstNonBlank(rs.getString("transcript"), ""), firstNonBlank(rs.getString("transcript"), ""),
firstNonBlank(rs.getString("summary"), ""), firstNonBlank(rs.getString("summary"), ""),
resolveMediaUrl(mediaOssId, rs.getString("media_url")), resolveMediaUrl(mediaOssId, rs.getString("media_url")),
mediaOssId mediaOssId,
parseLearningPoints(rs.getString("learning_points")),
firstNonBlank(rs.getString("supervisor_comment"), "")
); );
}, args.toArray()).stream().findFirst().orElse(null); }, args.toArray()).stream().findFirst().orElse(null);
} }
@@ -240,8 +258,8 @@ public class AihrCaseService {
ensureCaseTable(); ensureCaseTable();
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO aihr_case_record 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) (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()) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
file_name = VALUES(file_name), file_name = VALUES(file_name),
project_ext_org_id = VALUES(project_ext_org_id), project_ext_org_id = VALUES(project_ext_org_id),
@@ -250,6 +268,8 @@ public class AihrCaseService {
status = VALUES(status), status = VALUES(status),
transcript = VALUES(transcript), transcript = VALUES(transcript),
summary = VALUES(summary), 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_oss_id = VALUES(media_oss_id),
media_url = VALUES(media_url), media_url = VALUES(media_url),
update_time = now() update_time = now()
@@ -263,6 +283,8 @@ public class AihrCaseService {
status, status,
truncate(maskSensitiveText(state.transcript()), 4000), truncate(maskSensitiveText(state.transcript()), 4000),
truncate(maskSensitiveText(summary), 1000), truncate(maskSensitiveText(summary), 1000),
learningPointsJson(state.learningPoints()),
firstNonBlank(state.supervisorComment(), ""),
state.mediaOssId(), state.mediaOssId(),
truncate(state.mediaUrl(), 500), truncate(state.mediaUrl(), 500),
firstNonBlank(state.owner(), "培训组") firstNonBlank(state.owner(), "培训组")
@@ -289,6 +311,8 @@ public class AihrCaseService {
`status` varchar(30) DEFAULT '已转写' COMMENT '状态', `status` varchar(30) DEFAULT '已转写' COMMENT '状态',
`transcript` text DEFAULT NULL COMMENT 'ASR转写', `transcript` text DEFAULT NULL COMMENT 'ASR转写',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要', `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_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID',
`media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址', `media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址',
`owner` varchar(80) DEFAULT '培训组' 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_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("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; tableReady = true;
} }
} }
@@ -411,8 +437,8 @@ public class AihrCaseService {
if (state == null && !normalizedCaseId.isBlank()) { if (state == null && !normalizedCaseId.isBlank()) {
ensureCaseTable(); ensureCaseTable();
List<CaseState> persisted = jdbcTemplate.query(""" List<CaseState> persisted = jdbcTemplate.query("""
SELECT case_id, file_name, project_ext_org_id, transcript, type SELECT case_id, file_name, project_ext_org_id, transcript, type,
, media_oss_id, media_url, owner media_oss_id, media_url, learning_points, supervisor_comment, owner
FROM aihr_case_record FROM aihr_case_record
WHERE tenant_id = ? AND case_id = ? WHERE tenant_id = ? AND case_id = ?
LIMIT 1 LIMIT 1
@@ -423,6 +449,8 @@ public class AihrCaseService {
firstNonBlank(rs.getString("transcript"), ""), firstNonBlank(rs.getString("transcript"), ""),
tagsFromText(firstNonBlank(rs.getString("type"), "现场案例")), tagsFromText(firstNonBlank(rs.getString("type"), "现场案例")),
null, null,
parseLearningPoints(rs.getString("learning_points")),
firstNonBlank(rs.getString("supervisor_comment"), ""),
rs.getObject("media_oss_id", Long.class), rs.getObject("media_oss_id", Long.class),
firstNonBlank(rs.getString("media_url"), ""), firstNonBlank(rs.getString("media_url"), ""),
firstNonBlank(rs.getString("owner"), "培训组") firstNonBlank(rs.getString("owner"), "培训组")
@@ -526,6 +554,53 @@ public class AihrCaseService {
return tags.isEmpty() ? "现场案例" : tags.get(0).text(); return tags.isEmpty() ? "现场案例" : tags.get(0).text();
} }
static List<String> learningPointsFromSummary(List<SummaryResponse> 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<String> 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<String> 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<String> 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) { private static String tagType(int index) {
return switch (index % 3) { return switch (index % 3) {
case 0 -> "pill-success"; case 0 -> "pill-success";
@@ -592,6 +667,8 @@ public class AihrCaseService {
String transcript, String transcript,
List<TagResponse> tags, List<TagResponse> tags,
List<SummaryResponse> summary, List<SummaryResponse> summary,
List<String> learningPoints,
String supervisorComment,
Long mediaOssId, Long mediaOssId,
String mediaUrl, String mediaUrl,
String owner String owner
@@ -2,6 +2,7 @@ package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; 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.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService; import org.dromara.system.service.ISysOssService;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
@@ -70,8 +71,40 @@ class AihrCaseServiceTest {
assertEquals(123L, response.mediaOssId()); assertEquals(123L, response.mediaOssId());
assertEquals("https://oss.local/cases/case.webm", response.mediaUrl()); assertEquals("https://oss.local/cases/case.webm", response.mediaUrl());
assertEquals(123L, jdbcTemplate.updateArgs[9]); assertEquals(123L, jdbcTemplate.updateArgs[11]);
assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[10]); assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[12]);
}
@Test
@Tag("dev")
void learningPointsUseOnlyActionResultAndHighlightSummaryItems() {
List<String> 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<String> 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 @Test
@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS `aihr_case_record` (
`status` varchar(30) DEFAULT '已转写' COMMENT '状态', `status` varchar(30) DEFAULT '已转写' COMMENT '状态',
`transcript` text DEFAULT NULL COMMENT 'ASR转写', `transcript` text DEFAULT NULL COMMENT 'ASR转写',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要', `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_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID',
`media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址', `media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址',
`owner` varchar(80) DEFAULT '培训组' COMMENT '负责人', `owner` varchar(80) DEFAULT '培训组' COMMENT '负责人',
@@ -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;
+1 -1
View File
@@ -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,契约不变 | | 三角色对练 `/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/开发身份 | | 正式试点数据导出 | `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 音频 | | 对练语音 | `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` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 | | 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 | | 资料处理 `/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 名试点人员手机号 | | 组织人员同步 | `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 名试点人员手机号 |
+1
View File
@@ -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: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: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 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 更新时间:2026-07-14
+2 -1
View File
@@ -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_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_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_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_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_daily_drill_score_mode_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_sop_requester_identity_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 FROM information_schema.columns
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND ( 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_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_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')) OR (table_name = 'aihr_sop_answer_review' AND column_name IN ('prompt_version', 'requester_ext_party_id'))
+12
View File
@@ -38,6 +38,8 @@ export type CaseDetailResponse = {
summary: string; summary: string;
mediaUrl?: string; mediaUrl?: string;
mediaOssId?: number | null; mediaOssId?: number | null;
learningPoints: string[];
supervisorComment: string;
}; };
export type CaseUploadResponse = { export type CaseUploadResponse = {
@@ -52,6 +54,7 @@ export type CaseOrganizeResponse = {
caseId: string; caseId: string;
summary: CaseSummaryItem[]; summary: CaseSummaryItem[];
tags: CaseTag[]; tags: CaseTag[];
learningPoints: string[];
aiSummary: string; aiSummary: string;
source: string; source: string;
}; };
@@ -102,3 +105,12 @@ export function getCaseDetail(caseId: string): Promise<ApiResult<CaseDetailRespo
method: 'get' method: 'get'
}); });
} }
export function reviewKnowledgeCase(caseId: string, comment: string): Promise<ApiResult<void>> {
return request({
url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/review`,
method: 'post',
data: { comment },
headers: { repeatSubmit: false }
});
}
+45 -1
View File
@@ -144,11 +144,24 @@
<strong>案例摘要</strong> <strong>案例摘要</strong>
<p>{{ caseDetail.summary || '暂无案例摘要' }}</p> <p>{{ caseDetail.summary || '暂无案例摘要' }}</p>
</div> </div>
<div v-if="caseDetail.learningPoints?.length" class="detail-block">
<strong>学习点</strong>
<ul class="learning-points">
<li v-for="point in caseDetail.learningPoints" :key="point">{{ point }}</li>
</ul>
</div>
<div class="detail-block">
<strong>主管点评</strong>
<el-input v-model="supervisorComment" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="记录本案例可复用的带教建议" />
<div class="detail-actions">
<el-button type="danger" :loading="reviewSaving" :disabled="!caseDetail.caseId" @click="submitSupervisorReview">保存点评</el-button>
</div>
</div>
<div v-if="caseDetail.mediaUrl" class="detail-block"> <div v-if="caseDetail.mediaUrl" class="detail-block">
<strong>原始音频</strong> <strong>原始音频</strong>
<audio class="case-audio" :src="caseDetail.mediaUrl" controls preload="metadata" /> <audio class="case-audio" :src="caseDetail.mediaUrl" controls preload="metadata" />
</div> </div>
<el-alert title="当前版本提供转写、摘要和状态详情;视频样片、学习点与主管点评待正式媒体和业务字段接入。" type="info" :closable="false" /> <el-alert title="当前版本提供转写、摘要、学习点和主管点评;视频样片仍待正式媒体资源接入。" type="info" :closable="false" />
</template> </template>
<el-empty v-else description="暂无详情" /> <el-empty v-else description="暂无详情" />
</el-dialog> </el-dialog>
@@ -165,6 +178,7 @@ import {
getCaseDetail, getCaseDetail,
getCaseRecords, getCaseRecords,
organizeKnowledgeCase, organizeKnowledgeCase,
reviewKnowledgeCase,
uploadCaseAudio, uploadCaseAudio,
type CaseDetailResponse, type CaseDetailResponse,
type CaseRecord, type CaseRecord,
@@ -192,6 +206,8 @@ const recordsLoading = ref(true);
const recordsFallback = ref(false); const recordsFallback = ref(false);
const detailVisible = ref(false); const detailVisible = ref(false);
const caseDetail = ref<CaseDetailResponse | null>(null); const caseDetail = ref<CaseDetailResponse | null>(null);
const supervisorComment = ref('');
const reviewSaving = ref(false);
const projectExtOrgId = ref(''); const projectExtOrgId = ref('');
const projectOptions = ref<OrgProjectOption[]>([]); const projectOptions = ref<OrgProjectOption[]>([]);
const projectsLoading = ref(false); const projectsLoading = ref(false);
@@ -301,12 +317,27 @@ async function openCaseDetail(record: CaseRecord) {
try { try {
const { data } = await getCaseDetail(record.caseId); const { data } = await getCaseDetail(record.caseId);
caseDetail.value = data; caseDetail.value = data;
supervisorComment.value = data.supervisorComment || '';
detailVisible.value = true; detailVisible.value = true;
} catch { } catch {
ElMessage.error('案例详情加载失败'); 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) { async function handleAudioPicked(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; 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 { .case-audio {
display: block; display: block;
width: 100%; width: 100%;
+35
View File
@@ -58,6 +58,13 @@
<text class="summary-label">{{ item.label }}</text> <text class="summary-label">{{ item.label }}</text>
<text class="summary-text">{{ item.text }}</text> <text class="summary-text">{{ item.text }}</text>
</view> </view>
<view v-if="organizeResult.learningPoints?.length" class="case-learning-block">
<text class="detail-label">学习点</text>
<view v-for="point in organizeResult.learningPoints" :key="point" class="learning-point">
<text class="learning-bullet">•</text>
<text class="case-note">{{ point }}</text>
</view>
</view>
<text class="case-note">{{ organizeResult.aiSummary }}</text> <text class="case-note">{{ organizeResult.aiSummary }}</text>
</view> </view>
@@ -109,6 +116,17 @@
<text class="case-content">{{ caseDetail.transcript || '暂无转写内容' }}</text> <text class="case-content">{{ caseDetail.transcript || '暂无转写内容' }}</text>
<text class="detail-label">案例摘要</text> <text class="detail-label">案例摘要</text>
<text class="case-note">{{ caseDetail.summary || '暂无案例摘要' }}</text> <text class="case-note">{{ caseDetail.summary || '暂无案例摘要' }}</text>
<view v-if="caseDetail.learningPoints?.length" class="case-learning-block">
<text class="detail-label">学习点</text>
<view v-for="point in caseDetail.learningPoints" :key="point" class="learning-point">
<text class="learning-bullet">•</text>
<text class="case-note">{{ point }}</text>
</view>
</view>
<view v-if="caseDetail.supervisorComment" class="case-learning-block">
<text class="detail-label">主管点评</text>
<text class="case-note">{{ caseDetail.supervisorComment }}</text>
</view>
<view v-if="caseDetail.mediaUrl" class="case-media-block"> <view v-if="caseDetail.mediaUrl" class="case-media-block">
<text class="detail-label">原始音频</text> <text class="detail-label">原始音频</text>
<audio class="case-audio" :src="caseDetail.mediaUrl" controls /> <audio class="case-audio" :src="caseDetail.mediaUrl" controls />
@@ -401,6 +419,23 @@ onShow(() => {
gap: 8px; gap: 8px;
} }
.case-learning-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.learning-point {
display: flex;
align-items: flex-start;
gap: 6px;
}
.learning-bullet {
color: #c0392b;
line-height: 1.6;
}
.case-audio { .case-audio {
width: 100%; width: 100%;
} }
+3
View File
@@ -404,6 +404,8 @@ export interface CaseDetailResponse {
summary: string; summary: string;
mediaUrl?: string; mediaUrl?: string;
mediaOssId?: number; mediaOssId?: number;
learningPoints: string[];
supervisorComment: string;
} }
export interface CaseUploadResponse { export interface CaseUploadResponse {
@@ -420,6 +422,7 @@ export interface CaseOrganizeResponse {
caseId: string; caseId: string;
summary: CaseSummaryItem[]; summary: CaseSummaryItem[];
tags: CaseTag[]; tags: CaseTag[];
learningPoints: string[];
aiSummary: string; aiSummary: string;
source: string; source: string;
} }
+8 -2
View File
@@ -81,7 +81,7 @@ check_pilot_samples() {
[[ "$(pilot_sql_scalar "SELECT DATE('$pilot_end') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end" [[ "$(pilot_sql_scalar "SELECT DATE('$pilot_end') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end"
[[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date" [[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date"
local scenario_count scenario_service_promotion_count scenario_daily_service_count rubric_expected_count rubric_five_dimension_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count audio_upload_table_count case_media_schema_count practice_session_schema_count daily_drill_schema_count prompt_version_schema_count requester_identity_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count local scenario_count scenario_service_promotion_count scenario_daily_service_count rubric_expected_count rubric_five_dimension_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count audio_upload_table_count case_media_schema_count case_learning_schema_count practice_session_schema_count daily_drill_schema_count prompt_version_schema_count requester_identity_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count
local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending satisfaction_count satisfaction_average local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending satisfaction_count satisfaction_average
local org_identity_cte=" local org_identity_cte="
WITH active_org AS ( WITH active_org AS (
@@ -142,6 +142,7 @@ check_pilot_samples() {
audio_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio'")" audio_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio'")"
audio_upload_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio_upload'")" audio_upload_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio_upload'")"
case_media_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_case_record' AND column_name IN ('media_oss_id','media_url')")" case_media_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_case_record' AND column_name IN ('media_oss_id','media_url')")"
case_learning_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_case_record' AND column_name IN ('learning_points','supervisor_comment')")"
practice_session_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion','dim_response_timeliness','response_latency_ms','annotations_json','review_advice','incentive_point','satisfaction_score','satisfaction_comment')")" practice_session_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion','dim_response_timeliness','response_latency_ms','annotations_json','review_advice','incentive_point','satisfaction_score','satisfaction_comment')")"
daily_drill_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_assignment' AND column_name IN ('answer_text','feedback','correct','finished_time','score','score_mode')")" daily_drill_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_assignment' AND column_name IN ('answer_text','feedback','correct','finished_time','score','score_mode')")"
prompt_version_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'prompt_version'")" prompt_version_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'prompt_version'")"
@@ -300,7 +301,7 @@ check_pilot_samples() {
AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY) AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
) )
SELECT COALESCE(ROUND(AVG(satisfaction_score), 1), 0) FROM formal_sessions WHERE satisfaction_score IS NOT NULL")" SELECT COALESCE(ROUND(AVG(satisfaction_score), 1), 0) FROM formal_sessions WHERE satisfaction_score IS NOT NULL")"
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 rubric_five_dimensions=${rubric_five_dimension_count}/${rubric_expected_count} core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} audio_upload_schema=${audio_upload_table_count} case_media_schema=${case_media_schema_count}/2 practice_session_schema=${practice_session_schema_count}/8 daily_drill_schema=${daily_drill_schema_count}/6 prompt_version_schema=${prompt_version_schema_count} requester_identity_schema=${requester_identity_schema_count} hire_date_schema=${hire_date_schema_count}" echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 rubric_five_dimensions=${rubric_five_dimension_count}/${rubric_expected_count} core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} audio_upload_schema=${audio_upload_table_count} case_media_schema=${case_media_schema_count}/2 case_learning_schema=${case_learning_schema_count}/2 practice_session_schema=${practice_session_schema_count}/8 daily_drill_schema=${daily_drill_schema_count}/6 prompt_version_schema=${prompt_version_schema_count} requester_identity_schema=${requester_identity_schema_count} hire_date_schema=${hire_date_schema_count}"
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || { [[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || {
(( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12" (( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12"
@@ -313,6 +314,7 @@ check_pilot_samples() {
(( audio_table_count == 1 )) || fail "pilot audio schema unavailable (aihr_practice_audio missing)" (( audio_table_count == 1 )) || fail "pilot audio schema unavailable (aihr_practice_audio missing)"
(( audio_upload_table_count == 1 )) || fail "pilot audio staging schema unavailable (aihr_practice_audio_upload missing)" (( audio_upload_table_count == 1 )) || fail "pilot audio staging schema unavailable (aihr_practice_audio_upload missing)"
(( case_media_schema_count == 2 )) || fail "pilot case media schema unavailable (aihr_case_record.media_oss_id/media_url missing)" (( case_media_schema_count == 2 )) || fail "pilot case media schema unavailable (aihr_case_record.media_oss_id/media_url missing)"
(( case_learning_schema_count == 2 )) || fail "pilot case learning schema incomplete (aihr_case_record.learning_points/supervisor_comment missing)"
(( practice_session_schema_count == 8 )) || fail "pilot practice session schema incomplete (${practice_session_schema_count}/8)" (( practice_session_schema_count == 8 )) || fail "pilot practice session schema incomplete (${practice_session_schema_count}/8)"
(( daily_drill_schema_count == 6 )) || fail "pilot daily drill schema incomplete (${daily_drill_schema_count}/6)" (( daily_drill_schema_count == 6 )) || fail "pilot daily drill schema incomplete (${daily_drill_schema_count}/6)"
(( prompt_version_schema_count == 1 )) || fail "pilot RAG traceability schema unavailable (aihr_sop_answer_review.prompt_version missing)" (( prompt_version_schema_count == 1 )) || fail "pilot RAG traceability schema unavailable (aihr_sop_answer_review.prompt_version missing)"
@@ -447,6 +449,10 @@ contains frontend/src/views/knowledge/cases.vue "案例所属项目"
contains frontend/src/views/knowledge/cases.vue "请先选择案例所属项目" contains frontend/src/views/knowledge/cases.vue "请先选择案例所属项目"
contains backend/script/sql/update/aihr_20260714_case_media_mysql8.sql "media_oss_id" contains backend/script/sql/update/aihr_20260714_case_media_mysql8.sql "media_oss_id"
contains backend/script/sql/update/aihr_20260714_case_media_mysql8.sql "media_url" contains backend/script/sql/update/aihr_20260714_case_media_mysql8.sql "media_url"
contains backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql "learning_points"
contains backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql "supervisor_comment"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/records/{caseId}/review"
contains frontend/src/api/aihr/case.ts "reviewKnowledgeCase"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "系统用户上传案例必须明确所属项目" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "系统用户上传案例必须明确所属项目"
contains frontend/src/views/index.vue "核心流程覆盖待内容确认" contains frontend/src/views/index.vue "核心流程覆盖待内容确认"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "核心流程覆盖待内容确认" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "核心流程覆盖待内容确认"
+6 -1
View File
@@ -37,6 +37,11 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_case_media_mysq
case_media_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_case_record' AND column_name IN ('media_oss_id','media_url');")" case_media_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_case_record' AND column_name IN ('media_oss_id','media_url');")"
test "$case_media_columns" = 'media_oss_id,media_url' test "$case_media_columns" = 'media_oss_id,media_url'
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql"
case_learning_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_case_record' AND column_name IN ('learning_points','supervisor_comment');")"
test "$case_learning_columns" = 'learning_points,supervisor_comment'
mysql "$DB" <<'SQL' mysql "$DB" <<'SQL'
CREATE TABLE aihr_prompt_template ( CREATE TABLE aihr_prompt_template (
id bigint NOT NULL AUTO_INCREMENT, id bigint NOT NULL AUTO_INCREMENT,
@@ -93,4 +98,4 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_org_hire_date_m
org_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_org_snapshot' AND column_name='hire_date';")" org_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_org_snapshot' AND column_name='hire_date';")"
test "$org_columns" = 'hire_date' test "$org_columns" = 'hire_date'
echo "PASS: AIHR legacy schema migrations are idempotent, including case media fields" echo "PASS: AIHR legacy schema migrations are idempotent, including case media and learning fields"
+2 -1
View File
@@ -69,7 +69,7 @@ CREATE TABLE aihr_practice_scenario (tenant_id varchar(20), position varchar(80)
CREATE TABLE aihr_knowledge_attach (tenant_id varchar(20), type varchar(50), status tinyint); CREATE TABLE aihr_knowledge_attach (tenant_id varchar(20), type varchar(50), status tinyint);
CREATE TABLE aihr_case_record ( CREATE TABLE aihr_case_record (
tenant_id varchar(20), status varchar(30), title varchar(120), transcript text, summary varchar(1000), tenant_id varchar(20), status varchar(30), title varchar(120), transcript text, summary varchar(1000),
media_oss_id bigint, media_url varchar(500) media_oss_id bigint, media_url varchar(500), learning_points text, supervisor_comment varchar(1000)
); );
CREATE TABLE aihr_practice_session ( CREATE TABLE aihr_practice_session (
tenant_id varchar(20), session_id varchar(100), ext_party_id varchar(100), mode varchar(30), finished_time datetime, tenant_id varchar(20), session_id varchar(100), ext_party_id varchar(100), mode varchar(30), finished_time datetime,
@@ -177,6 +177,7 @@ pilot_output="$(AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10
[[ "$pilot_output" == *'sop_docs=5/5 stored_cases=20/20'* ]] || { echo "FAIL: BRD content quantity gates: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'sop_docs=5/5 stored_cases=20/20'* ]] || { echo "FAIL: BRD content quantity gates: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'case_unmasked=0'* ]] || { echo "FAIL: redacted case content gate: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'case_unmasked=0'* ]] || { echo "FAIL: redacted case content gate: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'case_media_schema=2/2'* ]] || { echo "FAIL: case media schema gate: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'case_media_schema=2/2'* ]] || { echo "FAIL: case media schema gate: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'case_learning_schema=2/2'* ]] || { echo "FAIL: case learning schema gate: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'practice_session_schema=8/8 daily_drill_schema=6/6'* ]] || { echo "FAIL: practice schema gates: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'practice_session_schema=8/8 daily_drill_schema=6/6'* ]] || { echo "FAIL: practice schema gates: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'sop_usable=1/1 sop_pending=1'* ]] || { echo "FAIL: SOP window: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'sop_usable=1/1 sop_pending=1'* ]] || { echo "FAIL: SOP window: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'satisfaction=5.0/5 responses=19'* ]] || { echo "FAIL: safe satisfaction identity scope: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'satisfaction=5.0/5 responses=19'* ]] || { echo "FAIL: safe satisfaction identity scope: $pilot_output" >&2; exit 1; }