feat: 添加重新识别会议转录功能
- 在 `MeetingCommandServiceImpl` 中添加 `retryTranscription` 方法,用于重新提交转录任务 - 更新 `MeetingCommandService` 接口,添加 `retryTranscription` 方法 - 在 `MeetingController` 中添加 `retryTranscription` API 端点 - 更新前端API和组件,支持重新识别会议转录 - 添加相关单元测试以验证新功能的正确性dev_na
parent
b9593324a5
commit
53ff2292a8
|
|
@ -366,6 +366,16 @@ public class MeetingController {
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/transcripts/regenerate")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ApiResponse<Boolean> retryTranscription(@PathVariable Long id) {
|
||||||
|
LoginUser loginUser = currentLoginUser();
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||||
|
meetingCommandService.retryTranscription(id);
|
||||||
|
return ApiResponse.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/basic")
|
@PutMapping("/{id}/basic")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<Boolean> updateBasic(@PathVariable Long id, @RequestBody UpdateMeetingBasicCommand command) {
|
public ApiResponse<Boolean> updateBasic(@PathVariable Long id, @RequestBody UpdateMeetingBasicCommand command) {
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,6 @@ public interface MeetingCommandService {
|
||||||
void updateSummaryContent(Long meetingId, String summaryContent);
|
void updateSummaryContent(Long meetingId, String summaryContent);
|
||||||
|
|
||||||
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
|
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
|
||||||
|
|
||||||
|
void retryTranscription(Long meetingId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,52 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void retryTranscription(Long meetingId) {
|
||||||
|
Meeting meeting = meetingService.getById(meetingId);
|
||||||
|
if (meeting == null) {
|
||||||
|
throw new RuntimeException("Meeting not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
|
||||||
|
.eq(MeetingTranscript::getMeetingId, meetingId));
|
||||||
|
if (transcriptCount > 0) {
|
||||||
|
throw new RuntimeException("当前会议已有转录内容,无需重新识别");
|
||||||
|
}
|
||||||
|
if (meeting.getAudioUrl() == null || meeting.getAudioUrl().isBlank()) {
|
||||||
|
throw new RuntimeException("当前会议缺少音频文件,无法重新识别");
|
||||||
|
}
|
||||||
|
|
||||||
|
AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meetingId)
|
||||||
|
.eq(AiTask::getTaskType, "ASR")
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (asrTask == null || asrTask.getTaskConfig() == null || asrTask.getTaskConfig().get("asrModelId") == null) {
|
||||||
|
throw new RuntimeException("未找到可用的识别任务配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
AiTask summaryTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meetingId)
|
||||||
|
.eq(AiTask::getTaskType, "SUMMARY")
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (summaryTask == null || summaryTask.getTaskConfig() == null) {
|
||||||
|
throw new RuntimeException("未找到可用的总结任务配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAiTask(asrTask, new HashMap<>(asrTask.getTaskConfig()));
|
||||||
|
aiTaskService.updateById(asrTask);
|
||||||
|
resetAiTask(summaryTask, new HashMap<>(summaryTask.getTaskConfig()));
|
||||||
|
aiTaskService.updateById(summaryTask);
|
||||||
|
|
||||||
|
meeting.setStatus(1);
|
||||||
|
meetingService.updateById(meeting);
|
||||||
|
updateMeetingProgress(meetingId, 0, "已重新提交识别任务,等待 ASR 处理...", 0);
|
||||||
|
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||||
|
}
|
||||||
|
|
||||||
private void dispatchSummaryTaskAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
private void dispatchSummaryTaskAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
||||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId);
|
aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId);
|
||||||
|
|
@ -471,6 +517,19 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
||||||
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) {
|
private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> progress = new HashMap<>();
|
Map<String, Object> progress = new HashMap<>();
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationUtils;
|
import org.springframework.transaction.support.TransactionSynchronizationUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
|
@ -332,6 +334,105 @@ class MeetingCommandServiceImplTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryTranscriptionShouldResetTasksAndDispatchAfterTransactionCommit() {
|
||||||
|
MeetingService meetingService = mock(MeetingService.class);
|
||||||
|
MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class);
|
||||||
|
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||||
|
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper = mock(com.imeeting.mapper.biz.MeetingTranscriptMapper.class);
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(401L);
|
||||||
|
meeting.setAudioUrl("/audio/demo.wav");
|
||||||
|
|
||||||
|
AiTask asrTask = new AiTask();
|
||||||
|
asrTask.setTaskType("ASR");
|
||||||
|
asrTask.setStatus(3);
|
||||||
|
asrTask.setTaskConfig(Map.of("asrModelId", 11L));
|
||||||
|
asrTask.setErrorMsg("failed");
|
||||||
|
asrTask.setStartedAt(LocalDateTime.now());
|
||||||
|
asrTask.setCompletedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
AiTask summaryTask = new AiTask();
|
||||||
|
summaryTask.setTaskType("SUMMARY");
|
||||||
|
summaryTask.setStatus(3);
|
||||||
|
summaryTask.setTaskConfig(Map.of("summaryModelId", 22L, "promptId", 33L));
|
||||||
|
summaryTask.setErrorMsg("failed");
|
||||||
|
summaryTask.setStartedAt(LocalDateTime.now());
|
||||||
|
summaryTask.setCompletedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
when(meetingService.getById(401L)).thenReturn(meeting);
|
||||||
|
when(transcriptMapper.selectCount(any())).thenReturn(0L);
|
||||||
|
when(aiTaskService.getOne(any())).thenReturn(asrTask, summaryTask);
|
||||||
|
|
||||||
|
MeetingCommandServiceImpl service = new MeetingCommandServiceImpl(
|
||||||
|
meetingService,
|
||||||
|
aiTaskService,
|
||||||
|
mock(HotWordService.class),
|
||||||
|
transcriptMapper,
|
||||||
|
mock(MeetingSummaryFileService.class),
|
||||||
|
meetingDomainSupport,
|
||||||
|
mockRuntimeProfileResolver(),
|
||||||
|
mock(RealtimeMeetingSessionStateService.class),
|
||||||
|
mock(RealtimeMeetingAudioStorageService.class),
|
||||||
|
mock(StringRedisTemplate.class),
|
||||||
|
new ObjectMapper()
|
||||||
|
);
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.initSynchronization();
|
||||||
|
try {
|
||||||
|
service.retryTranscription(401L);
|
||||||
|
|
||||||
|
assertEquals(1, meeting.getStatus());
|
||||||
|
assertEquals(0, asrTask.getStatus());
|
||||||
|
assertEquals(0, summaryTask.getStatus());
|
||||||
|
assertNull(asrTask.getErrorMsg());
|
||||||
|
assertNull(summaryTask.getErrorMsg());
|
||||||
|
verify(aiTaskService).updateById(asrTask);
|
||||||
|
verify(aiTaskService).updateById(summaryTask);
|
||||||
|
verify(meetingService).updateById(meeting);
|
||||||
|
verify(aiTaskService, never()).dispatchTasks(401L, null, null);
|
||||||
|
|
||||||
|
TransactionSynchronizationUtils.triggerAfterCommit();
|
||||||
|
|
||||||
|
verify(aiTaskService).dispatchTasks(401L, null, null);
|
||||||
|
} finally {
|
||||||
|
TransactionSynchronizationManager.clearSynchronization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryTranscriptionShouldRejectMeetingsWithExistingTranscripts() {
|
||||||
|
MeetingService meetingService = mock(MeetingService.class);
|
||||||
|
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||||
|
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper = mock(com.imeeting.mapper.biz.MeetingTranscriptMapper.class);
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(402L);
|
||||||
|
meeting.setAudioUrl("/audio/demo.wav");
|
||||||
|
|
||||||
|
when(meetingService.getById(402L)).thenReturn(meeting);
|
||||||
|
when(transcriptMapper.selectCount(any())).thenReturn(1L);
|
||||||
|
|
||||||
|
MeetingCommandServiceImpl service = new MeetingCommandServiceImpl(
|
||||||
|
meetingService,
|
||||||
|
aiTaskService,
|
||||||
|
mock(HotWordService.class),
|
||||||
|
transcriptMapper,
|
||||||
|
mock(MeetingSummaryFileService.class),
|
||||||
|
mock(MeetingDomainSupport.class),
|
||||||
|
mockRuntimeProfileResolver(),
|
||||||
|
mock(RealtimeMeetingSessionStateService.class),
|
||||||
|
mock(RealtimeMeetingAudioStorageService.class),
|
||||||
|
mock(StringRedisTemplate.class),
|
||||||
|
new ObjectMapper()
|
||||||
|
);
|
||||||
|
|
||||||
|
RuntimeException error = assertThrows(RuntimeException.class, () -> service.retryTranscription(402L));
|
||||||
|
|
||||||
|
assertEquals("当前会议已有转录内容,无需重新识别", error.getMessage());
|
||||||
|
verify(aiTaskService, never()).getOne(any());
|
||||||
|
verify(aiTaskService, never()).dispatchTasks(anyLong(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createMeetingShouldPersistResolvedRuntimeProfile() {
|
void createMeetingShouldPersistResolvedRuntimeProfile() {
|
||||||
MeetingService meetingService = mock(MeetingService.class);
|
MeetingService meetingService = mock(MeetingService.class);
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,13 @@ export const reSummary = (params: MeetingResummaryDTO) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const retryMeetingTranscription = (meetingId: number) => {
|
||||||
|
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||||
|
`/api/biz/meeting/${meetingId}/transcripts/regenerate`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => {
|
export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => {
|
||||||
return http.put<{ code: string; data: boolean; msg: string }>(
|
return http.put<{ code: string; data: boolean; msg: string }>(
|
||||||
`/api/biz/meeting/${data.meetingId}/basic`,
|
`/api/biz/meeting/${data.meetingId}/basic`,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
MeetingTranscriptVO,
|
MeetingTranscriptVO,
|
||||||
MeetingVO,
|
MeetingVO,
|
||||||
reSummary,
|
reSummary,
|
||||||
|
retryMeetingTranscription,
|
||||||
updateMeetingBasic,
|
updateMeetingBasic,
|
||||||
updateMeetingTranscript,
|
updateMeetingTranscript,
|
||||||
updateMeetingSummary,
|
updateMeetingSummary,
|
||||||
|
|
@ -566,6 +567,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
}, [meeting]);
|
}, [meeting]);
|
||||||
|
|
||||||
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
|
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
|
||||||
|
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
@ -715,6 +717,19 @@ const MeetingDetail: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetryTranscription = async () => {
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await retryMeetingTranscription(Number(id));
|
||||||
|
message.success('已重新提交识别任务');
|
||||||
|
await fetchData(Number(id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeywordToggle = (keyword: string, checked: boolean) => {
|
const handleKeywordToggle = (keyword: string, checked: boolean) => {
|
||||||
setSelectedKeywords((current) => {
|
setSelectedKeywords((current) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
|
|
@ -975,6 +990,11 @@ const MeetingDetail: React.FC = () => {
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Space>
|
||||||
|
{canRetryTranscription && (
|
||||||
|
<Button icon={<SyncOutlined />} type="primary" onClick={handleRetryTranscription} loading={actionLoading}>
|
||||||
|
重新识别
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canRetrySummary && (
|
{canRetrySummary && (
|
||||||
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
|
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
|
||||||
重新总结
|
重新总结
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue