feat(case): persist source audio for learning detail
This commit is contained in:
+4
-2
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+51
-10
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+31
-4
@@ -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 '租户编号',
|
||||
|
||||
Reference in New Issue
Block a user