feat(case): persist source audio for learning detail

This commit is contained in:
2026-07-14 05:51:45 +08:00
parent 9bbfd575a3
commit 9ace0cb5c9
10 changed files with 133 additions and 20 deletions
@@ -10,7 +10,8 @@ public final class AihrCaseDto {
public record UploadRequest(String fileName, String projectExtOrgId) {
}
public record UploadResponse(String caseId, String fileName, String transcript, List<TagResponse> tags, String source) {
public record UploadResponse(String caseId, String fileName, String transcript, List<TagResponse> tags, String source,
String mediaUrl, Long mediaOssId) {
}
public record OrganizeRequest(String caseId) {
@@ -38,6 +39,7 @@ public final class AihrCaseDto {
}
public record DetailResponse(String caseId, String fileName, String title, String type, String status,
String time, String owner, String transcript, String summary) {
String time, String owner, String transcript, String summary,
String mediaUrl, Long mediaOssId) {
}
}
@@ -13,6 +13,8 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse;
import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -40,6 +42,7 @@ public class AihrCaseService {
private final AihrModelSeedService modelService;
private final ObjectMapper objectMapper;
private final JdbcTemplate jdbcTemplate;
private final ISysOssService ossService;
private final Map<String, CaseState> cases = new ConcurrentHashMap<>();
private volatile boolean tableReady;
@@ -54,11 +57,13 @@ public class AihrCaseService {
String transcript = speechService.transcribe(file.getBytes(), fileName, contentType)
.orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败"));
transcript = maskSensitiveText(transcript);
MediaRef media = storeSourceAudio(file);
String caseId = "case-" + UUID.randomUUID();
CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null);
CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null,
media.ossId(), media.url());
cases.put(caseId, state);
saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript);
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr");
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", media.url(), media.ossId());
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
@@ -70,7 +75,8 @@ public class AihrCaseService {
public OrganizeResponse organize(OrganizeRequest request, List<String> projectScopes) {
CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes);
CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state));
CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary());
CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary(),
state.mediaOssId(), state.mediaUrl());
cases.put(state.id(), next);
saveCase(next, "已整理", caseTitle(summary.summary(), state.fileName()), summary.aiSummary());
return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source());
@@ -142,7 +148,7 @@ public class AihrCaseService {
args.addAll(projectScopes);
}
String sql = """
SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary
SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary, media_url, media_oss_id
FROM aihr_case_record
WHERE tenant_id = '000000' AND case_id = ?
""" + projectWhere + " LIMIT 1";
@@ -155,7 +161,9 @@ public class AihrCaseService {
rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT),
firstNonBlank(rs.getString("owner"), "培训组"),
firstNonBlank(rs.getString("transcript"), ""),
firstNonBlank(rs.getString("summary"), "")
firstNonBlank(rs.getString("summary"), ""),
firstNonBlank(rs.getString("media_url"), ""),
rs.getObject("media_oss_id", Long.class)
), args.toArray()).stream().findFirst().orElse(null);
}
@@ -210,8 +218,8 @@ public class AihrCaseService {
ensureCaseTable();
jdbcTemplate.update("""
INSERT INTO aihr_case_record
(tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, owner, create_time, update_time)
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, '培训组', now(), now())
(tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, media_oss_id, media_url, owner, create_time, update_time)
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '培训组', now(), now())
ON DUPLICATE KEY UPDATE
file_name = VALUES(file_name),
project_ext_org_id = VALUES(project_ext_org_id),
@@ -220,6 +228,8 @@ public class AihrCaseService {
status = VALUES(status),
transcript = VALUES(transcript),
summary = VALUES(summary),
media_oss_id = VALUES(media_oss_id),
media_url = VALUES(media_url),
update_time = now()
""",
state.id(),
@@ -229,7 +239,9 @@ public class AihrCaseService {
primaryTag(state.tags()),
status,
truncate(maskSensitiveText(state.transcript()), 4000),
truncate(maskSensitiveText(summary), 1000)
truncate(maskSensitiveText(summary), 1000),
state.mediaOssId(),
truncate(state.mediaUrl(), 500)
);
}
@@ -253,6 +265,8 @@ public class AihrCaseService {
`status` varchar(30) DEFAULT '已转写' COMMENT '状态',
`transcript` text DEFAULT NULL COMMENT 'ASR转写',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
`media_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID',
`media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址',
`owner` varchar(80) DEFAULT '培训组' COMMENT '负责人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
@@ -261,6 +275,8 @@ public class AihrCaseService {
KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录';
""");
addCaseColumn("media_oss_id", "ALTER TABLE aihr_case_record ADD COLUMN media_oss_id bigint DEFAULT NULL COMMENT '原始音频OSS文件ID'");
addCaseColumn("media_url", "ALTER TABLE aihr_case_record ADD COLUMN media_url varchar(500) DEFAULT NULL COMMENT '原始音频访问地址'");
tableReady = true;
}
}
@@ -368,6 +384,7 @@ public class AihrCaseService {
ensureCaseTable();
List<CaseState> persisted = jdbcTemplate.query("""
SELECT case_id, file_name, project_ext_org_id, transcript, type
, media_oss_id, media_url
FROM aihr_case_record
WHERE tenant_id = '000000' AND case_id = ?
LIMIT 1
@@ -377,7 +394,9 @@ public class AihrCaseService {
firstNonBlank(rs.getString("project_ext_org_id"), ""),
firstNonBlank(rs.getString("transcript"), ""),
tagsFromText(firstNonBlank(rs.getString("type"), "现场案例")),
null
null,
rs.getObject("media_oss_id", Long.class),
firstNonBlank(rs.getString("media_url"), "")
), normalizedCaseId);
state = persisted.stream().findFirst().orElse(null);
if (state != null) {
@@ -397,6 +416,23 @@ public class AihrCaseService {
return String.join(",", Collections.nCopies(size, "?"));
}
private MediaRef storeSourceAudio(MultipartFile file) {
// Unit tests can construct this service without the system OSS bean; production always wires it.
if (ossService == null) {
return new MediaRef(null, "");
}
SysOssVo oss = ossService.upload(file);
return new MediaRef(oss.getOssId(), firstNonBlank(oss.getUrl(), ""));
}
private void addCaseColumn(String column, String sql) {
try {
jdbcTemplate.execute(sql);
} catch (DataAccessException e) {
log.debug("case table column already exists or cannot be altered: {}", column);
}
}
private static List<TagResponse> tagsFromText(String text) {
String source = clean(text);
List<TagResponse> tags = new ArrayList<>();
@@ -477,10 +513,15 @@ public class AihrCaseService {
String projectExtOrgId,
String transcript,
List<TagResponse> tags,
List<SummaryResponse> summary
List<SummaryResponse> summary,
Long mediaOssId,
String mediaUrl
) {
}
private record MediaRef(Long ossId, String url) {
}
private record CaseSummary(List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary, String source) {
}
}
@@ -2,6 +2,8 @@ package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -32,7 +34,7 @@ class AihrCaseServiceTest {
}
};
CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate);
AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, null);
UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null);
@@ -44,11 +46,36 @@ class AihrCaseServiceTest {
assertFalse(response.transcript().contains("2-304"));
}
@Test
@Tag("dev")
void uploadPersistsSourceAudioReferenceWhenOssIsAvailable() {
AihrSpeechService speechService = new AihrSpeechService(null, new ObjectMapper()) {
@Override
public Optional<String> transcribe(byte[] audio, String filename, String contentType) {
return Optional.of("现场投诉已完成跟进");
}
};
SysOssVo oss = new SysOssVo();
oss.setOssId(123L);
oss.setUrl("https://oss.local/cases/case.webm");
ISysOssService ossService = mock(ISysOssService.class);
when(ossService.upload(org.mockito.ArgumentMatchers.any(org.springframework.web.multipart.MultipartFile.class))).thenReturn(oss);
CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, ossService);
UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null);
assertEquals(123L, response.mediaOssId());
assertEquals("https://oss.local/cases/case.webm", response.mediaUrl());
assertEquals(123L, jdbcTemplate.updateArgs[8]);
assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[9]);
}
@Test
@Tag("dev")
void curateCanReloadCaseAfterServiceRestart() {
PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate);
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
var response = service.curate(new org.dromara.aihr.domain.AihrCaseDto.CurateRequest(
"persisted-case", "可复用培训案例", 1
@@ -65,7 +92,7 @@ class AihrCaseServiceTest {
@Tag("dev")
void detailReturnsPersistedCaseForAuthorizedProject() {
PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate);
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
var detail = service.detail("persisted-case", List.of("P1"));
@@ -86,7 +113,7 @@ class AihrCaseServiceTest {
@Test
@Tag("dev")
void systemUploadRequiresExplicitProject() {
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), new CaseJdbcTemplate());
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), new CaseJdbcTemplate(), null);
assertThrows(IllegalArgumentException.class, () -> service.upload(
new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "", null
@@ -1,6 +1,27 @@
-- 物业 AI HR 对练记录最小表结构。
-- ponytail: one session table covers history, review list and profile aggregation for MVP.
CREATE TABLE IF NOT EXISTS `aihr_case_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`case_id` varchar(80) NOT NULL COMMENT '案例ID',
`file_name` varchar(255) DEFAULT NULL COMMENT '来源文件',
`project_ext_org_id` varchar(100) DEFAULT NULL COMMENT '项目外部组织ID',
`title` varchar(120) DEFAULT NULL COMMENT '标题',
`type` varchar(80) DEFAULT NULL COMMENT '案例类型',
`status` varchar(30) DEFAULT '已转写' COMMENT '状态',
`transcript` text DEFAULT NULL COMMENT 'ASR转写',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
`media_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID',
`media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址',
`owner` varchar(80) DEFAULT '培训组' COMMENT '负责人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_case_record_case` (`tenant_id`, `case_id`),
KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录';
CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',