refactor: 删除 MeetingTranscriptRevisionServiceImpl 类
- 移除 `MeetingTranscriptRevisionServiceImpl` 类及其相关方法和逻辑 - 该类涉及会议转录修订的生成、解析和更新等功能dev_na
parent
188809305e
commit
a046ecf05b
|
|
@ -5,8 +5,6 @@ public final class SysParamKeys {
|
||||||
|
|
||||||
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
|
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
|
||||||
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt";
|
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt";
|
||||||
public static final String MEETING_TRANSCRIPT_CLEANUP_FILLER_WORDS = "meeting.transcript.cleanup.filler_words";
|
|
||||||
public static final String MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS = "meeting.transcript.cleanup.replacements";
|
|
||||||
public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb";
|
public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb";
|
||||||
public static final String MEETING_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled";
|
public static final String MEETING_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled";
|
||||||
public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled";
|
public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled";
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ public class AndroidMeetingController {
|
||||||
loginUser.getUserId(),
|
loginUser.getUserId(),
|
||||||
AndroidLoginUserSupport.resolveDisplayName(authContext),
|
AndroidLoginUserSupport.resolveDisplayName(authContext),
|
||||||
"all",
|
"all",
|
||||||
|
null,
|
||||||
AndroidLoginUserSupport.isAdmin(authContext)
|
AndroidLoginUserSupport.isAdmin(authContext)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,7 @@ public class LegacyMeetingController {
|
||||||
loginUser.getUserId(),
|
loginUser.getUserId(),
|
||||||
resolveCreatorName(loginUser),
|
resolveCreatorName(loginUser),
|
||||||
"all",
|
"all",
|
||||||
|
null,
|
||||||
isAdmin
|
isAdmin
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,44 @@ public class MeetingController {
|
||||||
return ApiResponse.ok(progress);
|
return ApiResponse.ok(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "上传会议音频")
|
|
||||||
|
@Operation(summary = "批量查询会议处理进度")
|
||||||
|
@PostMapping("/progress/batch")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ApiResponse<Map<Long, Map<String, Object>>> getProgressBatch(@RequestBody List<Long> ids) {
|
||||||
|
LoginUser loginUser = currentLoginUser();
|
||||||
|
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
|
for (Long id : ids) {
|
||||||
|
if (id == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
|
||||||
|
Map<String, Object> progress = meetingProgressService.getProgressMap(id);
|
||||||
|
if (compatibilityAiTaskService != null && "Waiting...".equals(progress.get("message"))) {
|
||||||
|
AiTask asrTask = compatibilityAiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, id)
|
||||||
|
.eq(AiTask::getTaskType, "ASR")
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (asrTask != null && Integer.valueOf(0).equals(asrTask.getStatus())) {
|
||||||
|
progress = Map.of("percent", 0, "message", "排队中,等待 ASR 执行名额...");
|
||||||
|
} else if (asrTask != null && Integer.valueOf(1).equals(asrTask.getStatus())) {
|
||||||
|
progress = Map.of("percent", 5, "message", "识别中,等待进度刷新...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.put(id, progress);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Ignore inaccessible meetings in batch mode.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
|
@Operation(summary = "上传会议音频")
|
||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
|
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
|
||||||
|
|
@ -223,7 +260,8 @@ public class MeetingController {
|
||||||
@RequestParam(defaultValue = "1") Integer current,
|
@RequestParam(defaultValue = "1") Integer current,
|
||||||
@RequestParam(defaultValue = "10") Integer size,
|
@RequestParam(defaultValue = "10") Integer size,
|
||||||
@RequestParam(required = false) String title,
|
@RequestParam(required = false) String title,
|
||||||
@RequestParam(defaultValue = "all") String viewType) {
|
@RequestParam(defaultValue = "all") String viewType,
|
||||||
|
@RequestParam(required = false) Integer status) {
|
||||||
|
|
||||||
LoginUser loginUser = currentLoginUser();
|
LoginUser loginUser = currentLoginUser();
|
||||||
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
|
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
|
||||||
|
|
@ -236,6 +274,7 @@ public class MeetingController {
|
||||||
loginUser.getUserId(),
|
loginUser.getUserId(),
|
||||||
resolveCreatorName(loginUser),
|
resolveCreatorName(loginUser),
|
||||||
viewType,
|
viewType,
|
||||||
|
status,
|
||||||
isAdmin
|
isAdmin
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ public class MeetingProgressSnapshot {
|
||||||
private Integer percent;
|
private Integer percent;
|
||||||
private String message;
|
private String message;
|
||||||
private Integer eta;
|
private Integer eta;
|
||||||
|
private Integer queueAheadCount;
|
||||||
private String externalTaskId;
|
private String externalTaskId;
|
||||||
private LocalDateTime queuedAt;
|
private LocalDateTime queuedAt;
|
||||||
private LocalDateTime startedAt;
|
private LocalDateTime startedAt;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import com.imeeting.common.MeetingProgressStage;
|
||||||
import com.imeeting.dto.biz.MeetingProgressSnapshot;
|
import com.imeeting.dto.biz.MeetingProgressSnapshot;
|
||||||
import com.imeeting.entity.biz.AiTask;
|
import com.imeeting.entity.biz.AiTask;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public interface MeetingProgressService {
|
public interface MeetingProgressService {
|
||||||
|
|
@ -11,6 +12,8 @@ public interface MeetingProgressService {
|
||||||
|
|
||||||
Map<String, Object> getProgressMap(Long meetingId);
|
Map<String, Object> getProgressMap(Long meetingId);
|
||||||
|
|
||||||
|
Map<Long, Map<String, Object>> getProgressMaps(List<Long> meetingIds);
|
||||||
|
|
||||||
Integer resolvePercent(Long meetingId);
|
Integer resolvePercent(Long meetingId);
|
||||||
|
|
||||||
void markQueued(Long meetingId, AiTask task, Integer meetingStatus, String message);
|
void markQueued(Long meetingId, AiTask task, Integer meetingStatus, String message);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import java.util.Map;
|
||||||
|
|
||||||
public interface MeetingQueryService {
|
public interface MeetingQueryService {
|
||||||
PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
|
PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
|
||||||
Long userId, String userName, String viewType, boolean isAdmin);
|
Long userId, String userName, String viewType, Integer status, boolean isAdmin);
|
||||||
|
|
||||||
MeetingVO getDetail(Long id);
|
MeetingVO getDetail(Long id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,6 @@ import com.imeeting.entity.biz.Meeting;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface MeetingTranscriptRevisionService {
|
public interface MeetingTranscriptRevisionService {
|
||||||
String generateOfflineCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel);
|
|
||||||
|
|
||||||
MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask, AiModelVO asrModel);
|
|
||||||
|
|
||||||
List<MeetingTranscriptVO> listEffectiveTranscripts(Long meetingId);
|
|
||||||
|
|
||||||
boolean updateCurrentRevisionContent(Long meetingId, Long operatorId, String content);
|
|
||||||
|
|
||||||
void invalidateCurrentRevision(Long meetingId);
|
void invalidateCurrentRevision(Long meetingId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import com.imeeting.service.biz.MeetingProgressService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
|
||||||
import com.imeeting.support.RedisValueSupport;
|
import com.imeeting.support.RedisValueSupport;
|
||||||
|
|
||||||
import com.unisbase.entity.SysUser;
|
import com.unisbase.entity.SysUser;
|
||||||
|
|
@ -71,7 +71,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
private final MeetingProgressService meetingProgressService;
|
private final MeetingProgressService meetingProgressService;
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||||
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
|
||||||
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
|
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
|
||||||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||||
|
|
@ -115,7 +115,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
MeetingProgressService meetingProgressService,
|
MeetingProgressService meetingProgressService,
|
||||||
MeetingSummaryFileService meetingSummaryFileService,
|
MeetingSummaryFileService meetingSummaryFileService,
|
||||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||||
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
|
|
||||||
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||||
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
|
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
|
||||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||||
|
|
@ -131,7 +130,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
this.meetingProgressService = meetingProgressService;
|
this.meetingProgressService = meetingProgressService;
|
||||||
this.meetingSummaryFileService = meetingSummaryFileService;
|
this.meetingSummaryFileService = meetingSummaryFileService;
|
||||||
this.meetingTranscriptFileService = meetingTranscriptFileService;
|
this.meetingTranscriptFileService = meetingTranscriptFileService;
|
||||||
this.meetingTranscriptRevisionService = meetingTranscriptRevisionService;
|
|
||||||
this.meetingTranscriptChapterService = meetingTranscriptChapterService;
|
this.meetingTranscriptChapterService = meetingTranscriptChapterService;
|
||||||
this.meetingSummaryPromptAssembler = meetingSummaryPromptAssembler;
|
this.meetingSummaryPromptAssembler = meetingSummaryPromptAssembler;
|
||||||
this.taskSecurityContextRunner = taskSecurityContextRunner;
|
this.taskSecurityContextRunner = taskSecurityContextRunner;
|
||||||
|
|
@ -148,7 +146,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
StringRedisTemplate redisTemplate,
|
StringRedisTemplate redisTemplate,
|
||||||
MeetingSummaryFileService meetingSummaryFileService,
|
MeetingSummaryFileService meetingSummaryFileService,
|
||||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||||
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
|
|
||||||
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||||
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
|
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
|
||||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||||
|
|
@ -164,7 +162,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
|
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
|
||||||
meetingSummaryFileService,
|
meetingSummaryFileService,
|
||||||
meetingTranscriptFileService,
|
meetingTranscriptFileService,
|
||||||
meetingTranscriptRevisionService,
|
|
||||||
meetingTranscriptChapterService,
|
meetingTranscriptChapterService,
|
||||||
meetingSummaryPromptAssembler,
|
meetingSummaryPromptAssembler,
|
||||||
taskSecurityContextRunner,
|
taskSecurityContextRunner,
|
||||||
|
|
@ -213,7 +210,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
this.updateById(asrTask);
|
this.updateById(asrTask);
|
||||||
}
|
}
|
||||||
if (!claimQueuedAsrTask(asrTask)) {
|
if (!claimQueuedAsrTask(asrTask)) {
|
||||||
meetingProgressService.markQueued(meetingId, asrTask, 1, "已进入 ASR 队列,等待执行");
|
meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +246,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
if (!asrText.isBlank()) {
|
if (!asrText.isBlank()) {
|
||||||
meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0);
|
meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0);
|
||||||
|
scheduleQueuedAsrTasks();
|
||||||
self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +266,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Meeting {} AI Task Flow failed", meetingId, e);
|
log.error("Meeting {} AI Task Flow failed", meetingId, e);
|
||||||
failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务:" + e.getMessage());
|
failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务: " + e.getMessage());
|
||||||
updateMeetingStatus(meetingId, 4);
|
updateMeetingStatus(meetingId, 4);
|
||||||
updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0);
|
updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -384,6 +382,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
task.setStatus(1);
|
task.setStatus(1);
|
||||||
task.setStartedAt(now);
|
task.setStartedAt(now);
|
||||||
meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0);
|
meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0);
|
||||||
|
refreshQueuedAsrProgress();
|
||||||
}
|
}
|
||||||
return claimed;
|
return claimed;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -409,6 +408,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
if (available <= 0) {
|
if (available <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
refreshQueuedAsrProgress();
|
||||||
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
|
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
|
||||||
.eq(AiTask::getTaskType, "ASR")
|
.eq(AiTask::getTaskType, "ASR")
|
||||||
.eq(AiTask::getStatus, 0)
|
.eq(AiTask::getStatus, 0)
|
||||||
|
|
@ -427,6 +427,20 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void refreshQueuedAsrProgress() {
|
||||||
|
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getTaskType, "ASR")
|
||||||
|
.eq(AiTask::getStatus, 0)
|
||||||
|
.orderByAsc(AiTask::getQueuedAt)
|
||||||
|
.orderByAsc(AiTask::getId));
|
||||||
|
for (AiTask queuedTask : queuedTasks) {
|
||||||
|
if (queuedTask.getMeetingId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
meetingProgressService.markQueued(queuedTask.getMeetingId(), queuedTask, 1, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private int resolveAsrMaxConcurrent() {
|
private int resolveAsrMaxConcurrent() {
|
||||||
if (sysParamService == null) {
|
if (sysParamService == null) {
|
||||||
return 2;
|
return 2;
|
||||||
|
|
@ -459,6 +473,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
task.setResponseData(responseData);
|
task.setResponseData(responseData);
|
||||||
updateById(task);
|
updateById(task);
|
||||||
meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason);
|
meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason);
|
||||||
|
refreshQueuedAsrProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long extractAsrModelId(AiTask task) {
|
private Long extractAsrModelId(AiTask task) {
|
||||||
|
|
@ -565,20 +580,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect(Collectors.joining("/"));
|
.collect(Collectors.joining("/"));
|
||||||
req.put("file_url", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl);
|
req.put("audio_address", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl);
|
||||||
|
|
||||||
Map<String, Object> config = new HashMap<>();
|
Map<String, Object> config = new HashMap<>();
|
||||||
if (asrModel.getModelCode() != null && !asrModel.getModelCode().isBlank()) {
|
|
||||||
config.put("model", asrModel.getModelCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId");
|
Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId");
|
||||||
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
||||||
config.put("enable_speaker", useSpk);
|
config.put("enable_speaker", useSpk);
|
||||||
|
config.put("match_speaker_registry", useSpk);
|
||||||
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
|
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
|
||||||
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
|
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
|
||||||
config.put("enable_text_refine", enableTextRefine);
|
config.put("enable_text_cleanup", enableTextRefine);
|
||||||
config.put("enable_two_pass", true);
|
|
||||||
|
|
||||||
List<Map<String, Object>> hotwords = new ArrayList<>();
|
List<Map<String, Object>> hotwords = new ArrayList<>();
|
||||||
Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords");
|
Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords");
|
||||||
|
|
@ -647,8 +659,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
||||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||||
if (submitNode.path("code").asInt() != 0) {
|
if (submitNode.path("code").asInt() != 0) {
|
||||||
updateAiTaskFail(taskRecord, "ASR识别失败: " + respBody);
|
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
|
||||||
throw new RuntimeException("ASR识别失败: " + submitNode.path("msg").asText());
|
throw new RuntimeException("ASR识别失败: " + firstNonBlank(
|
||||||
|
submitNode.path("message").asText(""),
|
||||||
|
submitNode.path("msg").asText(""),
|
||||||
|
"unknown error"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
String taskId = submitNode.path("data").path("task_id").asText();
|
String taskId = submitNode.path("data").path("task_id").asText();
|
||||||
taskRecord.setResponseData(Map.of("task_id", taskId));
|
taskRecord.setResponseData(Map.of("task_id", taskId));
|
||||||
|
|
@ -657,11 +673,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
protected String saveTranscripts(Meeting meeting, JsonNode resultNode) {
|
protected String saveTranscripts(Meeting meeting, JsonNode resultNode) {
|
||||||
// 关键:入库前清理旧记录,防止恢复任务导致数据重复
|
// 闂備胶顭堢换鎴炵箾婵犲洤鏋佹い鎾卞灪閺咁剚鎱ㄥ鍡楀鐎殿喗濞婇獮鏍偓娑櫳戠亸顓烆熆瑜忔慨鎾Υ閹烘宸濇い鏍ㄧ☉閳ь剛鍋ら弻锟犲礃閸曨偅锛嶉柛鐐插閹叉悂鎮ч崼鐔衡敍缂備浇椴哥换鍫濐潖婵傜鐭楀鑸得竟姗€姊虹拠鈥冲箲闁搞劌缍婅棟闁告瑥顦遍々鐑芥偣閸ャ劌绲绘い顐犲€濋幃妤佹媴閸愵煈妫堥梺鎼炰紘閸パ勭€梺缁橆殔閻楀棛绮婇敃鍌涒拺闁圭粯甯炲瓭濡?
|
||||||
transcriptMapper.delete(new LambdaQueryWrapper<MeetingTranscript>().eq(MeetingTranscript::getMeetingId, meeting.getId()));
|
transcriptMapper.delete(new LambdaQueryWrapper<MeetingTranscript>().eq(MeetingTranscript::getMeetingId, meeting.getId()));
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
JsonNode segments = resultNode.path("segments");
|
JsonNode segments = resultNode.path("segments");
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
Map<String, String> resolvedUserNameCache = buildResolvedUserNameCache(segments);
|
||||||
int savedCount = 0;
|
int savedCount = 0;
|
||||||
if (segments.isArray()) {
|
if (segments.isArray()) {
|
||||||
int order = 0;
|
int order = 0;
|
||||||
|
|
@ -670,7 +687,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
mt.setMeetingId(meeting.getId());
|
mt.setMeetingId(meeting.getId());
|
||||||
|
|
||||||
String spkId = extractSpeakerId(seg);
|
String spkId = extractSpeakerId(seg);
|
||||||
String spkName = resolveTranscriptSpeakerName(seg, spkId);
|
String spkName = resolveTranscriptSpeakerName(seg, spkId, resolvedUserNameCache);
|
||||||
|
|
||||||
mt.setSpeakerId(spkId);
|
mt.setSpeakerId(spkId);
|
||||||
mt.setSpeakerName(spkName);
|
mt.setSpeakerName(spkName);
|
||||||
|
|
@ -714,7 +731,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return speakerId.trim();
|
return speakerId.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveTranscriptSpeakerName(JsonNode seg, String speakerId) {
|
private String resolveTranscriptSpeakerName(JsonNode seg, String speakerId, Map<String, String> resolvedUserNameCache) {
|
||||||
String speakerName = seg.path("speaker_name").asText("");
|
String speakerName = seg.path("speaker_name").asText("");
|
||||||
if (speakerName == null || speakerName.isBlank()) {
|
if (speakerName == null || speakerName.isBlank()) {
|
||||||
JsonNode speakerNode = seg.path("speaker");
|
JsonNode speakerNode = seg.path("speaker");
|
||||||
|
|
@ -728,13 +745,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
if (userId == null || userId.isBlank()) {
|
if (userId == null || userId.isBlank()) {
|
||||||
userId = seg.path("speaker").path("user_id").asText("");
|
userId = seg.path("speaker").path("user_id").asText("");
|
||||||
}
|
}
|
||||||
String resolvedUserName = resolveUserName(userId);
|
String resolvedUserName = resolveUserName(userId, resolvedUserNameCache);
|
||||||
if (resolvedUserName != null) {
|
if (resolvedUserName != null) {
|
||||||
return resolvedUserName;
|
return resolvedUserName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (speakerId != null && speakerId.matches("\\d+")) {
|
if (speakerId != null && speakerId.matches("\\d+")) {
|
||||||
String resolvedSpeakerName = resolveUserName(speakerId);
|
String resolvedSpeakerName = resolveUserName(speakerId, resolvedUserNameCache);
|
||||||
if (resolvedSpeakerName != null) {
|
if (resolvedSpeakerName != null) {
|
||||||
return resolvedSpeakerName;
|
return resolvedSpeakerName;
|
||||||
}
|
}
|
||||||
|
|
@ -746,10 +763,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return speakerName.trim();
|
return speakerName.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveUserName(String userId) {
|
private String resolveUserName(String userId, Map<String, String> resolvedUserNameCache) {
|
||||||
if (userId == null || userId.isBlank() || !userId.matches("\\d+")) {
|
if (userId == null || userId.isBlank() || !userId.matches("\\d+")) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (resolvedUserNameCache != null) {
|
||||||
|
return resolvedUserNameCache.get(userId);
|
||||||
|
}
|
||||||
SysUser user = sysUserMapper.selectById(Long.parseLong(userId));
|
SysUser user = sysUserMapper.selectById(Long.parseLong(userId));
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -757,6 +777,45 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
|
return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, String> buildResolvedUserNameCache(JsonNode segments) {
|
||||||
|
if (segments == null || !segments.isArray()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Set<Long> userIds = new LinkedHashSet<>();
|
||||||
|
for (JsonNode seg : segments) {
|
||||||
|
collectNumericUserId(userIds, seg.path("user_id").asText(""));
|
||||||
|
JsonNode speakerNode = seg.path("speaker");
|
||||||
|
collectNumericUserId(userIds, speakerNode.path("user_id").asText(""));
|
||||||
|
collectNumericUserId(userIds, speakerNode.path("id").asText(""));
|
||||||
|
collectNumericUserId(userIds, seg.path("speaker_id").asText(""));
|
||||||
|
}
|
||||||
|
if (userIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return sysUserMapper.selectBatchIds(userIds).stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
item -> String.valueOf(item.getUserId()),
|
||||||
|
item -> item.getDisplayName() != null ? item.getDisplayName() : item.getUsername(),
|
||||||
|
(left, right) -> left,
|
||||||
|
LinkedHashMap::new
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectNumericUserId(Set<Long> userIds, String candidate) {
|
||||||
|
if (candidate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String normalized = candidate.trim();
|
||||||
|
if (!normalized.matches("\\d+")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
userIds.add(Long.parseLong(normalized));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void fillTranscriptTime(MeetingTranscript transcript, JsonNode seg) {
|
private void fillTranscriptTime(MeetingTranscript transcript, JsonNode seg) {
|
||||||
JsonNode timestamp = seg.path("timestamp");
|
JsonNode timestamp = seg.path("timestamp");
|
||||||
if (timestamp.isArray() && timestamp.size() >= 2) {
|
if (timestamp.isArray() && timestamp.size() >= 2) {
|
||||||
|
|
@ -955,6 +1014,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
? new HashMap<>()
|
? new HashMap<>()
|
||||||
: new HashMap<>(chapterTask.getResponseData());
|
: new HashMap<>(chapterTask.getResponseData());
|
||||||
responseData.put("summarySource", summarySource.toSnapshot());
|
responseData.put("summarySource", summarySource.toSnapshot());
|
||||||
|
responseData.put("summarySourceText", summarySource.getText());
|
||||||
|
responseData.put("rawTranscriptText", summarySource.getRawTranscriptText());
|
||||||
responseData.put("chapterOutlineText", summarySource.getChapterOutlineText());
|
responseData.put("chapterOutlineText", summarySource.getChapterOutlineText());
|
||||||
responseData.put("sourceFingerprint", summarySource.getSourceFingerprint());
|
responseData.put("sourceFingerprint", summarySource.getSourceFingerprint());
|
||||||
responseData.put("chapterVersionId", summarySource.getChapterVersionId());
|
responseData.put("chapterVersionId", summarySource.getChapterVersionId());
|
||||||
|
|
@ -973,6 +1034,52 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private MeetingSummarySource restorePreparedSummarySource(AiTask chapterTask) {
|
||||||
|
if (chapterTask == null || chapterTask.getResponseData() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String rawTranscriptText = stringValue(chapterTask.getResponseData().get("rawTranscriptText"));
|
||||||
|
String chapterOutlineText = stringValue(chapterTask.getResponseData().get("chapterOutlineText"));
|
||||||
|
String text = stringValue(chapterTask.getResponseData().get("summarySourceText"));
|
||||||
|
if ((rawTranscriptText == null || rawTranscriptText.isBlank())
|
||||||
|
&& (text == null || text.isBlank())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object summarySourceSnapshot = chapterTask.getResponseData().get("summarySource");
|
||||||
|
Map<?, ?> snapshot = summarySourceSnapshot instanceof Map<?, ?> map ? map : Map.of();
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
text = rawTranscriptText != null && !rawTranscriptText.isBlank()
|
||||||
|
? rawTranscriptText
|
||||||
|
: chapterOutlineText;
|
||||||
|
}
|
||||||
|
return MeetingSummarySource.builder()
|
||||||
|
.text(text)
|
||||||
|
.sourceType(stringValue(snapshot.get("sourceType")))
|
||||||
|
.fallbackUsed(Boolean.TRUE.equals(snapshot.get("fallbackUsed")))
|
||||||
|
.sourceFingerprint(firstNonBlank(
|
||||||
|
stringValue(snapshot.get("sourceFingerprint")),
|
||||||
|
stringValue(chapterTask.getResponseData().get("sourceFingerprint"))
|
||||||
|
))
|
||||||
|
.chapterVersionId(longValue(firstNonNull(
|
||||||
|
snapshot.get("chapterVersionId"),
|
||||||
|
chapterTask.getResponseData().get("chapterVersionId")
|
||||||
|
)))
|
||||||
|
.chapterCount(intValue(firstNonNull(
|
||||||
|
snapshot.get("chapterCount"),
|
||||||
|
chapterTask.getResponseData().get("chapterCount")
|
||||||
|
)))
|
||||||
|
.algorithmVersion(stringValue(snapshot.get("algorithmVersion")))
|
||||||
|
.generationMode(stringValue(snapshot.get("generationMode")))
|
||||||
|
.rawTranscriptText(rawTranscriptText)
|
||||||
|
.chapterOutlineText(chapterOutlineText)
|
||||||
|
.chapterFilePath(firstNonBlank(
|
||||||
|
stringValue(snapshot.get("chapterFilePath")),
|
||||||
|
stringValue(chapterTask.getResponseData().get("chapterFilePath"))
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception {
|
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception {
|
||||||
if (isExternalSummaryModeEnabled()) {
|
if (isExternalSummaryModeEnabled()) {
|
||||||
triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false);
|
triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false);
|
||||||
|
|
@ -985,7 +1092,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
MeetingSummarySource summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask != null ? chapterTask : sumTask);
|
MeetingSummarySource summarySource = restorePreparedSummarySource(chapterTask);
|
||||||
|
if (summarySource == null || summarySource.getText() == null || summarySource.getText().isBlank()) {
|
||||||
|
summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask != null ? chapterTask : sumTask);
|
||||||
|
}
|
||||||
if (summarySource.getText() == null || summarySource.getText().isBlank()) {
|
if (summarySource.getText() == null || summarySource.getText().isBlank()) {
|
||||||
failPendingSummaryTask(sumTask, "没有转录内容");
|
failPendingSummaryTask(sumTask, "没有转录内容");
|
||||||
updateMeetingStatus(meeting.getId(), 4);
|
updateMeetingStatus(meeting.getId(), 4);
|
||||||
|
|
@ -1031,7 +1141,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (summaryTask == null) {
|
if (summaryTask == null) {
|
||||||
updateProgress(meeting.getId(), -1, "缺少总结任务,无法触发外部 n8n 编排", 0);
|
updateProgress(meeting.getId(), -1, "Summary task is missing, external n8n orchestration cannot be triggered", 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateMeetingStatus(meeting.getId(), 2);
|
updateMeetingStatus(meeting.getId(), 2);
|
||||||
|
|
@ -1041,7 +1151,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
updateProgress(meeting.getId(), 95, result.getMessage(), 0);
|
updateProgress(meeting.getId(), 95, result.getMessage(), 0);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
this.updateById(summaryTask);
|
this.updateById(summaryTask);
|
||||||
updateProgress(meeting.getId(), -1, "触发外部 n8n 编排失败: " + ex.getMessage(), 0);
|
updateProgress(meeting.getId(), -1, "闂佽崵鍠愰悷杈╃不閹达絻浜归柛灞剧☉缁剁偤鏌″搴″箹闁?n8n 缂傚倸鍊搁崐褰掓偋濡ゅ啯鏆滈柟鐐綑缁剁偤寮堕崼顐函鐞? " + ex.getMessage(), 0);
|
||||||
log.error("Failed to trigger external n8n webhook for meeting {}", meeting.getId(), ex);
|
log.error("Failed to trigger external n8n webhook for meeting {}", meeting.getId(), ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1052,6 +1162,56 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
&& !Integer.valueOf(3).equals(task.getStatus());
|
&& !Integer.valueOf(3).equals(task.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String stringValue(Object value) {
|
||||||
|
return value == null ? null : String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String... values) {
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object firstNonNull(Object... values) {
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Object value : values) {
|
||||||
|
if (value != null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long longValue(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(String.valueOf(value).trim());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer intValue(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(String.valueOf(value).trim());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private AiModelVO resolveAsrModelForRevision(AiTask asrTask) {
|
private AiModelVO resolveAsrModelForRevision(AiTask asrTask) {
|
||||||
if (asrTask == null || asrTask.getTaskConfig() == null) {
|
if (asrTask == null || asrTask.getTaskConfig() == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1138,7 +1298,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
|
|
||||||
private String normalizeUrlComponent(String value, String fieldName) {
|
private String normalizeUrlComponent(String value, String fieldName) {
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
throw new IllegalArgumentException(fieldName + "不能为空");
|
throw new IllegalArgumentException(fieldName + " must not be blank");
|
||||||
}
|
}
|
||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
@ -1214,3 +1374,4 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
|
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||||
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
|
||||||
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
|
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
|
||||||
private final MeetingDomainSupport meetingDomainSupport;
|
private final MeetingDomainSupport meetingDomainSupport;
|
||||||
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
||||||
|
|
@ -86,7 +86,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
|
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
|
||||||
MeetingSummaryFileService meetingSummaryFileService,
|
MeetingSummaryFileService meetingSummaryFileService,
|
||||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||||
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
|
|
||||||
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||||
MeetingDomainSupport meetingDomainSupport,
|
MeetingDomainSupport meetingDomainSupport,
|
||||||
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
|
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
|
||||||
|
|
@ -101,7 +101,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
this.transcriptMapper = transcriptMapper;
|
this.transcriptMapper = transcriptMapper;
|
||||||
this.meetingSummaryFileService = meetingSummaryFileService;
|
this.meetingSummaryFileService = meetingSummaryFileService;
|
||||||
this.meetingTranscriptFileService = meetingTranscriptFileService;
|
this.meetingTranscriptFileService = meetingTranscriptFileService;
|
||||||
this.meetingTranscriptRevisionService = meetingTranscriptRevisionService;
|
|
||||||
this.meetingTranscriptChapterService = meetingTranscriptChapterService;
|
this.meetingTranscriptChapterService = meetingTranscriptChapterService;
|
||||||
this.meetingDomainSupport = meetingDomainSupport;
|
this.meetingDomainSupport = meetingDomainSupport;
|
||||||
this.meetingRuntimeProfileResolver = meetingRuntimeProfileResolver;
|
this.meetingRuntimeProfileResolver = meetingRuntimeProfileResolver;
|
||||||
|
|
@ -118,7 +118,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
|
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
|
||||||
MeetingSummaryFileService meetingSummaryFileService,
|
MeetingSummaryFileService meetingSummaryFileService,
|
||||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||||
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
|
|
||||||
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||||
MeetingDomainSupport meetingDomainSupport,
|
MeetingDomainSupport meetingDomainSupport,
|
||||||
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
|
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
|
||||||
|
|
@ -134,7 +134,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
transcriptMapper,
|
transcriptMapper,
|
||||||
meetingSummaryFileService,
|
meetingSummaryFileService,
|
||||||
meetingTranscriptFileService,
|
meetingTranscriptFileService,
|
||||||
meetingTranscriptRevisionService,
|
|
||||||
meetingTranscriptChapterService,
|
meetingTranscriptChapterService,
|
||||||
meetingDomainSupport,
|
meetingDomainSupport,
|
||||||
meetingRuntimeProfileResolver,
|
meetingRuntimeProfileResolver,
|
||||||
|
|
@ -585,7 +585,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
if (updated <= 0) {
|
if (updated <= 0) {
|
||||||
throw new RuntimeException("转录记录不存在");
|
throw new RuntimeException("转录记录不存在");
|
||||||
}
|
}
|
||||||
meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId());
|
|
||||||
meetingTranscriptChapterService.invalidateCurrentVersion(command.getMeetingId());
|
meetingTranscriptChapterService.invalidateCurrentVersion(command.getMeetingId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
|
@ -55,6 +57,21 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
||||||
return objectMapper.convertValue(snapshot, Map.class);
|
return objectMapper.convertValue(snapshot, Map.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<Long, Map<String, Object>> getProgressMaps(List<Long> meetingIds) {
|
||||||
|
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
|
||||||
|
if (meetingIds == null || meetingIds.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (Long meetingId : meetingIds) {
|
||||||
|
if (meetingId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.put(meetingId, getProgressMap(meetingId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Integer resolvePercent(Long meetingId) {
|
public Integer resolvePercent(Long meetingId) {
|
||||||
MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class);
|
MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class);
|
||||||
|
|
@ -112,6 +129,7 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
||||||
int percent,
|
int percent,
|
||||||
String message,
|
String message,
|
||||||
int eta) {
|
int eta) {
|
||||||
|
Integer queueAheadCount = resolveQueueAheadCount(task, stage);
|
||||||
String externalTaskId = null;
|
String externalTaskId = null;
|
||||||
if (task != null && task.getResponseData() != null && task.getResponseData().get("task_id") != null) {
|
if (task != null && task.getResponseData() != null && task.getResponseData().get("task_id") != null) {
|
||||||
externalTaskId = String.valueOf(task.getResponseData().get("task_id"));
|
externalTaskId = String.valueOf(task.getResponseData().get("task_id"));
|
||||||
|
|
@ -125,8 +143,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
||||||
.stage(stage.getCode())
|
.stage(stage.getCode())
|
||||||
.stageOrder(stage.getOrder())
|
.stageOrder(stage.getOrder())
|
||||||
.percent(percent)
|
.percent(percent)
|
||||||
.message(message)
|
.message(resolveMessage(stage, message, queueAheadCount))
|
||||||
.eta(eta)
|
.eta(eta)
|
||||||
|
.queueAheadCount(queueAheadCount)
|
||||||
.externalTaskId(externalTaskId)
|
.externalTaskId(externalTaskId)
|
||||||
.queuedAt(task == null ? null : task.getQueuedAt())
|
.queuedAt(task == null ? null : task.getQueuedAt())
|
||||||
.startedAt(task == null ? null : task.getStartedAt())
|
.startedAt(task == null ? null : task.getStartedAt())
|
||||||
|
|
@ -185,6 +204,36 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Integer resolveQueueAheadCount(AiTask task, MeetingProgressStage stage) {
|
||||||
|
if (task == null
|
||||||
|
|| task.getId() == null
|
||||||
|
|| task.getQueuedAt() == null
|
||||||
|
|| stage != MeetingProgressStage.QUEUED
|
||||||
|
|| !"ASR".equals(task.getTaskType())
|
||||||
|
|| !Integer.valueOf(0).equals(task.getStatus())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.toIntExact(aiTaskMapper.selectCount(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getTaskType, "ASR")
|
||||||
|
.eq(AiTask::getStatus, 0)
|
||||||
|
.and(wrapper -> wrapper
|
||||||
|
.lt(AiTask::getQueuedAt, task.getQueuedAt())
|
||||||
|
.or(orWrapper -> orWrapper
|
||||||
|
.eq(AiTask::getQueuedAt, task.getQueuedAt())
|
||||||
|
.lt(AiTask::getId, task.getId())))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveMessage(MeetingProgressStage stage, String message, Integer queueAheadCount) {
|
||||||
|
if (stage != MeetingProgressStage.QUEUED) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
String baseMessage = (message == null || message.isBlank()) ? "已进入 ASR 队列,等待执行" : message.trim();
|
||||||
|
if (queueAheadCount == null || baseMessage.contains("前面还有")) {
|
||||||
|
return baseMessage;
|
||||||
|
}
|
||||||
|
return baseMessage + ",前面还有 " + queueAheadCount + " 个任务";
|
||||||
|
}
|
||||||
|
|
||||||
private boolean shouldReplace(MeetingProgressSnapshot existing, MeetingProgressSnapshot candidate) {
|
private boolean shouldReplace(MeetingProgressSnapshot existing, MeetingProgressSnapshot candidate) {
|
||||||
if (candidate == null) {
|
if (candidate == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -198,6 +247,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTerminal(existing) && !isTerminal(candidate)) {
|
if (isTerminal(existing) && !isTerminal(candidate)) {
|
||||||
|
if (isNewAttempt(existing, candidate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isTerminal(candidate)) {
|
if (isTerminal(candidate)) {
|
||||||
|
|
@ -239,6 +291,22 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
||||||
&& (existing.getQueuedAt() == null || candidate.getQueuedAt().isAfter(existing.getQueuedAt()));
|
&& (existing.getQueuedAt() == null || candidate.getQueuedAt().isAfter(existing.getQueuedAt()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isNewAttempt(MeetingProgressSnapshot existing, MeetingProgressSnapshot candidate) {
|
||||||
|
Long existingUpdateAt = existing.getUpdateAt() == null ? 0L : existing.getUpdateAt();
|
||||||
|
Long candidateUpdateAt = candidate.getUpdateAt() == null ? 0L : candidate.getUpdateAt();
|
||||||
|
if (candidateUpdateAt < existingUpdateAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (candidate.getTaskId() != null && !candidate.getTaskId().equals(existing.getTaskId())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Integer existingMeetingStatus = existing.getMeetingStatus();
|
||||||
|
Integer candidateMeetingStatus = candidate.getMeetingStatus();
|
||||||
|
return candidateMeetingStatus != null
|
||||||
|
&& existingMeetingStatus != null
|
||||||
|
&& !candidateMeetingStatus.equals(existingMeetingStatus);
|
||||||
|
}
|
||||||
|
|
||||||
private void afterCommitOrNow(Runnable runnable) {
|
private void afterCommitOrNow(Runnable runnable) {
|
||||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
runnable.run();
|
runnable.run();
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
|
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
|
||||||
Long userId, String userName, String viewType, boolean isAdmin) {
|
Long userId, String userName, String viewType, Integer status, boolean isAdmin) {
|
||||||
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>();
|
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>();
|
||||||
|
|
||||||
if (!isAdmin || !"all".equals(viewType)) {
|
if (!isAdmin || !"all".equals(viewType)) {
|
||||||
|
|
@ -61,6 +61,10 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
|
||||||
wrapper.like(Meeting::getTitle, title);
|
wrapper.like(Meeting::getTitle, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status != null) {
|
||||||
|
wrapper.eq(Meeting::getStatus, status);
|
||||||
|
}
|
||||||
|
|
||||||
wrapper.orderByDesc(Meeting::getCreatedAt);
|
wrapper.orderByDesc(Meeting::getCreatedAt);
|
||||||
|
|
||||||
Page<Meeting> page = meetingService.page(new Page<>(current, size), wrapper);
|
Page<Meeting> page = meetingService.page(new Page<>(current, size), wrapper);
|
||||||
|
|
|
||||||
|
|
@ -1,833 +0,0 @@
|
||||||
package com.imeeting.service.biz.impl;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.imeeting.common.SysParamKeys;
|
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
|
||||||
import com.imeeting.dto.biz.MeetingSummarySource;
|
|
||||||
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
|
||||||
import com.imeeting.entity.biz.AiTask;
|
|
||||||
import com.imeeting.entity.biz.Meeting;
|
|
||||||
import com.imeeting.entity.biz.MeetingTranscript;
|
|
||||||
import com.imeeting.entity.biz.MeetingTranscriptRevision;
|
|
||||||
import com.imeeting.entity.biz.MeetingTranscriptRevisionItem;
|
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptRevisionItemMapper;
|
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptRevisionMapper;
|
|
||||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
|
||||||
import com.unisbase.service.SysParamService;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.TreeMap;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class MeetingTranscriptRevisionServiceImpl implements MeetingTranscriptRevisionService {
|
|
||||||
|
|
||||||
private static final String RULE_PROFILE_VERSION = "v1";
|
|
||||||
private static final String TRIGGER_TASK_TYPE = "SUMMARY";
|
|
||||||
private static final String SEMANTIC_CORRECTOR = "NONE_V1";
|
|
||||||
private static final String SOURCE_TYPE_REVISION = "REVISION";
|
|
||||||
private static final String SOURCE_TYPE_RAW_FALLBACK = "RAW_FALLBACK";
|
|
||||||
private static final int MERGE_GAP_THRESHOLD_MS = 3000;
|
|
||||||
|
|
||||||
private final MeetingTranscriptMapper transcriptMapper;
|
|
||||||
private final MeetingTranscriptRevisionMapper revisionMapper;
|
|
||||||
private final MeetingTranscriptRevisionItemMapper itemMapper;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final SysParamService sysParamService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
|
||||||
public String generateOfflineCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel) {
|
|
||||||
MeetingTranscriptRevision revision = createCurrentRevision(meeting, task, asrModel);
|
|
||||||
return revision.getCleanedFullText();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
|
||||||
public MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask, AiModelVO asrModel) {
|
|
||||||
List<MeetingTranscript> transcripts = loadRawTranscripts(meeting.getId());
|
|
||||||
String rawText = buildRawTranscriptText(transcripts);
|
|
||||||
String fingerprint = buildSourceFingerprint(transcripts);
|
|
||||||
if (transcripts.isEmpty() || rawText.isBlank()) {
|
|
||||||
return buildFallbackSource(rawText, fingerprint);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> ruleProfile = buildRuleProfile(fingerprint, asrModel);
|
|
||||||
String ruleProfileJson = toJson(ruleProfile);
|
|
||||||
MeetingTranscriptRevision current = findCurrentRevision(meeting.getId());
|
|
||||||
if (isReusableCurrentRevision(current, ruleProfileJson)) {
|
|
||||||
return MeetingSummarySource.builder()
|
|
||||||
.text(current.getCleanedFullText())
|
|
||||||
.sourceType(SOURCE_TYPE_REVISION)
|
|
||||||
.revisionId(current.getId())
|
|
||||||
.fallbackUsed(false)
|
|
||||||
.sourceFingerprint(fingerprint)
|
|
||||||
.triggerTaskType(TRIGGER_TASK_TYPE)
|
|
||||||
.semanticCorrector(SEMANTIC_CORRECTOR)
|
|
||||||
.ruleProfileVersion(RULE_PROFILE_VERSION)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
MeetingTranscriptRevision revision = createCurrentRevision(meeting, summaryTask, asrModel);
|
|
||||||
return MeetingSummarySource.builder()
|
|
||||||
.text(revision.getCleanedFullText())
|
|
||||||
.sourceType(SOURCE_TYPE_REVISION)
|
|
||||||
.revisionId(revision.getId())
|
|
||||||
.fallbackUsed(false)
|
|
||||||
.sourceFingerprint(fingerprint)
|
|
||||||
.triggerTaskType(TRIGGER_TASK_TYPE)
|
|
||||||
.semanticCorrector(SEMANTIC_CORRECTOR)
|
|
||||||
.ruleProfileVersion(RULE_PROFILE_VERSION)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<MeetingTranscriptVO> listEffectiveTranscripts(Long meetingId) {
|
|
||||||
List<MeetingTranscript> transcripts = loadRawTranscripts(meetingId);
|
|
||||||
MeetingTranscriptRevision current = findCurrentRevision(meetingId);
|
|
||||||
Map<Long, MeetingTranscriptRevisionItem> itemByTranscriptId = new LinkedHashMap<>();
|
|
||||||
if (current != null) {
|
|
||||||
itemByTranscriptId = itemMapper.selectList(new LambdaQueryWrapper<MeetingTranscriptRevisionItem>()
|
|
||||||
.eq(MeetingTranscriptRevisionItem::getRevisionId, current.getId())
|
|
||||||
.orderByAsc(MeetingTranscriptRevisionItem::getSourceSortOrder)
|
|
||||||
.orderByAsc(MeetingTranscriptRevisionItem::getId))
|
|
||||||
.stream()
|
|
||||||
.collect(Collectors.toMap(
|
|
||||||
MeetingTranscriptRevisionItem::getSourceTranscriptId,
|
|
||||||
item -> item,
|
|
||||||
(left, right) -> right.getCleanedContent() != null && !right.getCleanedContent().isBlank() ? right : left,
|
|
||||||
LinkedHashMap::new
|
|
||||||
));
|
|
||||||
}
|
|
||||||
List<MeetingTranscriptVO> result = new ArrayList<>();
|
|
||||||
for (MeetingTranscript transcript : transcripts) {
|
|
||||||
MeetingTranscriptRevisionItem item = itemByTranscriptId.get(transcript.getId());
|
|
||||||
if (item != null && isSuppressedAction(item.getActionType())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
MeetingTranscriptVO vo = new MeetingTranscriptVO();
|
|
||||||
vo.setId(transcript.getId());
|
|
||||||
vo.setSpeakerId(transcript.getSpeakerId());
|
|
||||||
vo.setSpeakerName(transcript.getSpeakerName());
|
|
||||||
vo.setSpeakerLabel(transcript.getSpeakerLabel());
|
|
||||||
vo.setStartTime(transcript.getStartTime());
|
|
||||||
vo.setEndTime(transcript.getEndTime());
|
|
||||||
vo.setContent(resolveEffectiveContent(transcript, item));
|
|
||||||
result.add(vo);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
|
||||||
public boolean updateCurrentRevisionContent(Long meetingId, Long operatorId, String content) {
|
|
||||||
MeetingTranscriptRevision current = findCurrentRevision(meetingId);
|
|
||||||
if (current == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
current.setCleanedFullText(content);
|
|
||||||
revisionMapper.updateById(current);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
|
||||||
public void invalidateCurrentRevision(Long meetingId) {
|
|
||||||
revisionMapper.update(null, new LambdaUpdateWrapper<MeetingTranscriptRevision>()
|
|
||||||
.eq(MeetingTranscriptRevision::getMeetingId, meetingId)
|
|
||||||
.eq(MeetingTranscriptRevision::getIsCurrent, 1)
|
|
||||||
.set(MeetingTranscriptRevision::getIsCurrent, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
|
||||||
protected MeetingTranscriptRevision createCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel) {
|
|
||||||
List<MeetingTranscript> transcripts = loadRawTranscripts(meeting.getId());
|
|
||||||
String fingerprint = buildSourceFingerprint(transcripts);
|
|
||||||
Map<String, Object> ruleProfile = buildRuleProfile(fingerprint, asrModel);
|
|
||||||
CleaningResult cleaningResult = cleanTranscripts(transcripts, ruleProfile);
|
|
||||||
MeetingTranscriptRevision current = findCurrentRevision(meeting.getId());
|
|
||||||
int nextRevisionNo = resolveNextRevisionNo(meeting.getId(), current);
|
|
||||||
|
|
||||||
MeetingTranscriptRevision draft = new MeetingTranscriptRevision();
|
|
||||||
draft.setMeetingId(meeting.getId());
|
|
||||||
draft.setSourceTaskId(task != null ? task.getId() : null);
|
|
||||||
draft.setRevisionNo(nextRevisionNo);
|
|
||||||
draft.setStatus(1);
|
|
||||||
draft.setCleanedFullText(cleaningResult.fullText());
|
|
||||||
draft.setRuleProfile(toJson(ruleProfile));
|
|
||||||
draft.setSegmentCount(transcripts.size());
|
|
||||||
draft.setDroppedSegmentCount(cleaningResult.droppedCount());
|
|
||||||
draft.setMergedGroupCount(cleaningResult.mergedGroupCount());
|
|
||||||
draft.setIsCurrent(0);
|
|
||||||
revisionMapper.insert(draft);
|
|
||||||
|
|
||||||
if (current != null) {
|
|
||||||
revisionMapper.update(null, new LambdaUpdateWrapper<MeetingTranscriptRevision>()
|
|
||||||
.eq(MeetingTranscriptRevision::getMeetingId, meeting.getId())
|
|
||||||
.eq(MeetingTranscriptRevision::getIsCurrent, 1)
|
|
||||||
.set(MeetingTranscriptRevision::getIsCurrent, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
MeetingTranscriptRevision finalRevision = new MeetingTranscriptRevision();
|
|
||||||
finalRevision.setMeetingId(meeting.getId());
|
|
||||||
finalRevision.setSourceTaskId(task != null ? task.getId() : null);
|
|
||||||
finalRevision.setRevisionNo(nextRevisionNo + 1);
|
|
||||||
finalRevision.setStatus(2);
|
|
||||||
finalRevision.setCleanedFullText(cleaningResult.fullText());
|
|
||||||
finalRevision.setRuleProfile(toJson(ruleProfile));
|
|
||||||
finalRevision.setSegmentCount(transcripts.size());
|
|
||||||
finalRevision.setDroppedSegmentCount(cleaningResult.droppedCount());
|
|
||||||
finalRevision.setMergedGroupCount(cleaningResult.mergedGroupCount());
|
|
||||||
finalRevision.setIsCurrent(1);
|
|
||||||
revisionMapper.insert(finalRevision);
|
|
||||||
|
|
||||||
for (MeetingTranscriptRevisionItem item : cleaningResult.items()) {
|
|
||||||
item.setRevisionId(finalRevision.getId());
|
|
||||||
itemMapper.insert(item);
|
|
||||||
}
|
|
||||||
return finalRevision;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<MeetingTranscript> loadRawTranscripts(Long meetingId) {
|
|
||||||
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
|
||||||
.eq(MeetingTranscript::getMeetingId, meetingId)
|
|
||||||
.orderByAsc(MeetingTranscript::getSortOrder)
|
|
||||||
.orderByAsc(MeetingTranscript::getStartTime)
|
|
||||||
.orderByAsc(MeetingTranscript::getId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private MeetingTranscriptRevision findCurrentRevision(Long meetingId) {
|
|
||||||
return revisionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptRevision>()
|
|
||||||
.eq(MeetingTranscriptRevision::getMeetingId, meetingId)
|
|
||||||
.eq(MeetingTranscriptRevision::getIsCurrent, 1)
|
|
||||||
.orderByDesc(MeetingTranscriptRevision::getRevisionNo)
|
|
||||||
.last("limit 1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private int resolveNextRevisionNo(Long meetingId, MeetingTranscriptRevision current) {
|
|
||||||
if (current != null && current.getRevisionNo() != null) {
|
|
||||||
return current.getRevisionNo() + 1;
|
|
||||||
}
|
|
||||||
MeetingTranscriptRevision latest = revisionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptRevision>()
|
|
||||||
.eq(MeetingTranscriptRevision::getMeetingId, meetingId)
|
|
||||||
.orderByDesc(MeetingTranscriptRevision::getRevisionNo)
|
|
||||||
.last("limit 1"));
|
|
||||||
return latest == null || latest.getRevisionNo() == null ? 1 : latest.getRevisionNo() + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isReusableCurrentRevision(MeetingTranscriptRevision current, String expectedRuleProfile) {
|
|
||||||
return current != null
|
|
||||||
&& Integer.valueOf(1).equals(current.getIsCurrent())
|
|
||||||
&& Integer.valueOf(2).equals(current.getStatus())
|
|
||||||
&& current.getCleanedFullText() != null
|
|
||||||
&& !current.getCleanedFullText().isBlank()
|
|
||||||
&& Objects.equals(expectedRuleProfile, current.getRuleProfile());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> buildRuleProfile(String fingerprint, AiModelVO asrModel) {
|
|
||||||
Map<String, Object> profile = new LinkedHashMap<>();
|
|
||||||
profile.put("ruleProfileVersion", RULE_PROFILE_VERSION);
|
|
||||||
profile.put("sourceFingerprint", fingerprint);
|
|
||||||
profile.put("triggerTaskType", TRIGGER_TASK_TYPE);
|
|
||||||
profile.put("semanticCorrector", SEMANTIC_CORRECTOR);
|
|
||||||
profile.put("transcriptRuleFillerWords", resolveFillerWords(asrModel));
|
|
||||||
profile.put("transcriptRuleReplacements", resolveReplacementRules(asrModel));
|
|
||||||
return profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> resolveFillerWords(AiModelVO asrModel) {
|
|
||||||
List<String> configured = parseCleanupWords(sysParamService.getCachedParamValue(
|
|
||||||
SysParamKeys.MEETING_TRANSCRIPT_CLEANUP_FILLER_WORDS,
|
|
||||||
""
|
|
||||||
));
|
|
||||||
if (!configured.isEmpty()) {
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
if (asrModel == null || asrModel.getMediaConfig() == null) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
Object raw = asrModel.getMediaConfig().get("transcriptRuleFillerWords");
|
|
||||||
if (!(raw instanceof List<?> list)) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
return list.stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.map(String::valueOf)
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(value -> !value.isBlank())
|
|
||||||
.distinct()
|
|
||||||
.sorted()
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, String> resolveReplacementRules(AiModelVO asrModel) {
|
|
||||||
Map<String, String> configured = parseCleanupReplacements(sysParamService.getCachedParamValue(
|
|
||||||
SysParamKeys.MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS,
|
|
||||||
""
|
|
||||||
));
|
|
||||||
if (!configured.isEmpty()) {
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
if (asrModel == null || asrModel.getMediaConfig() == null) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
Object raw = asrModel.getMediaConfig().get("transcriptRuleReplacements");
|
|
||||||
if (!(raw instanceof Map<?, ?> map)) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
Map<String, String> result = new TreeMap<>();
|
|
||||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
|
||||||
if (entry.getKey() == null || entry.getValue() == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String key = String.valueOf(entry.getKey()).trim();
|
|
||||||
String value = String.valueOf(entry.getValue()).trim();
|
|
||||||
if (!key.isBlank() && !value.isBlank()) {
|
|
||||||
result.put(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> parseCleanupWords(String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
String normalized = raw.trim();
|
|
||||||
try {
|
|
||||||
if (normalized.startsWith("[")) {
|
|
||||||
List<?> parsed = objectMapper.readValue(normalized, new TypeReference<List<?>>() {});
|
|
||||||
return parsed.stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.map(String::valueOf)
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(value -> !value.isBlank())
|
|
||||||
.distinct()
|
|
||||||
.sorted()
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// Fall back to plain-text parsing.
|
|
||||||
}
|
|
||||||
return java.util.Arrays.stream(normalized.split("[,,;;\\r\\n]+"))
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(value -> !value.isBlank())
|
|
||||||
.distinct()
|
|
||||||
.sorted()
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, String> parseCleanupReplacements(String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
String normalized = raw.trim();
|
|
||||||
try {
|
|
||||||
if (normalized.startsWith("{")) {
|
|
||||||
Map<String, Object> parsed = objectMapper.readValue(normalized, new TypeReference<Map<String, Object>>() {});
|
|
||||||
Map<String, String> result = new TreeMap<>();
|
|
||||||
for (Map.Entry<String, Object> entry : parsed.entrySet()) {
|
|
||||||
if (entry.getKey() == null || entry.getValue() == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String key = entry.getKey().trim();
|
|
||||||
String value = String.valueOf(entry.getValue()).trim();
|
|
||||||
if (!key.isBlank() && !value.isBlank()) {
|
|
||||||
result.put(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// Fall back to line-based parsing.
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, String> result = new TreeMap<>();
|
|
||||||
for (String line : normalized.split("[\\r\\n]+")) {
|
|
||||||
String candidate = line == null ? "" : line.trim();
|
|
||||||
if (candidate.isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String[] separatorCandidates = {"=>", "=", ":"};
|
|
||||||
for (String separator : separatorCandidates) {
|
|
||||||
int index = candidate.indexOf(separator);
|
|
||||||
if (index <= 0 || index >= candidate.length() - separator.length()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String key = candidate.substring(0, index).trim();
|
|
||||||
String value = candidate.substring(index + separator.length()).trim();
|
|
||||||
if (!key.isBlank() && !value.isBlank()) {
|
|
||||||
result.put(key, value);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private CleaningResult cleanTranscripts(List<MeetingTranscript> transcripts, Map<String, Object> ruleProfile) {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<String> fillerWords = (List<String>) ruleProfile.getOrDefault("transcriptRuleFillerWords", List.of());
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, String> replacements = (Map<String, String>) ruleProfile.getOrDefault("transcriptRuleReplacements", Map.of());
|
|
||||||
|
|
||||||
List<String> orderedFillerWords = fillerWords.stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(value -> !value.isBlank())
|
|
||||||
.distinct()
|
|
||||||
.sorted(Comparator.comparingInt(String::length).reversed().thenComparing(String::compareTo))
|
|
||||||
.toList();
|
|
||||||
List<Map.Entry<String, String>> orderedReplacementRules = replacements.entrySet().stream()
|
|
||||||
.filter(entry -> entry.getKey() != null && entry.getValue() != null)
|
|
||||||
.sorted(Comparator.<Map.Entry<String, String>>comparingInt(entry -> entry.getKey().length()).reversed()
|
|
||||||
.thenComparing(Map.Entry::getKey))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
List<MeetingTranscriptRevisionItem> items = new ArrayList<>();
|
|
||||||
List<SegmentGroupState> groups = new ArrayList<>();
|
|
||||||
MeetingTranscript previousTranscript = null;
|
|
||||||
SegmentGroupState currentGroup = null;
|
|
||||||
int droppedCount = 0;
|
|
||||||
|
|
||||||
for (int index = 0; index < transcripts.size(); index++) {
|
|
||||||
MeetingTranscript transcript = transcripts.get(index);
|
|
||||||
String normalizedSource = normalizeRawContent(transcript.getContent());
|
|
||||||
MeetingTranscriptRevisionItem item = createRevisionItem(transcript, transcripts, index);
|
|
||||||
|
|
||||||
if (previousTranscript != null
|
|
||||||
&& currentGroup != null
|
|
||||||
&& isAdjacentDuplicate(previousTranscript, transcript)) {
|
|
||||||
item.setActionType("DROP_DUPLICATE");
|
|
||||||
item.setCleanedContent("");
|
|
||||||
item.setCleanedSpeakerName(transcript.getSpeakerName());
|
|
||||||
item.setMergeGroupId(currentGroup.getGroupId());
|
|
||||||
item.setConfidence(confidenceForAction("DROP_DUPLICATE"));
|
|
||||||
item.setRuleHits(toJson(buildRuleHits(List.of(), List.of())));
|
|
||||||
items.add(item);
|
|
||||||
droppedCount++;
|
|
||||||
previousTranscript = transcript;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentGroup != null && shouldMergeIntoCurrentGroup(currentGroup, transcript)) {
|
|
||||||
currentGroup.appendSourceContent(normalizedSource, transcript);
|
|
||||||
currentGroup.incrementSourceSegmentCount();
|
|
||||||
currentGroup.getRepresentativeItem().setMergeGroupId(currentGroup.getGroupId());
|
|
||||||
item.setActionType("MERGE_INTO_PREV");
|
|
||||||
item.setCleanedContent("");
|
|
||||||
item.setCleanedSpeakerName(transcript.getSpeakerName());
|
|
||||||
item.setMergeGroupId(currentGroup.getGroupId());
|
|
||||||
item.setConfidence(confidenceForAction("MERGE_INTO_PREV"));
|
|
||||||
item.setRuleHits(toJson(buildRuleHits(List.of(), List.of())));
|
|
||||||
items.add(item);
|
|
||||||
previousTranscript = transcript;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.setMergeGroupId("");
|
|
||||||
items.add(item);
|
|
||||||
currentGroup = SegmentGroupState.start("merge-" + transcript.getId() + "-" + UUID.randomUUID(), transcript, item, normalizedSource);
|
|
||||||
groups.add(currentGroup);
|
|
||||||
previousTranscript = transcript;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> finalLines = new ArrayList<>();
|
|
||||||
int mergedGroupCount = 0;
|
|
||||||
for (SegmentGroupState group : groups) {
|
|
||||||
if (group.getSourceSegmentCount() > 1) {
|
|
||||||
mergedGroupCount++;
|
|
||||||
group.getRepresentativeItem().setMergeGroupId(group.getGroupId());
|
|
||||||
}
|
|
||||||
|
|
||||||
TextCleanupResult cleanupResult = cleanTranscriptContent(group.getMergedSourceContent(), orderedFillerWords, orderedReplacementRules);
|
|
||||||
String cleaned = cleanupResult.cleanedContent();
|
|
||||||
MeetingTranscriptRevisionItem representativeItem = group.getRepresentativeItem();
|
|
||||||
representativeItem.setCleanedContent(cleaned);
|
|
||||||
representativeItem.setCleanedSpeakerName(group.getRepresentativeTranscript().getSpeakerName());
|
|
||||||
representativeItem.setActionType(resolveActionType(group.getMergedSourceContent(), cleaned,
|
|
||||||
cleanupResult.matchedFillerWords(), cleanupResult.matchedReplacementRules()));
|
|
||||||
representativeItem.setRuleHits(toJson(buildRuleHits(
|
|
||||||
cleanupResult.matchedFillerWords(),
|
|
||||||
cleanupResult.matchedReplacementRules()
|
|
||||||
)));
|
|
||||||
representativeItem.setConfidence(confidenceForAction(representativeItem.getActionType()));
|
|
||||||
|
|
||||||
if (cleaned.isBlank()) {
|
|
||||||
droppedCount += group.getSourceSegmentCount();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
finalLines.add(formatTranscriptLine(group.getRepresentativeTranscript(), cleaned));
|
|
||||||
}
|
|
||||||
|
|
||||||
String fullText = finalLines.stream()
|
|
||||||
.filter(line -> line != null && !line.isBlank())
|
|
||||||
.collect(Collectors.joining("\n"));
|
|
||||||
return new CleaningResult(fullText, items, droppedCount, mergedGroupCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildSourceFingerprint(List<MeetingTranscript> transcripts) {
|
|
||||||
String raw = transcripts.stream()
|
|
||||||
.sorted(Comparator.comparing(MeetingTranscript::getSortOrder, Comparator.nullsLast(Integer::compareTo))
|
|
||||||
.thenComparing(MeetingTranscript::getStartTime, Comparator.nullsLast(Integer::compareTo))
|
|
||||||
.thenComparing(MeetingTranscript::getId, Comparator.nullsLast(Long::compareTo)))
|
|
||||||
.map(transcript -> String.join("|",
|
|
||||||
String.valueOf(transcript.getId()),
|
|
||||||
nullSafe(transcript.getSpeakerId()),
|
|
||||||
nullSafe(transcript.getSpeakerName()),
|
|
||||||
nullSafe(transcript.getContent()),
|
|
||||||
String.valueOf(transcript.getStartTime()),
|
|
||||||
String.valueOf(transcript.getEndTime()),
|
|
||||||
String.valueOf(transcript.getSortOrder())))
|
|
||||||
.collect(Collectors.joining("\n"));
|
|
||||||
try {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
byte[] hashed = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
|
||||||
StringBuilder builder = new StringBuilder();
|
|
||||||
for (byte b : hashed) {
|
|
||||||
builder.append(String.format("%02x", b));
|
|
||||||
}
|
|
||||||
return builder.toString();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
throw new RuntimeException("计算转录内容失败", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildRawTranscriptText(List<MeetingTranscript> transcripts) {
|
|
||||||
return transcripts.stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.map(transcript -> formatTranscriptLine(transcript, normalizeRawContent(transcript.getContent())))
|
|
||||||
.filter(line -> line != null && !line.isBlank())
|
|
||||||
.collect(Collectors.joining("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private MeetingSummarySource buildFallbackSource(String rawText, String fingerprint) {
|
|
||||||
return MeetingSummarySource.builder()
|
|
||||||
.text(rawText)
|
|
||||||
.sourceType(SOURCE_TYPE_RAW_FALLBACK)
|
|
||||||
.revisionId(null)
|
|
||||||
.fallbackUsed(true)
|
|
||||||
.sourceFingerprint(fingerprint)
|
|
||||||
.triggerTaskType(TRIGGER_TASK_TYPE)
|
|
||||||
.semanticCorrector(SEMANTIC_CORRECTOR)
|
|
||||||
.ruleProfileVersion(RULE_PROFILE_VERSION)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatTranscriptLine(MeetingTranscript transcript, String content) {
|
|
||||||
if (content == null || content.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String speaker = transcript.getSpeakerName();
|
|
||||||
if (speaker == null || speaker.isBlank()) {
|
|
||||||
speaker = transcript.getSpeakerId();
|
|
||||||
}
|
|
||||||
if (speaker == null || speaker.isBlank()) {
|
|
||||||
return content.trim();
|
|
||||||
}
|
|
||||||
return speaker.trim() + ": " + content.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveEffectiveContent(MeetingTranscript transcript, MeetingTranscriptRevisionItem item) {
|
|
||||||
if (item == null) {
|
|
||||||
return transcript.getContent();
|
|
||||||
}
|
|
||||||
if (item.getCleanedContent() != null) {
|
|
||||||
return item.getCleanedContent();
|
|
||||||
}
|
|
||||||
return transcript.getContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isSuppressedAction(String actionType) {
|
|
||||||
return "MERGE_INTO_PREV".equals(actionType)
|
|
||||||
|| "DROP_FILLER".equals(actionType)
|
|
||||||
|| "DROP_DUPLICATE".equals(actionType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveActionType(String normalizedSource,
|
|
||||||
String cleaned,
|
|
||||||
List<String> matchedFillerWords,
|
|
||||||
List<String> matchedReplacementRules) {
|
|
||||||
if (cleaned == null || cleaned.isBlank()) {
|
|
||||||
return !matchedFillerWords.isEmpty() ? "DROP_FILLER" : "EDIT";
|
|
||||||
}
|
|
||||||
if (Objects.equals(cleaned, normalizedSource)) {
|
|
||||||
return "KEEP";
|
|
||||||
}
|
|
||||||
return "RULE_REPLACED";
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> buildRuleHits(List<String> matchedFillerWords, List<String> matchedReplacementRules) {
|
|
||||||
Map<String, Object> ruleHits = new LinkedHashMap<>();
|
|
||||||
ruleHits.put("fillerWords", matchedFillerWords == null ? List.of() : List.copyOf(matchedFillerWords));
|
|
||||||
ruleHits.put("replacements", matchedReplacementRules == null ? List.of() : List.copyOf(matchedReplacementRules));
|
|
||||||
return ruleHits;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MeetingTranscriptRevisionItem createRevisionItem(MeetingTranscript transcript,
|
|
||||||
List<MeetingTranscript> transcripts,
|
|
||||||
int index) {
|
|
||||||
MeetingTranscriptRevisionItem item = new MeetingTranscriptRevisionItem();
|
|
||||||
item.setSourceTranscriptId(transcript.getId());
|
|
||||||
item.setSourceSortOrder(transcript.getSortOrder());
|
|
||||||
item.setSourceSpeakerId(transcript.getSpeakerId());
|
|
||||||
item.setSourceSpeakerName(transcript.getSpeakerName());
|
|
||||||
item.setSourceContent(transcript.getContent());
|
|
||||||
item.setCleanedSpeakerName(transcript.getSpeakerName());
|
|
||||||
item.setContextSnapshot(buildContextSnapshot(transcripts, index));
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildContextSnapshot(List<MeetingTranscript> transcripts, int index) {
|
|
||||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
|
||||||
snapshot.put("prevContent", index > 0 ? normalizeRawContent(transcripts.get(index - 1).getContent()) : "");
|
|
||||||
snapshot.put("currentContent", normalizeRawContent(transcripts.get(index).getContent()));
|
|
||||||
snapshot.put("nextContent", index + 1 < transcripts.size()
|
|
||||||
? normalizeRawContent(transcripts.get(index + 1).getContent())
|
|
||||||
: "");
|
|
||||||
return toJson(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isAdjacentDuplicate(MeetingTranscript previousTranscript, MeetingTranscript currentTranscript) {
|
|
||||||
return sameSpeaker(previousTranscript, currentTranscript)
|
|
||||||
&& !normalizeComparisonText(previousTranscript.getContent()).isBlank()
|
|
||||||
&& Objects.equals(
|
|
||||||
normalizeComparisonText(previousTranscript.getContent()),
|
|
||||||
normalizeComparisonText(currentTranscript.getContent())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean shouldMergeIntoCurrentGroup(SegmentGroupState currentGroup, MeetingTranscript currentTranscript) {
|
|
||||||
if (currentGroup == null || currentTranscript == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!sameSpeaker(currentGroup.getLastTranscript(), currentTranscript)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Integer previousEndTime = currentGroup.getLastTranscript().getEndTime();
|
|
||||||
Integer currentStartTime = currentTranscript.getStartTime();
|
|
||||||
if (previousEndTime == null || currentStartTime == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
int gap = currentStartTime - previousEndTime;
|
|
||||||
return gap >= 0 && gap <= MERGE_GAP_THRESHOLD_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean sameSpeaker(MeetingTranscript left, MeetingTranscript right) {
|
|
||||||
if (left == null || right == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return Objects.equals(nullSafe(left.getSpeakerId()), nullSafe(right.getSpeakerId()))
|
|
||||||
&& Objects.equals(nullSafe(left.getSpeakerName()), nullSafe(right.getSpeakerName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeComparisonText(String content) {
|
|
||||||
return normalizeRawContent(content)
|
|
||||||
.replaceAll("\\s+", "")
|
|
||||||
.replaceAll("[,。?!;:、,.!?;:]", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private TextCleanupResult cleanTranscriptContent(String content,
|
|
||||||
List<String> fillerWords,
|
|
||||||
List<Map.Entry<String, String>> replacementRules) {
|
|
||||||
String cleaned = normalizeRawContent(content);
|
|
||||||
List<String> matchedFillerWords = new ArrayList<>();
|
|
||||||
List<String> matchedReplacementRules = new ArrayList<>();
|
|
||||||
for (String fillerWord : fillerWords) {
|
|
||||||
String updated = removeFillerWord(cleaned, fillerWord);
|
|
||||||
if (!Objects.equals(updated, cleaned)) {
|
|
||||||
matchedFillerWords.add(fillerWord);
|
|
||||||
cleaned = updated;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (Map.Entry<String, String> entry : replacementRules) {
|
|
||||||
if (cleaned.contains(entry.getKey())) {
|
|
||||||
matchedReplacementRules.add(entry.getKey() + "->" + entry.getValue());
|
|
||||||
cleaned = cleaned.replace(entry.getKey(), entry.getValue());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleaned = normalizeCleanedContent(cleaned);
|
|
||||||
return new TextCleanupResult(cleaned, matchedFillerWords, matchedReplacementRules);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String removeFillerWord(String content, String fillerWord) {
|
|
||||||
if (content == null || content.isBlank() || fillerWord == null || fillerWord.isBlank()) {
|
|
||||||
return content == null ? "" : content;
|
|
||||||
}
|
|
||||||
Pattern pattern = Pattern.compile("(^|[\\s,。?!;:、,.!?;:])(" + Pattern.quote(fillerWord) + ")(?=($|[\\s,。?!;:、,.!?;:]))");
|
|
||||||
Matcher matcher = pattern.matcher(content);
|
|
||||||
StringBuffer buffer = new StringBuffer();
|
|
||||||
boolean changed = false;
|
|
||||||
while (matcher.find()) {
|
|
||||||
String prefix = matcher.group(1);
|
|
||||||
matcher.appendReplacement(buffer, Matcher.quoteReplacement(prefix == null ? "" : prefix));
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
matcher.appendTail(buffer);
|
|
||||||
return changed ? buffer.toString() : content;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeRawContent(String content) {
|
|
||||||
if (content == null || content.isBlank()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return content.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeCleanedContent(String content) {
|
|
||||||
if (content == null || content.isBlank()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
String normalized = content.replace("\r\n", " ")
|
|
||||||
.replace("\n", " ")
|
|
||||||
.replaceAll("\\s+", " ")
|
|
||||||
.trim();
|
|
||||||
normalized = normalized.replaceAll("\\s*([,。?!;:、,.!?;:])\\s*", "$1");
|
|
||||||
normalized = normalized.replaceAll("([,。?!;:、,.!?;:])[,。?!;:、,.!?;:]+", "$1");
|
|
||||||
normalized = normalized.replaceAll("^[,。?!;:、,.!?;:]+", "");
|
|
||||||
normalized = normalized.replaceAll("\\(\\s+\\)", "()");
|
|
||||||
return normalized.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal confidenceForAction(String actionType) {
|
|
||||||
return switch (actionType) {
|
|
||||||
case "DROP_DUPLICATE" -> BigDecimal.valueOf(0.98D);
|
|
||||||
case "DROP_FILLER" -> BigDecimal.valueOf(0.95D);
|
|
||||||
case "MERGE_INTO_PREV" -> BigDecimal.valueOf(0.90D);
|
|
||||||
case "RULE_REPLACED" -> BigDecimal.valueOf(0.88D);
|
|
||||||
default -> BigDecimal.ONE;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private String nullSafe(String value) {
|
|
||||||
return value == null ? "" : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toJson(Object value) {
|
|
||||||
try {
|
|
||||||
return objectMapper.writeValueAsString(value);
|
|
||||||
} catch (JsonProcessingException ex) {
|
|
||||||
throw new RuntimeException("序列化修正版元数据失败", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
private Map<String, Object> readRuleProfile(String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return objectMapper.readValue(raw, new TypeReference<>() {});
|
|
||||||
} catch (JsonProcessingException ex) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record CleaningResult(String fullText,
|
|
||||||
List<MeetingTranscriptRevisionItem> items,
|
|
||||||
int droppedCount,
|
|
||||||
int mergedGroupCount) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private record TextCleanupResult(String cleanedContent,
|
|
||||||
List<String> matchedFillerWords,
|
|
||||||
List<String> matchedReplacementRules) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class SegmentGroupState {
|
|
||||||
private final String groupId;
|
|
||||||
private final MeetingTranscript representativeTranscript;
|
|
||||||
private final MeetingTranscriptRevisionItem representativeItem;
|
|
||||||
private MeetingTranscript lastTranscript;
|
|
||||||
private String mergedSourceContent;
|
|
||||||
private int sourceSegmentCount;
|
|
||||||
|
|
||||||
private SegmentGroupState(String groupId,
|
|
||||||
MeetingTranscript representativeTranscript,
|
|
||||||
MeetingTranscriptRevisionItem representativeItem,
|
|
||||||
String mergedSourceContent) {
|
|
||||||
this.groupId = groupId;
|
|
||||||
this.representativeTranscript = representativeTranscript;
|
|
||||||
this.representativeItem = representativeItem;
|
|
||||||
this.lastTranscript = representativeTranscript;
|
|
||||||
this.mergedSourceContent = mergedSourceContent;
|
|
||||||
this.sourceSegmentCount = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SegmentGroupState start(String groupId,
|
|
||||||
MeetingTranscript representativeTranscript,
|
|
||||||
MeetingTranscriptRevisionItem representativeItem,
|
|
||||||
String mergedSourceContent) {
|
|
||||||
return new SegmentGroupState(groupId, representativeTranscript, representativeItem, mergedSourceContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void appendSourceContent(String nextContent, MeetingTranscript transcript) {
|
|
||||||
this.mergedSourceContent = joinTranscriptContent(this.mergedSourceContent, nextContent);
|
|
||||||
this.lastTranscript = transcript;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void incrementSourceSegmentCount() {
|
|
||||||
this.sourceSegmentCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String joinTranscriptContent(String left, String right) {
|
|
||||||
if (left == null || left.isBlank()) {
|
|
||||||
return right == null ? "" : right;
|
|
||||||
}
|
|
||||||
if (right == null || right.isBlank()) {
|
|
||||||
return left;
|
|
||||||
}
|
|
||||||
boolean leftAsciiTail = Character.isLetterOrDigit(left.charAt(left.length() - 1));
|
|
||||||
boolean rightAsciiHead = Character.isLetterOrDigit(right.charAt(0));
|
|
||||||
if (leftAsciiTail && rightAsciiHead) {
|
|
||||||
return left + " " + right;
|
|
||||||
}
|
|
||||||
return left + right;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getGroupId() {
|
|
||||||
return groupId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MeetingTranscript getRepresentativeTranscript() {
|
|
||||||
return representativeTranscript;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MeetingTranscriptRevisionItem getRepresentativeItem() {
|
|
||||||
return representativeItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MeetingTranscript getLastTranscript() {
|
|
||||||
return lastTranscript;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getMergedSourceContent() {
|
|
||||||
return mergedSourceContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getSourceSegmentCount() {
|
|
||||||
return sourceSegmentCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,6 +8,8 @@ import com.imeeting.entity.biz.AiTask;
|
||||||
import com.imeeting.service.biz.MeetingProgressService;
|
import com.imeeting.service.biz.MeetingProgressService;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
|
@ -38,6 +40,21 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
|
||||||
return objectMapper.convertValue(snapshot, Map.class);
|
return objectMapper.convertValue(snapshot, Map.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<Long, Map<String, Object>> getProgressMaps(List<Long> meetingIds) {
|
||||||
|
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
|
||||||
|
if (meetingIds == null || meetingIds.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (Long meetingId : meetingIds) {
|
||||||
|
if (meetingId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.put(meetingId, getProgressMap(meetingId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Integer resolvePercent(Long meetingId) {
|
public Integer resolvePercent(Long meetingId) {
|
||||||
MeetingProgressSnapshot snapshot = readSnapshot(meetingId);
|
MeetingProgressSnapshot snapshot = readSnapshot(meetingId);
|
||||||
|
|
@ -108,6 +125,10 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
|
||||||
int percent,
|
int percent,
|
||||||
String message,
|
String message,
|
||||||
int eta) {
|
int eta) {
|
||||||
|
String resolvedMessage = message;
|
||||||
|
if (stage == MeetingProgressStage.QUEUED && (resolvedMessage == null || resolvedMessage.isBlank())) {
|
||||||
|
resolvedMessage = "已进入 ASR 队列,等待执行";
|
||||||
|
}
|
||||||
return MeetingProgressSnapshot.builder()
|
return MeetingProgressSnapshot.builder()
|
||||||
.meetingId(meetingId)
|
.meetingId(meetingId)
|
||||||
.taskId(task == null ? null : task.getId())
|
.taskId(task == null ? null : task.getId())
|
||||||
|
|
@ -117,7 +138,7 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
|
||||||
.stage(stage.getCode())
|
.stage(stage.getCode())
|
||||||
.stageOrder(stage.getOrder())
|
.stageOrder(stage.getOrder())
|
||||||
.percent(percent)
|
.percent(percent)
|
||||||
.message(message)
|
.message(resolvedMessage)
|
||||||
.eta(eta)
|
.eta(eta)
|
||||||
.queuedAt(task == null ? null : task.getQueuedAt())
|
.queuedAt(task == null ? null : task.getQueuedAt())
|
||||||
.startedAt(task == null ? null : task.getStartedAt())
|
.startedAt(task == null ? null : task.getStartedAt())
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ public class MeetingMcpToolService {
|
||||||
loginUser.getUserId(),
|
loginUser.getUserId(),
|
||||||
resolveCreatorName(loginUser),
|
resolveCreatorName(loginUser),
|
||||||
"all",
|
"all",
|
||||||
|
null,
|
||||||
isAdmin
|
isAdmin
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,303 +1,152 @@
|
||||||
package com.imeeting.service.biz.impl;
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.imeeting.dto.biz.MeetingSummarySource;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
import com.imeeting.entity.biz.AiTask;
|
import com.imeeting.entity.biz.AiTask;
|
||||||
|
import com.imeeting.entity.biz.HotWord;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.MeetingTranscript;
|
|
||||||
import com.imeeting.mapper.biz.MeetingMapper;
|
import com.imeeting.mapper.biz.MeetingMapper;
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
|
import com.imeeting.service.biz.MeetingProgressService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
import com.imeeting.support.RedisValueSupport;
|
||||||
import com.imeeting.support.TaskSecurityContextRunner;
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
|
import com.unisbase.service.SysParamService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
||||||
import org.springframework.data.redis.core.ValueOperations;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
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.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.doReturn;
|
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.spy;
|
|
||||||
import static org.mockito.Mockito.times;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class AiTaskServiceImplTest {
|
class AiTaskServiceImplTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void appendPathShouldTrimWhitespaceAndNormalizeSlashBoundaries() {
|
void buildAsrRequestShouldFollowCurrentOfflineAsrContract() {
|
||||||
AiTaskServiceImpl service = createService();
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
|
HotWord hotWord = new HotWord();
|
||||||
|
hotWord.setWord("汇智");
|
||||||
|
hotWord.setWeight(25);
|
||||||
|
when(hotWordService.list(any())).thenReturn(List.of(hotWord));
|
||||||
|
|
||||||
String url = ReflectionTestUtils.invokeMethod(
|
AiTaskServiceImpl service = new AiTaskServiceImpl(
|
||||||
service,
|
|
||||||
"appendPath",
|
|
||||||
" http://10.100.52.43:1234/ ",
|
|
||||||
" /v1/chat/completions "
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals("http://10.100.52.43:1234/v1/chat/completions", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void appendPathShouldReturnAbsolutePathWhenApiPathIsFullUrl() {
|
|
||||||
AiTaskServiceImpl service = createService();
|
|
||||||
|
|
||||||
String url = ReflectionTestUtils.invokeMethod(
|
|
||||||
service,
|
|
||||||
"appendPath",
|
|
||||||
" http://10.100.52.43:1234/ ",
|
|
||||||
" https://example.com/custom-endpoint "
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals("https://example.com/custom-endpoint", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildUriShouldTrimWhitespaceBeforeUriCreate() {
|
|
||||||
AiTaskServiceImpl service = createService();
|
|
||||||
|
|
||||||
URI uri = ReflectionTestUtils.invokeMethod(
|
|
||||||
service,
|
|
||||||
"buildUri",
|
|
||||||
" http://10.100.52.43:1234/v1/chat/completions "
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals("http://10.100.52.43:1234/v1/chat/completions", uri.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void dispatchTasksShouldFailSummaryTaskWhenTranscriptContentIsBlank() {
|
|
||||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
|
||||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
|
||||||
AiModelService aiModelService = mock(AiModelService.class);
|
|
||||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
|
||||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
|
||||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
|
|
||||||
|
|
||||||
AiTaskServiceImpl service = spy(createService(
|
|
||||||
meetingMapper,
|
|
||||||
transcriptMapper,
|
|
||||||
aiModelService,
|
|
||||||
redisTemplate,
|
|
||||||
new TaskSecurityContextRunner(),
|
|
||||||
mock(MeetingTranscriptFileService.class),
|
|
||||||
mock(MeetingTranscriptRevisionService.class),
|
|
||||||
mock(MeetingTranscriptChapterService.class)
|
|
||||||
));
|
|
||||||
doReturn(true).when(service).updateById(any());
|
|
||||||
|
|
||||||
Meeting meeting = new Meeting();
|
|
||||||
meeting.setId(66L);
|
|
||||||
meeting.setAudioUrl("/audio/demo.wav");
|
|
||||||
when(meetingMapper.selectById(66L)).thenReturn(meeting);
|
|
||||||
|
|
||||||
MeetingTranscript transcript = new MeetingTranscript();
|
|
||||||
transcript.setSpeakerName("Alice");
|
|
||||||
transcript.setContent(" ");
|
|
||||||
when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript));
|
|
||||||
|
|
||||||
AiTask summaryTask = new AiTask();
|
|
||||||
summaryTask.setId(99L);
|
|
||||||
summaryTask.setMeetingId(66L);
|
|
||||||
summaryTask.setTaskType("SUMMARY");
|
|
||||||
summaryTask.setStatus(0);
|
|
||||||
doReturn(null, null, summaryTask).when(service).getOne(any());
|
|
||||||
|
|
||||||
service.dispatchTasks(66L, 1L, 2L);
|
|
||||||
|
|
||||||
assertEquals(3, summaryTask.getStatus());
|
|
||||||
assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank());
|
|
||||||
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void dispatchSummaryTaskShouldFailWhenTranscriptContentIsBlank() {
|
|
||||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
|
||||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
|
||||||
AiModelService aiModelService = mock(AiModelService.class);
|
|
||||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
|
||||||
MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.class);
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
|
||||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
|
||||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
|
|
||||||
when(chapterService.resolveSummarySource(any(), any())).thenReturn(MeetingSummarySource.builder()
|
|
||||||
.text(" ")
|
|
||||||
.sourceType("RAW_FALLBACK")
|
|
||||||
.fallbackUsed(true)
|
|
||||||
.algorithmVersion("cohesion-v1")
|
|
||||||
.build());
|
|
||||||
|
|
||||||
AiTaskServiceImpl service = spy(createService(
|
|
||||||
meetingMapper,
|
|
||||||
transcriptMapper,
|
|
||||||
aiModelService,
|
|
||||||
redisTemplate,
|
|
||||||
new TaskSecurityContextRunner(),
|
|
||||||
mock(MeetingTranscriptFileService.class),
|
|
||||||
mock(MeetingTranscriptRevisionService.class),
|
|
||||||
chapterService
|
|
||||||
));
|
|
||||||
doReturn(true).when(service).updateById(any());
|
|
||||||
|
|
||||||
Meeting meeting = new Meeting();
|
|
||||||
meeting.setId(77L);
|
|
||||||
when(meetingMapper.selectById(77L)).thenReturn(meeting);
|
|
||||||
|
|
||||||
AiTask chapterTask = new AiTask();
|
|
||||||
chapterTask.setId(88L);
|
|
||||||
chapterTask.setMeetingId(77L);
|
|
||||||
chapterTask.setTaskType("CHAPTER");
|
|
||||||
chapterTask.setStatus(0);
|
|
||||||
AiTask summaryTask = new AiTask();
|
|
||||||
summaryTask.setId(100L);
|
|
||||||
summaryTask.setMeetingId(77L);
|
|
||||||
summaryTask.setTaskType("SUMMARY");
|
|
||||||
summaryTask.setStatus(0);
|
|
||||||
doReturn(chapterTask, summaryTask).when(service).getOne(any());
|
|
||||||
|
|
||||||
service.dispatchSummaryTask(77L, 1L, 2L);
|
|
||||||
|
|
||||||
assertEquals(3, summaryTask.getStatus());
|
|
||||||
assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank());
|
|
||||||
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void dispatchSummaryTaskShouldWaitForExternalOrchestrationWhenExternalModeEnabled() {
|
|
||||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
|
||||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
|
||||||
AiModelService aiModelService = mock(AiModelService.class);
|
|
||||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
|
||||||
MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.class);
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
|
||||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
|
||||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
|
|
||||||
|
|
||||||
AiTaskServiceImpl service = spy(createService(
|
|
||||||
meetingMapper,
|
|
||||||
transcriptMapper,
|
|
||||||
aiModelService,
|
|
||||||
redisTemplate,
|
|
||||||
new TaskSecurityContextRunner(),
|
|
||||||
mock(MeetingTranscriptFileService.class),
|
|
||||||
mock(MeetingTranscriptRevisionService.class),
|
|
||||||
chapterService
|
|
||||||
));
|
|
||||||
ReflectionTestUtils.setField(service, "summaryOrchestrationMode", "EXTERNAL_N8N");
|
|
||||||
|
|
||||||
Meeting meeting = new Meeting();
|
|
||||||
meeting.setId(78L);
|
|
||||||
when(meetingMapper.selectById(78L)).thenReturn(meeting);
|
|
||||||
|
|
||||||
AiTask summaryTask = new AiTask();
|
|
||||||
summaryTask.setId(101L);
|
|
||||||
summaryTask.setMeetingId(78L);
|
|
||||||
summaryTask.setTaskType("SUMMARY");
|
|
||||||
summaryTask.setStatus(0);
|
|
||||||
doReturn(summaryTask, null).when(service).getOne(any());
|
|
||||||
|
|
||||||
service.dispatchSummaryTask(78L, 1L, 2L);
|
|
||||||
|
|
||||||
verify(chapterService, never()).resolveSummarySource(any(), any());
|
|
||||||
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void saveTranscriptsShouldInitializeTranscriptFileAfterFirstPersist() throws Exception {
|
|
||||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
|
||||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
|
||||||
MeetingTranscriptFileService transcriptFileService = mock(MeetingTranscriptFileService.class);
|
|
||||||
AiTaskServiceImpl service = createService(
|
|
||||||
meetingMapper,
|
|
||||||
transcriptMapper,
|
|
||||||
mock(AiModelService.class),
|
|
||||||
mock(StringRedisTemplate.class),
|
|
||||||
mock(TaskSecurityContextRunner.class),
|
|
||||||
transcriptFileService,
|
|
||||||
mock(MeetingTranscriptRevisionService.class),
|
|
||||||
mock(MeetingTranscriptChapterService.class)
|
|
||||||
);
|
|
||||||
|
|
||||||
Meeting meeting = new Meeting();
|
|
||||||
meeting.setId(88L);
|
|
||||||
|
|
||||||
ReflectionTestUtils.invokeMethod(
|
|
||||||
service,
|
|
||||||
"saveTranscripts",
|
|
||||||
meeting,
|
|
||||||
new ObjectMapper().readTree("""
|
|
||||||
{
|
|
||||||
"segments": [
|
|
||||||
{
|
|
||||||
"speaker_id": "123",
|
|
||||||
"speaker_name": "Alice",
|
|
||||||
"text": "hello world",
|
|
||||||
"timestamp": [0, 1200]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
);
|
|
||||||
|
|
||||||
verify(transcriptMapper, times(1)).insert(any(MeetingTranscript.class));
|
|
||||||
verify(transcriptFileService, times(1)).initializeTranscriptFileIfAbsent(eq(88L));
|
|
||||||
}
|
|
||||||
|
|
||||||
private AiTaskServiceImpl createService() {
|
|
||||||
return createService(
|
|
||||||
mock(MeetingMapper.class),
|
mock(MeetingMapper.class),
|
||||||
mock(MeetingTranscriptMapper.class),
|
mock(MeetingTranscriptMapper.class),
|
||||||
mock(AiModelService.class),
|
mock(AiModelService.class),
|
||||||
mock(StringRedisTemplate.class),
|
new ObjectMapper(),
|
||||||
mock(TaskSecurityContextRunner.class),
|
mock(SysUserMapper.class),
|
||||||
|
hotWordService,
|
||||||
|
mock(RedisValueSupport.class),
|
||||||
|
mock(MeetingProgressService.class),
|
||||||
|
mock(MeetingSummaryFileService.class),
|
||||||
mock(MeetingTranscriptFileService.class),
|
mock(MeetingTranscriptFileService.class),
|
||||||
mock(MeetingTranscriptRevisionService.class),
|
mock(MeetingTranscriptChapterService.class),
|
||||||
mock(MeetingTranscriptChapterService.class)
|
mock(MeetingSummaryPromptAssembler.class),
|
||||||
|
mock(TaskSecurityContextRunner.class),
|
||||||
|
mock(MeetingExternalSummaryWebhookTrigger.class),
|
||||||
|
mock(SysParamService.class)
|
||||||
);
|
);
|
||||||
|
ReflectionTestUtils.setField(service, "serverBaseUrl", "http://localhost:8080");
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setAudioUrl("/api/static/meetings/12/source audio.mp4");
|
||||||
|
|
||||||
|
AiTask task = new AiTask();
|
||||||
|
Map<String, Object> taskConfig = new HashMap<>();
|
||||||
|
taskConfig.put("useSpkId", 1);
|
||||||
|
taskConfig.put("enableTextRefine", true);
|
||||||
|
taskConfig.put("hotWords", List.of("汇智"));
|
||||||
|
task.setTaskConfig(taskConfig);
|
||||||
|
|
||||||
|
AiModelVO asrModel = new AiModelVO();
|
||||||
|
asrModel.setModelCode("legacy-model-code");
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> request = (Map<String, Object>) ReflectionTestUtils.invokeMethod(
|
||||||
|
service,
|
||||||
|
"buildAsrRequest",
|
||||||
|
meeting,
|
||||||
|
task,
|
||||||
|
asrModel
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("http://localhost:8080/api/static/meetings/12/source%20audio.mp4", request.get("audio_address"));
|
||||||
|
assertFalse(request.containsKey("file_url"));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> config = (Map<String, Object>) request.get("config");
|
||||||
|
assertEquals(Boolean.TRUE, config.get("enable_speaker"));
|
||||||
|
assertEquals(Boolean.TRUE, config.get("match_speaker_registry"));
|
||||||
|
assertEquals(Boolean.TRUE, config.get("enable_text_cleanup"));
|
||||||
|
assertFalse(config.containsKey("enable_text_refine"));
|
||||||
|
assertFalse(config.containsKey("enable_two_pass"));
|
||||||
|
assertFalse(config.containsKey("model"));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> hotwords = (List<Map<String, Object>>) config.get("hotwords");
|
||||||
|
assertEquals(1, hotwords.size());
|
||||||
|
assertEquals("汇智", hotwords.get(0).get("hotword"));
|
||||||
|
assertEquals(2.5, hotwords.get(0).get("weight"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private AiTaskServiceImpl createService(MeetingMapper meetingMapper,
|
@Test
|
||||||
MeetingTranscriptMapper transcriptMapper,
|
void buildAsrRequestShouldDisableRegistryMatchWhenSpeakerSplitDisabled() {
|
||||||
AiModelService aiModelService,
|
AiTaskServiceImpl service = new AiTaskServiceImpl(
|
||||||
StringRedisTemplate redisTemplate,
|
mock(MeetingMapper.class),
|
||||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
mock(MeetingTranscriptMapper.class),
|
||||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
mock(AiModelService.class),
|
||||||
MeetingTranscriptRevisionService revisionService,
|
|
||||||
MeetingTranscriptChapterService chapterService) {
|
|
||||||
return new AiTaskServiceImpl(
|
|
||||||
meetingMapper,
|
|
||||||
transcriptMapper,
|
|
||||||
aiModelService,
|
|
||||||
new ObjectMapper(),
|
new ObjectMapper(),
|
||||||
mock(SysUserMapper.class),
|
mock(SysUserMapper.class),
|
||||||
mock(HotWordService.class),
|
mock(HotWordService.class),
|
||||||
redisTemplate,
|
mock(RedisValueSupport.class),
|
||||||
|
mock(MeetingProgressService.class),
|
||||||
mock(MeetingSummaryFileService.class),
|
mock(MeetingSummaryFileService.class),
|
||||||
meetingTranscriptFileService,
|
mock(MeetingTranscriptFileService.class),
|
||||||
revisionService,
|
mock(MeetingTranscriptChapterService.class),
|
||||||
chapterService,
|
|
||||||
mock(MeetingSummaryPromptAssembler.class),
|
mock(MeetingSummaryPromptAssembler.class),
|
||||||
taskSecurityContextRunner,
|
mock(TaskSecurityContextRunner.class),
|
||||||
mock(MeetingExternalSummaryWebhookTrigger.class)
|
mock(MeetingExternalSummaryWebhookTrigger.class),
|
||||||
|
mock(SysParamService.class)
|
||||||
);
|
);
|
||||||
|
ReflectionTestUtils.setField(service, "serverBaseUrl", "http://localhost:8080");
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setAudioUrl("/api/static/audio/demo.wav");
|
||||||
|
|
||||||
|
AiTask task = new AiTask();
|
||||||
|
Map<String, Object> taskConfig = new HashMap<>();
|
||||||
|
taskConfig.put("useSpkId", 0);
|
||||||
|
taskConfig.put("enableTextRefine", false);
|
||||||
|
task.setTaskConfig(taskConfig);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> request = (Map<String, Object>) ReflectionTestUtils.invokeMethod(
|
||||||
|
service,
|
||||||
|
"buildAsrRequest",
|
||||||
|
meeting,
|
||||||
|
task,
|
||||||
|
new AiModelVO()
|
||||||
|
);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> config = (Map<String, Object>) request.get("config");
|
||||||
|
assertEquals(Boolean.FALSE, config.get("enable_speaker"));
|
||||||
|
assertEquals(Boolean.FALSE, config.get("match_speaker_registry"));
|
||||||
|
assertEquals(Boolean.FALSE, config.get("enable_text_cleanup"));
|
||||||
|
assertTrue(((List<?>) config.get("hotwords")).isEmpty());
|
||||||
|
assertNull(request.get("file_url"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ export const getMeetingPage = (params: {
|
||||||
size: number;
|
size: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
viewType?: "all" | "created" | "involved";
|
viewType?: "all" | "created" | "involved";
|
||||||
|
status?: number;
|
||||||
}) => {
|
}) => {
|
||||||
return http.get<{ code: string; data: { records: MeetingVO[]; total: number }; msg: string }>(
|
return http.get<{ code: string; data: { records: MeetingVO[]; total: number }; msg: string }>(
|
||||||
"/api/biz/meeting/page",
|
"/api/biz/meeting/page",
|
||||||
|
|
@ -426,6 +427,7 @@ export interface MeetingProgress {
|
||||||
message: string;
|
message: string;
|
||||||
updateAt: number;
|
updateAt: number;
|
||||||
eta?: number;
|
eta?: number;
|
||||||
|
queueAheadCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => {
|
export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => {
|
||||||
|
|
@ -437,6 +439,16 @@ export const getMeetingProgress = (id: number, options?: { suppressErrorToast?:
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMeetingProgressBatch = (ids: number[], options?: { suppressErrorToast?: boolean }) => {
|
||||||
|
return http.post<{ code: string; data: Record<number, MeetingProgress>; msg: string }>(
|
||||||
|
"/api/biz/meeting/progress/batch",
|
||||||
|
ids,
|
||||||
|
{
|
||||||
|
suppressErrorToast: options?.suppressErrorToast,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => {
|
export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => {
|
||||||
const token = localStorage.getItem("accessToken");
|
const token = localStorage.getItem("accessToken");
|
||||||
return axios.get(`/api/biz/meeting/${id}/summary/export`, {
|
return axios.get(`/api/biz/meeting/${id}/summary/export`, {
|
||||||
|
|
|
||||||
|
|
@ -359,67 +359,101 @@ function parseChapterTimeToMs(value?: string) {
|
||||||
return totalSeconds * 1000;
|
return totalSeconds * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MeetingProgressPhase = 'queued' | 'asr' | 'chapter' | 'summary' | 'terminal';
|
||||||
|
|
||||||
|
const meetingProgressTerminalRefreshCache = new Map<number, string>();
|
||||||
|
const meetingProgressPhaseRefreshCache = new Map<number, MeetingProgressPhase>();
|
||||||
|
|
||||||
|
const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => {
|
||||||
|
const percent = progress?.percent ?? 0;
|
||||||
|
if (percent < 0 || percent >= 100) {
|
||||||
|
return 'terminal';
|
||||||
|
}
|
||||||
|
if (percent >= 90) {
|
||||||
|
return 'summary';
|
||||||
|
}
|
||||||
|
if (percent >= 85) {
|
||||||
|
return 'chapter';
|
||||||
|
}
|
||||||
|
if (percent >= 5) {
|
||||||
|
return 'asr';
|
||||||
|
}
|
||||||
|
return 'queued';
|
||||||
|
};
|
||||||
|
|
||||||
const MeetingProgressDisplay: React.FC<{
|
const MeetingProgressDisplay: React.FC<{
|
||||||
meetingId: number;
|
meetingId: number;
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
onProgressUpdate?: (meeting: MeetingVO) => void;
|
onRefreshNeeded?: (phase: MeetingProgressPhase) => void;
|
||||||
onProgressChange?: (progress: MeetingProgress | null) => void;
|
onProgressChange?: (progress: MeetingProgress | null) => void;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
}> = ({ meetingId, onComplete, onProgressUpdate, onProgressChange, compact, inline }) => {
|
}> = ({ meetingId, onComplete, onRefreshNeeded, onProgressChange, compact, inline }) => {
|
||||||
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
||||||
|
|
||||||
const onCompleteRef = useRef(onComplete);
|
const onCompleteRef = useRef(onComplete);
|
||||||
const onProgressUpdateRef = useRef(onProgressUpdate);
|
const onRefreshNeededRef = useRef(onRefreshNeeded);
|
||||||
const onProgressChangeRef = useRef(onProgressChange);
|
const onProgressChangeRef = useRef(onProgressChange);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCompleteRef.current = onComplete;
|
onCompleteRef.current = onComplete;
|
||||||
onProgressUpdateRef.current = onProgressUpdate;
|
onRefreshNeededRef.current = onRefreshNeeded;
|
||||||
onProgressChangeRef.current = onProgressChange;
|
onProgressChangeRef.current = onProgressChange;
|
||||||
}, [onComplete, onProgressUpdate, onProgressChange]);
|
}, [onComplete, onRefreshNeeded, onProgressChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let completed = false;
|
let completed = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let requesting = false;
|
||||||
|
|
||||||
const fetchProgress = async () => {
|
const fetchProgress = async () => {
|
||||||
if (completed) {
|
if (completed || requesting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
requesting = true;
|
||||||
|
let shouldContinue = true;
|
||||||
try {
|
try {
|
||||||
const [progressRes, detailRes] = await Promise.all([
|
const progressRes = await getMeetingProgress(meetingId, { suppressErrorToast: true });
|
||||||
getMeetingProgress(meetingId, { suppressErrorToast: true }),
|
|
||||||
getMeetingDetail(meetingId, { suppressErrorToast: true }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (detailRes.data?.data) {
|
|
||||||
onProgressUpdateRef.current?.(detailRes.data.data);
|
|
||||||
if (detailRes.data.data.status !== 1 && detailRes.data.data.status !== 2) {
|
|
||||||
completed = true;
|
|
||||||
onCompleteRef.current?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressRes.data?.data) {
|
if (progressRes.data?.data) {
|
||||||
const nextProgress = progressRes.data.data;
|
const nextProgress = progressRes.data.data;
|
||||||
setProgress(nextProgress);
|
setProgress(nextProgress);
|
||||||
onProgressChangeRef.current?.(nextProgress);
|
onProgressChangeRef.current?.(nextProgress);
|
||||||
|
const phase = resolveProgressPhase(nextProgress);
|
||||||
|
const previousPhase = meetingProgressPhaseRefreshCache.get(meetingId);
|
||||||
|
meetingProgressPhaseRefreshCache.set(meetingId, phase);
|
||||||
|
if ((phase === 'chapter' || phase === 'summary') && previousPhase !== phase) {
|
||||||
|
onRefreshNeededRef.current?.(phase);
|
||||||
|
}
|
||||||
if (nextProgress.percent === 100 || nextProgress.percent < 0) {
|
if (nextProgress.percent === 100 || nextProgress.percent < 0) {
|
||||||
completed = true;
|
completed = true;
|
||||||
onCompleteRef.current?.();
|
shouldContinue = false;
|
||||||
|
const terminalKey = `${nextProgress.updateAt}:${nextProgress.percent}`;
|
||||||
|
if (meetingProgressTerminalRefreshCache.get(meetingId) !== terminalKey) {
|
||||||
|
meetingProgressTerminalRefreshCache.set(meetingId, terminalKey);
|
||||||
|
onCompleteRef.current?.();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
meetingProgressTerminalRefreshCache.delete(meetingId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
} finally {
|
||||||
|
requesting = false;
|
||||||
|
if (!completed && shouldContinue) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void fetchProgress();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchProgress();
|
void fetchProgress();
|
||||||
const timer = setInterval(fetchProgress, 3000);
|
|
||||||
return () => {
|
return () => {
|
||||||
completed = true;
|
completed = true;
|
||||||
clearInterval(timer);
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [meetingId]);
|
}, [meetingId]);
|
||||||
|
|
||||||
|
|
@ -1325,9 +1359,22 @@ const MeetingDetail: React.FC = () => {
|
||||||
userPrompt: values.userPrompt,
|
userPrompt: values.userPrompt,
|
||||||
summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel,
|
summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel,
|
||||||
});
|
});
|
||||||
message.success('已重新发起总结任务');
|
setMeeting((current) => current ? {
|
||||||
setSummaryVisible(false);
|
...current,
|
||||||
fetchData(Number(id));
|
status: 2,
|
||||||
|
latestChapterAttemptStatus: 0,
|
||||||
|
latestChapterAttemptErrorMsg: undefined,
|
||||||
|
latestSummaryAttemptStatus: 0,
|
||||||
|
latestSummaryAttemptErrorMsg: undefined,
|
||||||
|
} : current);
|
||||||
|
setGenerationProgress({
|
||||||
|
percent: 85,
|
||||||
|
message: '已重新发起总结任务',
|
||||||
|
updateAt: Date.now(),
|
||||||
|
eta: 0,
|
||||||
|
});
|
||||||
|
message.success('操作成功');
|
||||||
|
await fetchData(Number(id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -2000,15 +2047,11 @@ const MeetingDetail: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="meeting-detail-workspace">
|
<div className="meeting-detail-workspace">
|
||||||
{meeting.status === 1 ? (
|
{meeting.status === 0 || meeting.status === 1 ? (
|
||||||
<MeetingProgressDisplay
|
<MeetingProgressDisplay
|
||||||
meetingId={meeting.id}
|
meetingId={meeting.id}
|
||||||
onComplete={() => fetchData(meeting.id)}
|
onComplete={() => fetchData(meeting.id)}
|
||||||
onProgressUpdate={(updated) => {
|
onRefreshNeeded={() => { void fetchData(meeting.id); }}
|
||||||
if (updated.status !== meeting.status) {
|
|
||||||
void fetchData(updated.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onProgressChange={setGenerationProgress}
|
onProgressChange={setGenerationProgress}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -2051,11 +2094,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
<MeetingProgressDisplay
|
<MeetingProgressDisplay
|
||||||
meetingId={meeting.id}
|
meetingId={meeting.id}
|
||||||
onComplete={() => fetchData(meeting.id)}
|
onComplete={() => fetchData(meeting.id)}
|
||||||
onProgressUpdate={(updated) => {
|
onRefreshNeeded={() => { void fetchData(meeting.id); }}
|
||||||
if (updated.status === 2 || updated.status !== meeting.status) {
|
|
||||||
void fetchData(updated.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onProgressChange={setGenerationProgress}
|
onProgressChange={setGenerationProgress}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
|
|
@ -2075,11 +2114,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
<MeetingProgressDisplay
|
<MeetingProgressDisplay
|
||||||
meetingId={meeting.id}
|
meetingId={meeting.id}
|
||||||
onComplete={() => fetchData(meeting.id)}
|
onComplete={() => fetchData(meeting.id)}
|
||||||
onProgressUpdate={(updated) => {
|
onRefreshNeeded={() => { void fetchData(meeting.id); }}
|
||||||
if (updated.status !== meeting.status) {
|
|
||||||
void fetchData(updated.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onProgressChange={setGenerationProgress}
|
onProgressChange={setGenerationProgress}
|
||||||
inline
|
inline
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
CloudUploadOutlined,
|
CloudUploadOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
FilterOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
PauseCircleOutlined,
|
PauseCircleOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
|
@ -38,7 +39,7 @@ import {
|
||||||
Typography,
|
Typography,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
|
@ -47,7 +48,7 @@ import {
|
||||||
deleteMeeting,
|
deleteMeeting,
|
||||||
getMeetingCreateConfig,
|
getMeetingCreateConfig,
|
||||||
getMeetingPage,
|
getMeetingPage,
|
||||||
getMeetingProgress,
|
getMeetingProgressBatch,
|
||||||
getRealtimeMeetingSessionStatus,
|
getRealtimeMeetingSessionStatus,
|
||||||
getRealtimeMeetingSessionStatuses,
|
getRealtimeMeetingSessionStatuses,
|
||||||
type MeetingCreateConfig,
|
type MeetingCreateConfig,
|
||||||
|
|
@ -64,11 +65,21 @@ import PageContainer from "../../components/shared/PageContainer";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
const { Search } = Input;
|
||||||
|
|
||||||
const CURRENT_PLATFORM = "WEB" as const;
|
const CURRENT_PLATFORM = "WEB" as const;
|
||||||
const PAUSED_DISPLAY_STATUS = 5;
|
const PAUSED_DISPLAY_STATUS = 5;
|
||||||
const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
|
const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
|
||||||
const REALTIME_IDLE_DISPLAY_STATUS = 7;
|
const REALTIME_IDLE_DISPLAY_STATUS = 7;
|
||||||
|
const ALL_STATUS_FILTER = "all";
|
||||||
|
const MEETING_STATUS_FILTER_OPTIONS = [
|
||||||
|
{ label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" },
|
||||||
|
{ label: "排队中", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" },
|
||||||
|
{ label: "识别中", value: "1", color: "#1890ff", bgColor: "#e6f7ff" },
|
||||||
|
{ label: "总结中", value: "2", color: "#faad14", bgColor: "#fff7e6" },
|
||||||
|
{ label: "已完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" },
|
||||||
|
{ label: "失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" },
|
||||||
|
] as const;
|
||||||
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
|
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
|
||||||
offlineEnabled: true,
|
offlineEnabled: true,
|
||||||
realtimeEnabled: true,
|
realtimeEnabled: true,
|
||||||
|
|
@ -95,9 +106,17 @@ const canOpenRealtimeSession = (status?: RealtimeMeetingSessionStatus["status"])
|
||||||
|| status === "ACTIVE"
|
|| status === "ACTIVE"
|
||||||
|| status === "IDLE";
|
|| status === "IDLE";
|
||||||
|
|
||||||
|
const hasLatestGenerationFailure = (item: MeetingVO) =>
|
||||||
|
item.latestChapterAttemptStatus === 3 || item.latestSummaryAttemptStatus === 3;
|
||||||
|
|
||||||
|
const shouldTrackGenerationProgress = (item: MeetingVO) =>
|
||||||
|
!hasLatestGenerationFailure(item) && (item.status === 0 || item.status === 1 || item.status === 2);
|
||||||
|
|
||||||
|
const isTerminalMeetingProgress = (progress?: MeetingProgress | null) =>
|
||||||
|
!!progress && (progress.percent === 100 || progress.percent < 0);
|
||||||
|
|
||||||
const shouldPollMeetingCard = (item: MeetingVO) =>
|
const shouldPollMeetingCard = (item: MeetingVO) =>
|
||||||
item.status === 1
|
shouldTrackGenerationProgress(item)
|
||||||
|| item.status === 2
|
|
||||||
|| item.realtimeSessionStatus === "ACTIVE"
|
|| item.realtimeSessionStatus === "ACTIVE"
|
||||||
|| isPausedRealtimeSessionStatus(item.realtimeSessionStatus);
|
|| isPausedRealtimeSessionStatus(item.realtimeSessionStatus);
|
||||||
|
|
||||||
|
|
@ -130,35 +149,9 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee
|
||||||
return { ...item, realtimeSessionStatus: sessionStatus.status };
|
return { ...item, realtimeSessionStatus: sessionStatus.status };
|
||||||
};
|
};
|
||||||
|
|
||||||
const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
|
|
||||||
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (meeting.status !== 1 && meeting.status !== 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fetchProgress = async () => {
|
|
||||||
try {
|
|
||||||
const res = await getMeetingProgress(meeting.id, { suppressErrorToast: true });
|
|
||||||
const nextProgress = res.data?.data;
|
|
||||||
if (nextProgress) {
|
|
||||||
setProgress(nextProgress);
|
|
||||||
if ((nextProgress.percent === 100 || nextProgress.percent < 0) && onComplete) {
|
|
||||||
onComplete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
void fetchProgress();
|
|
||||||
const timer = setInterval(fetchProgress, 3000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [meeting.id, meeting.status, onComplete]);
|
|
||||||
|
|
||||||
return progress;
|
|
||||||
};
|
|
||||||
|
|
||||||
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
|
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
|
||||||
const effectiveStatus = meeting.displayStatus ?? meeting.status;
|
const failedByLatestAttempt = hasLatestGenerationFailure(meeting);
|
||||||
|
const effectiveStatus = failedByLatestAttempt ? 4 : (meeting.displayStatus ?? meeting.status);
|
||||||
const statusConfig: Record<number, { text: string; color: string; bgColor: string; icon: React.ReactNode }> = {
|
const statusConfig: Record<number, { text: string; color: string; bgColor: string; icon: React.ReactNode }> = {
|
||||||
0: { text: "排队中", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> },
|
0: { text: "排队中", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> },
|
||||||
1: { text: "识别中", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: <SyncOutlined spin /> },
|
1: { text: "识别中", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: <SyncOutlined spin /> },
|
||||||
|
|
@ -170,8 +163,8 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
|
||||||
7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: <InfoCircleOutlined /> },
|
7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: <InfoCircleOutlined /> },
|
||||||
};
|
};
|
||||||
const config = statusConfig[effectiveStatus] || statusConfig[0];
|
const config = statusConfig[effectiveStatus] || statusConfig[0];
|
||||||
const percent = meeting.status === 1 || meeting.status === 2 ? progress?.percent || 0 : 0;
|
const isProcessing = shouldTrackGenerationProgress(meeting);
|
||||||
const isProcessing = meeting.status === 1 || meeting.status === 2;
|
const percent = isProcessing ? progress?.percent || 0 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -213,20 +206,19 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TableStatusCell: React.FC<{ meeting: MeetingVO; fetchData: () => void }> = ({ meeting, fetchData }) => {
|
const TableStatusCell: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
|
||||||
const progress = useMeetingProgress(meeting, fetchData);
|
|
||||||
return <IntegratedStatusTag meeting={meeting} progress={progress} />;
|
return <IntegratedStatusTag meeting={meeting} progress={progress} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MeetingCardItem: React.FC<{
|
const MeetingCardItem: React.FC<{
|
||||||
item: MeetingVO;
|
item: MeetingVO;
|
||||||
config: { text: string; color: string; bgColor: string };
|
config: { text: string; color: string; bgColor: string };
|
||||||
|
progress: MeetingProgress | null;
|
||||||
fetchData: () => void;
|
fetchData: () => void;
|
||||||
onOpenMeeting: (meeting: MeetingVO) => void;
|
onOpenMeeting: (meeting: MeetingVO) => void;
|
||||||
}> = ({ item, config, fetchData, onOpenMeeting }) => {
|
}> = ({ item, config, progress, fetchData, onOpenMeeting }) => {
|
||||||
const progress = useMeetingProgress(item, fetchData);
|
const effectiveStatus = hasLatestGenerationFailure(item) ? 4 : (item.displayStatus ?? item.status);
|
||||||
const effectiveStatus = item.displayStatus ?? item.status;
|
const isProcessing = shouldTrackGenerationProgress(item);
|
||||||
const isProcessing = item.status === 1 || item.status === 2;
|
|
||||||
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
|
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
|
||||||
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
|
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
|
||||||
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
|
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
|
||||||
|
|
@ -403,12 +395,15 @@ const Meetings: React.FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<MeetingVO[]>([]);
|
const [data, setData] = useState<MeetingVO[]>([]);
|
||||||
|
const [progressMap, setProgressMap] = useState<Record<number, MeetingProgress>>({});
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [current, setCurrent] = useState(1);
|
const [current, setCurrent] = useState(1);
|
||||||
const [displayMode, setDisplayMode] = useState<"card" | "list">("card");
|
const [displayMode, setDisplayMode] = useState<"card" | "list">("card");
|
||||||
const [size, setSize] = useState(8);
|
const [size, setSize] = useState(8);
|
||||||
const [searchTitle, setSearchTitle] = useState("");
|
const [searchTitle, setSearchTitle] = useState("");
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
const [viewType, setViewType] = useState<"all" | "created" | "involved">("all");
|
const [viewType, setViewType] = useState<"all" | "created" | "involved">("all");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>(ALL_STATUS_FILTER);
|
||||||
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
|
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
|
||||||
const [createDrawerType, setCreateDrawerType] = useState<MeetingCreateType>("upload");
|
const [createDrawerType, setCreateDrawerType] = useState<MeetingCreateType>("upload");
|
||||||
const [configLoaded, setConfigLoaded] = useState(false);
|
const [configLoaded, setConfigLoaded] = useState(false);
|
||||||
|
|
@ -418,8 +413,9 @@ const Meetings: React.FC = () => {
|
||||||
offlineAudioMaxSizeMb: 1024,
|
offlineAudioMaxSizeMb: 1024,
|
||||||
});
|
});
|
||||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||||
|
const progressTerminalRefreshRef = useRef<Map<number, string>>(new Map());
|
||||||
|
|
||||||
const hasRunningTasks = data.some(shouldPollMeetingCard);
|
const activeFilterCount = (statusFilter !== ALL_STATUS_FILTER ? 1 : 0) + (searchTitle ? 1 : 0);
|
||||||
|
|
||||||
const handleDisplayModeChange = (mode: "card" | "list") => {
|
const handleDisplayModeChange = (mode: "card" | "list") => {
|
||||||
setDisplayMode(mode);
|
setDisplayMode(mode);
|
||||||
|
|
@ -427,6 +423,44 @@ const Meetings: React.FC = () => {
|
||||||
setCurrent(1);
|
setCurrent(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value?: string) => {
|
||||||
|
const nextValue = (value ?? searchKeyword).trim();
|
||||||
|
setSearchKeyword(value ?? searchKeyword);
|
||||||
|
setSearchTitle(nextValue);
|
||||||
|
setCurrent(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
setSearchKeyword("");
|
||||||
|
setSearchTitle("");
|
||||||
|
setStatusFilter(ALL_STATUS_FILTER);
|
||||||
|
setCurrent(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBatchProgress = async (meetings: MeetingVO[]) => {
|
||||||
|
const trackedIds = meetings.filter(shouldTrackGenerationProgress).map((item) => item.id);
|
||||||
|
if (trackedIds.length === 0) {
|
||||||
|
progressTerminalRefreshRef.current.clear();
|
||||||
|
setProgressMap({});
|
||||||
|
return {} as Record<number, MeetingProgress>;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const progressRes = await getMeetingProgressBatch(trackedIds, { suppressErrorToast: true });
|
||||||
|
const nextProgressMap = progressRes.data?.data || {};
|
||||||
|
const activeIds = new Set(trackedIds);
|
||||||
|
progressTerminalRefreshRef.current.forEach((_, id) => {
|
||||||
|
if (!activeIds.has(id)) {
|
||||||
|
progressTerminalRefreshRef.current.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setProgressMap(nextProgressMap);
|
||||||
|
return nextProgressMap;
|
||||||
|
} catch {
|
||||||
|
setProgressMap({});
|
||||||
|
return {} as Record<number, MeetingProgress>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const action = searchParams.get("action");
|
const action = searchParams.get("action");
|
||||||
const type = searchParams.get("type") as MeetingCreateType;
|
const type = searchParams.get("type") as MeetingCreateType;
|
||||||
|
|
@ -439,15 +473,105 @@ const Meetings: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchData();
|
void fetchData();
|
||||||
}, [current, size, searchTitle, viewType]);
|
}, [current, size, searchTitle, viewType, statusFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasRunningTasks) {
|
const trackedMeetings = data.filter(shouldTrackGenerationProgress);
|
||||||
|
if (trackedMeetings.length === 0) {
|
||||||
|
progressTerminalRefreshRef.current.clear();
|
||||||
|
setProgressMap({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setInterval(() => void fetchData(true), 5000);
|
|
||||||
return () => clearInterval(timer);
|
let cancelled = false;
|
||||||
}, [hasRunningTasks, current, size, searchTitle, viewType]);
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let requesting = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled || requesting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requesting = true;
|
||||||
|
try {
|
||||||
|
const nextProgressMap = await loadBatchProgress(trackedMeetings);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shouldRefresh = false;
|
||||||
|
for (const meeting of trackedMeetings) {
|
||||||
|
const progress = nextProgressMap[meeting.id];
|
||||||
|
if (!isTerminalMeetingProgress(progress)) {
|
||||||
|
progressTerminalRefreshRef.current.delete(meeting.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const terminalKey = `${progress.updateAt}:${progress.percent}`;
|
||||||
|
if (progressTerminalRefreshRef.current.get(meeting.id) !== terminalKey) {
|
||||||
|
progressTerminalRefreshRef.current.set(meeting.id, terminalKey);
|
||||||
|
shouldRefresh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldRefresh) {
|
||||||
|
await fetchData(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
requesting = false;
|
||||||
|
if (!cancelled) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void poll();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void poll();
|
||||||
|
}, 3000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [data, current, size, searchTitle, viewType, statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasRealtimeSessionsToPoll = data.some(
|
||||||
|
(item) => item.realtimeSessionStatus === "ACTIVE" || isPausedRealtimeSessionStatus(item.realtimeSessionStatus),
|
||||||
|
);
|
||||||
|
if (!hasRealtimeSessionsToPoll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let requesting = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled || requesting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requesting = true;
|
||||||
|
try {
|
||||||
|
await fetchData(true);
|
||||||
|
} finally {
|
||||||
|
requesting = false;
|
||||||
|
if (!cancelled) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void poll();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void poll();
|
||||||
|
}, 5000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [data, current, size, searchTitle, viewType, statusFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([]));
|
listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([]));
|
||||||
|
|
@ -467,7 +591,13 @@ const Meetings: React.FC = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await getMeetingPage({ current, size, title: searchTitle, viewType });
|
const res = await getMeetingPage({
|
||||||
|
current,
|
||||||
|
size,
|
||||||
|
title: searchTitle,
|
||||||
|
viewType,
|
||||||
|
status: statusFilter === ALL_STATUS_FILTER ? undefined : Number(statusFilter),
|
||||||
|
});
|
||||||
const records = res.data?.data?.records || [];
|
const records = res.data?.data?.records || [];
|
||||||
let statusMap: Record<number, RealtimeMeetingSessionStatus> = {};
|
let statusMap: Record<number, RealtimeMeetingSessionStatus> = {};
|
||||||
const realtimeCandidates = records.filter(isRealtimeMeetingCandidate);
|
const realtimeCandidates = records.filter(isRealtimeMeetingCandidate);
|
||||||
|
|
@ -477,8 +607,10 @@ const Meetings: React.FC = () => {
|
||||||
statusMap = sessionRes.data?.data || {};
|
statusMap = sessionRes.data?.data || {};
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
setData(records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id])));
|
const nextData = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id]));
|
||||||
|
setData(nextData);
|
||||||
setTotal(res.data?.data?.total || 0);
|
setTotal(res.data?.data?.total || 0);
|
||||||
|
await loadBatchProgress(nextData);
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -525,7 +657,7 @@ const Meetings: React.FC = () => {
|
||||||
title: "状态",
|
title: "状态",
|
||||||
key: "status",
|
key: "status",
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (_: unknown, record: MeetingVO) => <TableStatusCell meeting={record} fetchData={() => void fetchData()} />,
|
render: (_: unknown, record: MeetingVO) => <TableStatusCell meeting={record} progress={progressMap[record.id] || null} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "会议时间",
|
title: "会议时间",
|
||||||
|
|
@ -611,20 +743,70 @@ const Meetings: React.FC = () => {
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
toolbar={
|
toolbar={
|
||||||
<>
|
<Space wrap size={12} style={{ width: "100%", justifyContent: "space-between" }}>
|
||||||
<Radio.Group value={viewType} onChange={(e) => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
|
<Radio.Group value={viewType} onChange={(e) => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
|
||||||
<Radio.Button value="all">全部</Radio.Button>
|
<Radio.Button value="all">全部</Radio.Button>
|
||||||
<Radio.Button value="created">我发起</Radio.Button>
|
<Radio.Button value="created">我发起</Radio.Button>
|
||||||
<Radio.Button value="involved">我参与</Radio.Button>
|
<Radio.Button value="involved">我参与</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
<Input
|
<Space wrap size={10} align="center">
|
||||||
placeholder="搜索标题"
|
<Tag
|
||||||
prefix={<SearchOutlined />}
|
color={activeFilterCount > 0 ? "processing" : "default"}
|
||||||
allowClear
|
style={{ margin: 0, borderRadius: 999, paddingInline: 10, lineHeight: "24px" }}
|
||||||
onPressEnter={(e) => { setSearchTitle((e.target as HTMLInputElement).value); setCurrent(1); }}
|
>
|
||||||
style={{ width: 200 }}
|
<Space size={6}>
|
||||||
/>
|
<FilterOutlined />
|
||||||
</>
|
<span>{activeFilterCount > 0 ? `已筛选 ${activeFilterCount} 项` : "未筛选"}</span>
|
||||||
|
</Space>
|
||||||
|
</Tag>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(value) => { setStatusFilter(value); setCurrent(1); }}
|
||||||
|
style={{ width: 170 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
suffixIcon={<FilterOutlined style={{ color: "#8c8c8c" }} />}
|
||||||
|
options={MEETING_STATUS_FILTER_OPTIONS.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: (
|
||||||
|
<Space size={8}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: option.color,
|
||||||
|
boxShadow: `0 0 0 4px ${option.bgColor}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Search
|
||||||
|
placeholder="搜索会议标题"
|
||||||
|
value={searchKeyword}
|
||||||
|
allowClear
|
||||||
|
enterButton={false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextValue = e.target.value;
|
||||||
|
setSearchKeyword(nextValue);
|
||||||
|
if (!nextValue) {
|
||||||
|
setSearchTitle("");
|
||||||
|
setCurrent(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Button onClick={handleResetFilters}>
|
||||||
|
清空筛选
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -639,8 +821,17 @@ const Meetings: React.FC = () => {
|
||||||
grid={{ gutter: [20, 20], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
grid={{ gutter: [20, 20], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
renderItem={(item) => {
|
renderItem={(item) => {
|
||||||
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
|
const visualStatus = hasLatestGenerationFailure(item) ? 4 : (item.displayStatus ?? item.status);
|
||||||
return <MeetingCardItem item={item} config={config} fetchData={() => void fetchData()} onOpenMeeting={handleOpenMeeting} />;
|
const config = statusConfig[visualStatus] || statusConfig[0];
|
||||||
|
return (
|
||||||
|
<MeetingCardItem
|
||||||
|
item={item}
|
||||||
|
config={config}
|
||||||
|
progress={progressMap[item.id] || null}
|
||||||
|
fetchData={() => void fetchData()}
|
||||||
|
onOpenMeeting={handleOpenMeeting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
|
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
form.setFieldValue('userId', profile.userId);
|
form.setFieldValue('userId', profile.userId);
|
||||||
|
form.setFieldValue('name', profile.displayName);
|
||||||
}, [form, isAdmin, profile?.userId]);
|
}, [form, isAdmin, profile?.userId]);
|
||||||
|
|
||||||
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
|
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue