feat(aihr): complete case learning review loop
This commit is contained in:
+10
@@ -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()));
|
||||
|
||||
+7
-2
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+86
-9
@@ -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
|
||||
|
||||
+35
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user