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.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<Void> review(@PathVariable String caseId, @RequestBody ReviewRequest request) {
if (!canManageCases()) {
return R.fail("仅项目负责人或主管可提交案例点评");
}
caseService.review(caseId, request, currentProjectScopes());
return R.ok();
}
@GetMapping("/capabilities")
public R<CapabilitiesResponse> capabilities() {
return R.ok(new CapabilitiesResponse(canManageCases(), canViewCases()));
@@ -17,12 +17,16 @@ public final class AihrCaseDto {
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 ReviewRequest(String comment) {
}
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,
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.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<String> projectScopes) {
CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes);
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(),
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<String> 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<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) {
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<CaseState> 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<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) {
return switch (index % 3) {
case 0 -> "pill-success";
@@ -592,6 +667,8 @@ public class AihrCaseService {
String transcript,
List<TagResponse> tags,
List<SummaryResponse> summary,
List<String> learningPoints,
String supervisorComment,
Long mediaOssId,
String mediaUrl,
String owner
@@ -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<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