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 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_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled";
|
||||
public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled";
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ public class AndroidMeetingController {
|
|||
loginUser.getUserId(),
|
||||
AndroidLoginUserSupport.resolveDisplayName(authContext),
|
||||
"all",
|
||||
null,
|
||||
AndroidLoginUserSupport.isAdmin(authContext)
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ public class LegacyMeetingController {
|
|||
loginUser.getUserId(),
|
||||
resolveCreatorName(loginUser),
|
||||
"all",
|
||||
null,
|
||||
isAdmin
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,43 @@ public class MeetingController {
|
|||
return ApiResponse.ok(progress);
|
||||
}
|
||||
|
||||
|
||||
@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")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
|
|
@ -223,7 +260,8 @@ public class MeetingController {
|
|||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "10") Integer size,
|
||||
@RequestParam(required = false) String title,
|
||||
@RequestParam(defaultValue = "all") String viewType) {
|
||||
@RequestParam(defaultValue = "all") String viewType,
|
||||
@RequestParam(required = false) Integer status) {
|
||||
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
|
||||
|
|
@ -236,6 +274,7 @@ public class MeetingController {
|
|||
loginUser.getUserId(),
|
||||
resolveCreatorName(loginUser),
|
||||
viewType,
|
||||
status,
|
||||
isAdmin
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public class MeetingProgressSnapshot {
|
|||
private Integer percent;
|
||||
private String message;
|
||||
private Integer eta;
|
||||
private Integer queueAheadCount;
|
||||
private String externalTaskId;
|
||||
private LocalDateTime queuedAt;
|
||||
private LocalDateTime startedAt;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.imeeting.common.MeetingProgressStage;
|
|||
import com.imeeting.dto.biz.MeetingProgressSnapshot;
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface MeetingProgressService {
|
||||
|
|
@ -11,6 +12,8 @@ public interface MeetingProgressService {
|
|||
|
||||
Map<String, Object> getProgressMap(Long meetingId);
|
||||
|
||||
Map<Long, Map<String, Object>> getProgressMaps(List<Long> meetingIds);
|
||||
|
||||
Integer resolvePercent(Long meetingId);
|
||||
|
||||
void markQueued(Long meetingId, AiTask task, Integer meetingStatus, String message);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import java.util.Map;
|
|||
|
||||
public interface MeetingQueryService {
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,6 @@ import com.imeeting.entity.biz.Meeting;
|
|||
import java.util.List;
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import com.imeeting.service.biz.MeetingProgressService;
|
|||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||
|
||||
import com.imeeting.support.RedisValueSupport;
|
||||
|
||||
import com.unisbase.entity.SysUser;
|
||||
|
|
@ -71,7 +71,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
private final MeetingProgressService meetingProgressService;
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
||||
|
||||
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
|
||||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||
|
|
@ -115,7 +115,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
MeetingProgressService meetingProgressService,
|
||||
MeetingSummaryFileService meetingSummaryFileService,
|
||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
|
||||
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
|
||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||
|
|
@ -131,7 +130,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
this.meetingProgressService = meetingProgressService;
|
||||
this.meetingSummaryFileService = meetingSummaryFileService;
|
||||
this.meetingTranscriptFileService = meetingTranscriptFileService;
|
||||
this.meetingTranscriptRevisionService = meetingTranscriptRevisionService;
|
||||
this.meetingTranscriptChapterService = meetingTranscriptChapterService;
|
||||
this.meetingSummaryPromptAssembler = meetingSummaryPromptAssembler;
|
||||
this.taskSecurityContextRunner = taskSecurityContextRunner;
|
||||
|
|
@ -148,7 +146,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
StringRedisTemplate redisTemplate,
|
||||
MeetingSummaryFileService meetingSummaryFileService,
|
||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
|
||||
|
||||
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
|
||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||
|
|
@ -164,7 +162,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
|
||||
meetingSummaryFileService,
|
||||
meetingTranscriptFileService,
|
||||
meetingTranscriptRevisionService,
|
||||
meetingTranscriptChapterService,
|
||||
meetingSummaryPromptAssembler,
|
||||
taskSecurityContextRunner,
|
||||
|
|
@ -213,7 +210,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
this.updateById(asrTask);
|
||||
}
|
||||
if (!claimQueuedAsrTask(asrTask)) {
|
||||
meetingProgressService.markQueued(meetingId, asrTask, 1, "已进入 ASR 队列,等待执行");
|
||||
meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -249,6 +246,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
}
|
||||
if (!asrText.isBlank()) {
|
||||
meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0);
|
||||
scheduleQueuedAsrTasks();
|
||||
self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||
return;
|
||||
}
|
||||
|
|
@ -384,6 +382,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
task.setStatus(1);
|
||||
task.setStartedAt(now);
|
||||
meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0);
|
||||
refreshQueuedAsrProgress();
|
||||
}
|
||||
return claimed;
|
||||
} finally {
|
||||
|
|
@ -409,6 +408,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
if (available <= 0) {
|
||||
return;
|
||||
}
|
||||
refreshQueuedAsrProgress();
|
||||
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getTaskType, "ASR")
|
||||
.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() {
|
||||
if (sysParamService == null) {
|
||||
return 2;
|
||||
|
|
@ -459,6 +473,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
task.setResponseData(responseData);
|
||||
updateById(task);
|
||||
meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason);
|
||||
refreshQueuedAsrProgress();
|
||||
}
|
||||
|
||||
private Long extractAsrModelId(AiTask task) {
|
||||
|
|
@ -565,20 +580,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
}
|
||||
})
|
||||
.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<>();
|
||||
if (asrModel.getModelCode() != null && !asrModel.getModelCode().isBlank()) {
|
||||
config.put("model", asrModel.getModelCode());
|
||||
}
|
||||
|
||||
Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId");
|
||||
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
||||
config.put("enable_speaker", useSpk);
|
||||
config.put("match_speaker_registry", useSpk);
|
||||
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
|
||||
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
|
||||
config.put("enable_text_refine", enableTextRefine);
|
||||
config.put("enable_two_pass", true);
|
||||
config.put("enable_text_cleanup", enableTextRefine);
|
||||
|
||||
List<Map<String, Object>> hotwords = new ArrayList<>();
|
||||
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());
|
||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||
if (submitNode.path("code").asInt() != 0) {
|
||||
updateAiTaskFail(taskRecord, "ASR识别失败: " + respBody);
|
||||
throw new RuntimeException("ASR识别失败: " + submitNode.path("msg").asText());
|
||||
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
|
||||
throw new RuntimeException("ASR识别失败: " + firstNonBlank(
|
||||
submitNode.path("message").asText(""),
|
||||
submitNode.path("msg").asText(""),
|
||||
"unknown error"
|
||||
));
|
||||
}
|
||||
String taskId = submitNode.path("data").path("task_id").asText();
|
||||
taskRecord.setResponseData(Map.of("task_id", taskId));
|
||||
|
|
@ -657,11 +673,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
}
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
protected String saveTranscripts(Meeting meeting, JsonNode resultNode) {
|
||||
// 关键:入库前清理旧记录,防止恢复任务导致数据重复
|
||||
// 闂備胶顭堢换鎴炵箾婵犲洤鏋佹い鎾卞灪閺咁剚鎱ㄥ鍡楀鐎殿喗濞婇獮鏍偓娑櫳戠亸顓烆熆瑜忔慨鎾Υ閹烘宸濇い鏍ㄧ☉閳ь剛鍋ら弻锟犲礃閸曨偅锛嶉柛鐐插閹叉悂鎮ч崼鐔衡敍缂備浇椴哥换鍫濐潖婵傜鐭楀鑸得竟姗€姊虹拠鈥冲箲闁搞劌缍婅棟闁告瑥顦遍々鐑芥偣閸ャ劌绲绘い顐犲€濋幃妤佹媴閸愵煈妫堥梺鎼炰紘閸パ勭€梺缁橆殔閻楀棛绮婇敃鍌涒拺闁圭粯甯炲瓭濡?
|
||||
transcriptMapper.delete(new LambdaQueryWrapper<MeetingTranscript>().eq(MeetingTranscript::getMeetingId, meeting.getId()));
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
JsonNode segments = resultNode.path("segments");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Map<String, String> resolvedUserNameCache = buildResolvedUserNameCache(segments);
|
||||
int savedCount = 0;
|
||||
if (segments.isArray()) {
|
||||
int order = 0;
|
||||
|
|
@ -670,7 +687,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
mt.setMeetingId(meeting.getId());
|
||||
|
||||
String spkId = extractSpeakerId(seg);
|
||||
String spkName = resolveTranscriptSpeakerName(seg, spkId);
|
||||
String spkName = resolveTranscriptSpeakerName(seg, spkId, resolvedUserNameCache);
|
||||
|
||||
mt.setSpeakerId(spkId);
|
||||
mt.setSpeakerName(spkName);
|
||||
|
|
@ -714,7 +731,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
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("");
|
||||
if (speakerName == null || speakerName.isBlank()) {
|
||||
JsonNode speakerNode = seg.path("speaker");
|
||||
|
|
@ -728,13 +745,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
if (userId == null || userId.isBlank()) {
|
||||
userId = seg.path("speaker").path("user_id").asText("");
|
||||
}
|
||||
String resolvedUserName = resolveUserName(userId);
|
||||
String resolvedUserName = resolveUserName(userId, resolvedUserNameCache);
|
||||
if (resolvedUserName != null) {
|
||||
return resolvedUserName;
|
||||
}
|
||||
|
||||
if (speakerId != null && speakerId.matches("\\d+")) {
|
||||
String resolvedSpeakerName = resolveUserName(speakerId);
|
||||
String resolvedSpeakerName = resolveUserName(speakerId, resolvedUserNameCache);
|
||||
if (resolvedSpeakerName != null) {
|
||||
return resolvedSpeakerName;
|
||||
}
|
||||
|
|
@ -746,10 +763,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
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+")) {
|
||||
return null;
|
||||
}
|
||||
if (resolvedUserNameCache != null) {
|
||||
return resolvedUserNameCache.get(userId);
|
||||
}
|
||||
SysUser user = sysUserMapper.selectById(Long.parseLong(userId));
|
||||
if (user == null) {
|
||||
return null;
|
||||
|
|
@ -757,6 +777,45 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
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) {
|
||||
JsonNode timestamp = seg.path("timestamp");
|
||||
if (timestamp.isArray() && timestamp.size() >= 2) {
|
||||
|
|
@ -955,6 +1014,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
? new HashMap<>()
|
||||
: new HashMap<>(chapterTask.getResponseData());
|
||||
responseData.put("summarySource", summarySource.toSnapshot());
|
||||
responseData.put("summarySourceText", summarySource.getText());
|
||||
responseData.put("rawTranscriptText", summarySource.getRawTranscriptText());
|
||||
responseData.put("chapterOutlineText", summarySource.getChapterOutlineText());
|
||||
responseData.put("sourceFingerprint", summarySource.getSourceFingerprint());
|
||||
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 {
|
||||
if (isExternalSummaryModeEnabled()) {
|
||||
triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false);
|
||||
|
|
@ -985,7 +1092,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
return;
|
||||
}
|
||||
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()) {
|
||||
failPendingSummaryTask(sumTask, "没有转录内容");
|
||||
updateMeetingStatus(meeting.getId(), 4);
|
||||
|
|
@ -1031,7 +1141,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
updateMeetingStatus(meeting.getId(), 2);
|
||||
|
|
@ -1041,7 +1151,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
updateProgress(meeting.getId(), 95, result.getMessage(), 0);
|
||||
} catch (Exception ex) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1052,6 +1162,56 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
&& !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) {
|
||||
if (asrTask == null || asrTask.getTaskConfig() == null) {
|
||||
return null;
|
||||
|
|
@ -1138,7 +1298,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
private String normalizeUrlComponent(String value, String fieldName) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(fieldName + "不能为空");
|
||||
throw new IllegalArgumentException(fieldName + " must not be blank");
|
||||
}
|
||||
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 MeetingSummaryFileService meetingSummaryFileService;
|
||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
||||
|
||||
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
|
||||
private final MeetingDomainSupport meetingDomainSupport;
|
||||
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
||||
|
|
@ -86,7 +86,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
|
||||
MeetingSummaryFileService meetingSummaryFileService,
|
||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
|
||||
|
||||
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||
MeetingDomainSupport meetingDomainSupport,
|
||||
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
|
||||
|
|
@ -101,7 +101,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
this.transcriptMapper = transcriptMapper;
|
||||
this.meetingSummaryFileService = meetingSummaryFileService;
|
||||
this.meetingTranscriptFileService = meetingTranscriptFileService;
|
||||
this.meetingTranscriptRevisionService = meetingTranscriptRevisionService;
|
||||
|
||||
this.meetingTranscriptChapterService = meetingTranscriptChapterService;
|
||||
this.meetingDomainSupport = meetingDomainSupport;
|
||||
this.meetingRuntimeProfileResolver = meetingRuntimeProfileResolver;
|
||||
|
|
@ -118,7 +118,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
|
||||
MeetingSummaryFileService meetingSummaryFileService,
|
||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
|
||||
|
||||
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||
MeetingDomainSupport meetingDomainSupport,
|
||||
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
|
||||
|
|
@ -134,7 +134,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
transcriptMapper,
|
||||
meetingSummaryFileService,
|
||||
meetingTranscriptFileService,
|
||||
meetingTranscriptRevisionService,
|
||||
|
||||
meetingTranscriptChapterService,
|
||||
meetingDomainSupport,
|
||||
meetingRuntimeProfileResolver,
|
||||
|
|
@ -585,7 +585,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
if (updated <= 0) {
|
||||
throw new RuntimeException("转录记录不存在");
|
||||
}
|
||||
meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId());
|
||||
meetingTranscriptChapterService.invalidateCurrentVersion(command.getMeetingId());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
|||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
|
|
@ -55,6 +57,21 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
|||
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
|
||||
public Integer resolvePercent(Long meetingId) {
|
||||
MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class);
|
||||
|
|
@ -112,6 +129,7 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
|||
int percent,
|
||||
String message,
|
||||
int eta) {
|
||||
Integer queueAheadCount = resolveQueueAheadCount(task, stage);
|
||||
String externalTaskId = null;
|
||||
if (task != null && task.getResponseData() != null && task.getResponseData().get("task_id") != null) {
|
||||
externalTaskId = String.valueOf(task.getResponseData().get("task_id"));
|
||||
|
|
@ -125,8 +143,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
|||
.stage(stage.getCode())
|
||||
.stageOrder(stage.getOrder())
|
||||
.percent(percent)
|
||||
.message(message)
|
||||
.message(resolveMessage(stage, message, queueAheadCount))
|
||||
.eta(eta)
|
||||
.queueAheadCount(queueAheadCount)
|
||||
.externalTaskId(externalTaskId)
|
||||
.queuedAt(task == null ? null : task.getQueuedAt())
|
||||
.startedAt(task == null ? null : task.getStartedAt())
|
||||
|
|
@ -185,6 +204,36 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
|||
.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) {
|
||||
if (candidate == null) {
|
||||
return false;
|
||||
|
|
@ -198,6 +247,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
|||
}
|
||||
|
||||
if (isTerminal(existing) && !isTerminal(candidate)) {
|
||||
if (isNewAttempt(existing, candidate)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isTerminal(candidate)) {
|
||||
|
|
@ -239,6 +291,22 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
|||
&& (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) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
runnable.run();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
|
|||
|
||||
@Override
|
||||
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>();
|
||||
|
||||
if (!isAdmin || !"all".equals(viewType)) {
|
||||
|
|
@ -61,6 +61,10 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
|
|||
wrapper.like(Meeting::getTitle, title);
|
||||
}
|
||||
|
||||
if (status != null) {
|
||||
wrapper.eq(Meeting::getStatus, status);
|
||||
}
|
||||
|
||||
wrapper.orderByDesc(Meeting::getCreatedAt);
|
||||
|
||||
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 org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
|
|
@ -38,6 +40,21 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
|
|||
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
|
||||
public Integer resolvePercent(Long meetingId) {
|
||||
MeetingProgressSnapshot snapshot = readSnapshot(meetingId);
|
||||
|
|
@ -108,6 +125,10 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
|
|||
int percent,
|
||||
String message,
|
||||
int eta) {
|
||||
String resolvedMessage = message;
|
||||
if (stage == MeetingProgressStage.QUEUED && (resolvedMessage == null || resolvedMessage.isBlank())) {
|
||||
resolvedMessage = "已进入 ASR 队列,等待执行";
|
||||
}
|
||||
return MeetingProgressSnapshot.builder()
|
||||
.meetingId(meetingId)
|
||||
.taskId(task == null ? null : task.getId())
|
||||
|
|
@ -117,7 +138,7 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
|
|||
.stage(stage.getCode())
|
||||
.stageOrder(stage.getOrder())
|
||||
.percent(percent)
|
||||
.message(message)
|
||||
.message(resolvedMessage)
|
||||
.eta(eta)
|
||||
.queuedAt(task == null ? null : task.getQueuedAt())
|
||||
.startedAt(task == null ? null : task.getStartedAt())
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ public class MeetingMcpToolService {
|
|||
loginUser.getUserId(),
|
||||
resolveCreatorName(loginUser),
|
||||
"all",
|
||||
null,
|
||||
isAdmin
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,303 +1,152 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
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.HotWord;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.entity.biz.MeetingTranscript;
|
||||
import com.imeeting.mapper.biz.MeetingMapper;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.HotWordService;
|
||||
import com.imeeting.service.biz.MeetingProgressService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||
import com.imeeting.support.RedisValueSupport;
|
||||
import com.imeeting.support.TaskSecurityContextRunner;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
import com.unisbase.service.SysParamService;
|
||||
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 java.net.URI;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.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.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;
|
||||
|
||||
class AiTaskServiceImplTest {
|
||||
|
||||
@Test
|
||||
void appendPathShouldTrimWhitespaceAndNormalizeSlashBoundaries() {
|
||||
AiTaskServiceImpl service = createService();
|
||||
void buildAsrRequestShouldFollowCurrentOfflineAsrContract() {
|
||||
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(
|
||||
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(
|
||||
AiTaskServiceImpl service = new AiTaskServiceImpl(
|
||||
mock(MeetingMapper.class),
|
||||
mock(MeetingTranscriptMapper.class),
|
||||
mock(AiModelService.class),
|
||||
mock(StringRedisTemplate.class),
|
||||
mock(TaskSecurityContextRunner.class),
|
||||
new ObjectMapper(),
|
||||
mock(SysUserMapper.class),
|
||||
hotWordService,
|
||||
mock(RedisValueSupport.class),
|
||||
mock(MeetingProgressService.class),
|
||||
mock(MeetingSummaryFileService.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,
|
||||
MeetingTranscriptMapper transcriptMapper,
|
||||
AiModelService aiModelService,
|
||||
StringRedisTemplate redisTemplate,
|
||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||
MeetingTranscriptRevisionService revisionService,
|
||||
MeetingTranscriptChapterService chapterService) {
|
||||
return new AiTaskServiceImpl(
|
||||
meetingMapper,
|
||||
transcriptMapper,
|
||||
aiModelService,
|
||||
@Test
|
||||
void buildAsrRequestShouldDisableRegistryMatchWhenSpeakerSplitDisabled() {
|
||||
AiTaskServiceImpl service = new AiTaskServiceImpl(
|
||||
mock(MeetingMapper.class),
|
||||
mock(MeetingTranscriptMapper.class),
|
||||
mock(AiModelService.class),
|
||||
new ObjectMapper(),
|
||||
mock(SysUserMapper.class),
|
||||
mock(HotWordService.class),
|
||||
redisTemplate,
|
||||
mock(RedisValueSupport.class),
|
||||
mock(MeetingProgressService.class),
|
||||
mock(MeetingSummaryFileService.class),
|
||||
meetingTranscriptFileService,
|
||||
revisionService,
|
||||
chapterService,
|
||||
mock(MeetingTranscriptFileService.class),
|
||||
mock(MeetingTranscriptChapterService.class),
|
||||
mock(MeetingSummaryPromptAssembler.class),
|
||||
taskSecurityContextRunner,
|
||||
mock(MeetingExternalSummaryWebhookTrigger.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/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;
|
||||
title?: string;
|
||||
viewType?: "all" | "created" | "involved";
|
||||
status?: number;
|
||||
}) => {
|
||||
return http.get<{ code: string; data: { records: MeetingVO[]; total: number }; msg: string }>(
|
||||
"/api/biz/meeting/page",
|
||||
|
|
@ -426,6 +427,7 @@ export interface MeetingProgress {
|
|||
message: string;
|
||||
updateAt: number;
|
||||
eta?: number;
|
||||
queueAheadCount?: number;
|
||||
}
|
||||
|
||||
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") => {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
return axios.get(`/api/biz/meeting/${id}/summary/export`, {
|
||||
|
|
|
|||
|
|
@ -359,67 +359,101 @@ function parseChapterTimeToMs(value?: string) {
|
|||
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<{
|
||||
meetingId: number;
|
||||
onComplete: () => void;
|
||||
onProgressUpdate?: (meeting: MeetingVO) => void;
|
||||
onRefreshNeeded?: (phase: MeetingProgressPhase) => void;
|
||||
onProgressChange?: (progress: MeetingProgress | null) => void;
|
||||
compact?: boolean;
|
||||
inline?: boolean;
|
||||
}> = ({ meetingId, onComplete, onProgressUpdate, onProgressChange, compact, inline }) => {
|
||||
}> = ({ meetingId, onComplete, onRefreshNeeded, onProgressChange, compact, inline }) => {
|
||||
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
||||
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
const onProgressUpdateRef = useRef(onProgressUpdate);
|
||||
const onRefreshNeededRef = useRef(onRefreshNeeded);
|
||||
const onProgressChangeRef = useRef(onProgressChange);
|
||||
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete;
|
||||
onProgressUpdateRef.current = onProgressUpdate;
|
||||
onRefreshNeededRef.current = onRefreshNeeded;
|
||||
onProgressChangeRef.current = onProgressChange;
|
||||
}, [onComplete, onProgressUpdate, onProgressChange]);
|
||||
}, [onComplete, onRefreshNeeded, onProgressChange]);
|
||||
|
||||
useEffect(() => {
|
||||
let completed = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let requesting = false;
|
||||
|
||||
const fetchProgress = async () => {
|
||||
if (completed) {
|
||||
if (completed || requesting) {
|
||||
return;
|
||||
}
|
||||
requesting = true;
|
||||
let shouldContinue = true;
|
||||
try {
|
||||
const [progressRes, detailRes] = await Promise.all([
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const progressRes = await getMeetingProgress(meetingId, { suppressErrorToast: true });
|
||||
if (progressRes.data?.data) {
|
||||
const nextProgress = progressRes.data.data;
|
||||
setProgress(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) {
|
||||
completed = true;
|
||||
shouldContinue = false;
|
||||
const terminalKey = `${nextProgress.updateAt}:${nextProgress.percent}`;
|
||||
if (meetingProgressTerminalRefreshCache.get(meetingId) !== terminalKey) {
|
||||
meetingProgressTerminalRefreshCache.set(meetingId, terminalKey);
|
||||
onCompleteRef.current?.();
|
||||
}
|
||||
} else {
|
||||
meetingProgressTerminalRefreshCache.delete(meetingId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
requesting = false;
|
||||
if (!completed && shouldContinue) {
|
||||
timer = setTimeout(() => {
|
||||
void fetchProgress();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchProgress();
|
||||
const timer = setInterval(fetchProgress, 3000);
|
||||
void fetchProgress();
|
||||
return () => {
|
||||
completed = true;
|
||||
clearInterval(timer);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [meetingId]);
|
||||
|
||||
|
|
@ -1325,9 +1359,22 @@ const MeetingDetail: React.FC = () => {
|
|||
userPrompt: values.userPrompt,
|
||||
summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel,
|
||||
});
|
||||
message.success('已重新发起总结任务');
|
||||
setSummaryVisible(false);
|
||||
fetchData(Number(id));
|
||||
setMeeting((current) => current ? {
|
||||
...current,
|
||||
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) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
|
|
@ -2000,15 +2047,11 @@ const MeetingDetail: React.FC = () => {
|
|||
/>
|
||||
|
||||
<div className="meeting-detail-workspace">
|
||||
{meeting.status === 1 ? (
|
||||
{meeting.status === 0 || meeting.status === 1 ? (
|
||||
<MeetingProgressDisplay
|
||||
meetingId={meeting.id}
|
||||
onComplete={() => fetchData(meeting.id)}
|
||||
onProgressUpdate={(updated) => {
|
||||
if (updated.status !== meeting.status) {
|
||||
void fetchData(updated.id);
|
||||
}
|
||||
}}
|
||||
onRefreshNeeded={() => { void fetchData(meeting.id); }}
|
||||
onProgressChange={setGenerationProgress}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -2051,11 +2094,7 @@ const MeetingDetail: React.FC = () => {
|
|||
<MeetingProgressDisplay
|
||||
meetingId={meeting.id}
|
||||
onComplete={() => fetchData(meeting.id)}
|
||||
onProgressUpdate={(updated) => {
|
||||
if (updated.status === 2 || updated.status !== meeting.status) {
|
||||
void fetchData(updated.id);
|
||||
}
|
||||
}}
|
||||
onRefreshNeeded={() => { void fetchData(meeting.id); }}
|
||||
onProgressChange={setGenerationProgress}
|
||||
compact
|
||||
/>
|
||||
|
|
@ -2075,11 +2114,7 @@ const MeetingDetail: React.FC = () => {
|
|||
<MeetingProgressDisplay
|
||||
meetingId={meeting.id}
|
||||
onComplete={() => fetchData(meeting.id)}
|
||||
onProgressUpdate={(updated) => {
|
||||
if (updated.status !== meeting.status) {
|
||||
void fetchData(updated.id);
|
||||
}
|
||||
}}
|
||||
onRefreshNeeded={() => { void fetchData(meeting.id); }}
|
||||
onProgressChange={setGenerationProgress}
|
||||
inline
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
CloudUploadOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FilterOutlined,
|
||||
InfoCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlusOutlined,
|
||||
|
|
@ -38,7 +39,7 @@ import {
|
|||
Typography,
|
||||
} from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
|
|
@ -47,7 +48,7 @@ import {
|
|||
deleteMeeting,
|
||||
getMeetingCreateConfig,
|
||||
getMeetingPage,
|
||||
getMeetingProgress,
|
||||
getMeetingProgressBatch,
|
||||
getRealtimeMeetingSessionStatus,
|
||||
getRealtimeMeetingSessionStatuses,
|
||||
type MeetingCreateConfig,
|
||||
|
|
@ -64,11 +65,21 @@ import PageContainer from "../../components/shared/PageContainer";
|
|||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
const { Search } = Input;
|
||||
|
||||
const CURRENT_PLATFORM = "WEB" as const;
|
||||
const PAUSED_DISPLAY_STATUS = 5;
|
||||
const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
|
||||
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 = {
|
||||
offlineEnabled: true,
|
||||
realtimeEnabled: true,
|
||||
|
|
@ -95,9 +106,17 @@ const canOpenRealtimeSession = (status?: RealtimeMeetingSessionStatus["status"])
|
|||
|| status === "ACTIVE"
|
||||
|| 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) =>
|
||||
item.status === 1
|
||||
|| item.status === 2
|
||||
shouldTrackGenerationProgress(item)
|
||||
|| item.realtimeSessionStatus === "ACTIVE"
|
||||
|| isPausedRealtimeSessionStatus(item.realtimeSessionStatus);
|
||||
|
||||
|
|
@ -130,35 +149,9 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee
|
|||
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 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 }> = {
|
||||
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 /> },
|
||||
|
|
@ -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 /> },
|
||||
};
|
||||
const config = statusConfig[effectiveStatus] || statusConfig[0];
|
||||
const percent = meeting.status === 1 || meeting.status === 2 ? progress?.percent || 0 : 0;
|
||||
const isProcessing = meeting.status === 1 || meeting.status === 2;
|
||||
const isProcessing = shouldTrackGenerationProgress(meeting);
|
||||
const percent = isProcessing ? progress?.percent || 0 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -213,20 +206,19 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
|
|||
);
|
||||
};
|
||||
|
||||
const TableStatusCell: React.FC<{ meeting: MeetingVO; fetchData: () => void }> = ({ meeting, fetchData }) => {
|
||||
const progress = useMeetingProgress(meeting, fetchData);
|
||||
const TableStatusCell: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
|
||||
return <IntegratedStatusTag meeting={meeting} progress={progress} />;
|
||||
};
|
||||
|
||||
const MeetingCardItem: React.FC<{
|
||||
item: MeetingVO;
|
||||
config: { text: string; color: string; bgColor: string };
|
||||
progress: MeetingProgress | null;
|
||||
fetchData: () => void;
|
||||
onOpenMeeting: (meeting: MeetingVO) => void;
|
||||
}> = ({ item, config, fetchData, onOpenMeeting }) => {
|
||||
const progress = useMeetingProgress(item, fetchData);
|
||||
const effectiveStatus = item.displayStatus ?? item.status;
|
||||
const isProcessing = item.status === 1 || item.status === 2;
|
||||
}> = ({ item, config, progress, fetchData, onOpenMeeting }) => {
|
||||
const effectiveStatus = hasLatestGenerationFailure(item) ? 4 : (item.displayStatus ?? item.status);
|
||||
const isProcessing = shouldTrackGenerationProgress(item);
|
||||
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
|
||||
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
|
||||
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
|
||||
|
|
@ -403,12 +395,15 @@ const Meetings: React.FC = () => {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<MeetingVO[]>([]);
|
||||
const [progressMap, setProgressMap] = useState<Record<number, MeetingProgress>>({});
|
||||
const [total, setTotal] = useState(0);
|
||||
const [current, setCurrent] = useState(1);
|
||||
const [displayMode, setDisplayMode] = useState<"card" | "list">("card");
|
||||
const [size, setSize] = useState(8);
|
||||
const [searchTitle, setSearchTitle] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [viewType, setViewType] = useState<"all" | "created" | "involved">("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>(ALL_STATUS_FILTER);
|
||||
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
|
||||
const [createDrawerType, setCreateDrawerType] = useState<MeetingCreateType>("upload");
|
||||
const [configLoaded, setConfigLoaded] = useState(false);
|
||||
|
|
@ -418,8 +413,9 @@ const Meetings: React.FC = () => {
|
|||
offlineAudioMaxSizeMb: 1024,
|
||||
});
|
||||
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") => {
|
||||
setDisplayMode(mode);
|
||||
|
|
@ -427,6 +423,44 @@ const Meetings: React.FC = () => {
|
|||
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(() => {
|
||||
const action = searchParams.get("action");
|
||||
const type = searchParams.get("type") as MeetingCreateType;
|
||||
|
|
@ -439,15 +473,105 @@ const Meetings: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
}, [current, size, searchTitle, viewType]);
|
||||
}, [current, size, searchTitle, viewType, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRunningTasks) {
|
||||
const trackedMeetings = data.filter(shouldTrackGenerationProgress);
|
||||
if (trackedMeetings.length === 0) {
|
||||
progressTerminalRefreshRef.current.clear();
|
||||
setProgressMap({});
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => void fetchData(true), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasRunningTasks, current, size, searchTitle, viewType]);
|
||||
|
||||
let cancelled = false;
|
||||
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(() => {
|
||||
listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([]));
|
||||
|
|
@ -467,7 +591,13 @@ const Meetings: React.FC = () => {
|
|||
setLoading(true);
|
||||
}
|
||||
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 || [];
|
||||
let statusMap: Record<number, RealtimeMeetingSessionStatus> = {};
|
||||
const realtimeCandidates = records.filter(isRealtimeMeetingCandidate);
|
||||
|
|
@ -477,8 +607,10 @@ const Meetings: React.FC = () => {
|
|||
statusMap = sessionRes.data?.data || {};
|
||||
} 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);
|
||||
await loadBatchProgress(nextData);
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
|
|
@ -525,7 +657,7 @@ const Meetings: React.FC = () => {
|
|||
title: "状态",
|
||||
key: "status",
|
||||
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: "会议时间",
|
||||
|
|
@ -611,20 +743,70 @@ const Meetings: React.FC = () => {
|
|||
</Space>
|
||||
}
|
||||
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.Button value="all">全部</Radio.Button>
|
||||
<Radio.Button value="created">我发起</Radio.Button>
|
||||
<Radio.Button value="involved">我参与</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Input
|
||||
placeholder="搜索标题"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onPressEnter={(e) => { setSearchTitle((e.target as HTMLInputElement).value); setCurrent(1); }}
|
||||
style={{ width: 200 }}
|
||||
<Space wrap size={10} align="center">
|
||||
<Tag
|
||||
color={activeFilterCount > 0 ? "processing" : "default"}
|
||||
style={{ margin: 0, borderRadius: 999, paddingInline: 10, lineHeight: "24px" }}
|
||||
>
|
||||
<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
|
||||
|
|
@ -639,8 +821,17 @@ const Meetings: React.FC = () => {
|
|||
grid={{ gutter: [20, 20], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
||||
dataSource={data}
|
||||
renderItem={(item) => {
|
||||
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
|
||||
return <MeetingCardItem item={item} config={config} fetchData={() => void fetchData()} onOpenMeeting={handleOpenMeeting} />;
|
||||
const visualStatus = hasLatestGenerationFailure(item) ? 4 : (item.displayStatus ?? item.status);
|
||||
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="开启您的第一场会议分析" /> }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ const SpeakerReg: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
form.setFieldValue('userId', profile.userId);
|
||||
form.setFieldValue('name', profile.displayName);
|
||||
}, [form, isAdmin, profile?.userId]);
|
||||
|
||||
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue