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);
|
||||
}
|
||||
|
||||
@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")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> updateBasic(@PathVariable Long id, @RequestBody UpdateMeetingBasicCommand command) {
|
||||
|
|
|
|||
|
|
@ -33,4 +33,6 @@ public interface MeetingCommandService {
|
|||
void updateSummaryContent(Long meetingId, String summaryContent);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@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) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
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) {
|
||||
try {
|
||||
Map<String, Object> progress = new HashMap<>();
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
|||
import org.springframework.transaction.support.TransactionSynchronizationUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.any;
|
||||
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
|
||||
void createMeetingShouldPersistResolvedRuntimeProfile() {
|
||||
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) => {
|
||||
return http.put<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${data.meetingId}/basic`,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
MeetingTranscriptVO,
|
||||
MeetingVO,
|
||||
reSummary,
|
||||
retryMeetingTranscription,
|
||||
updateMeetingBasic,
|
||||
updateMeetingTranscript,
|
||||
updateMeetingSummary,
|
||||
|
|
@ -566,6 +567,7 @@ const MeetingDetail: React.FC = () => {
|
|||
}, [meeting]);
|
||||
|
||||
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
|
||||
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
setSelectedKeywords((current) => {
|
||||
if (checked) {
|
||||
|
|
@ -975,6 +990,11 @@ const MeetingDetail: React.FC = () => {
|
|||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
{canRetryTranscription && (
|
||||
<Button icon={<SyncOutlined />} type="primary" onClick={handleRetryTranscription} loading={actionLoading}>
|
||||
重新识别
|
||||
</Button>
|
||||
)}
|
||||
{canRetrySummary && (
|
||||
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
|
||||
重新总结
|
||||
|
|
|
|||
Loading…
Reference in New Issue