feat: 添加会议转录文件服务
- 新增 `MeetingTranscriptFileServiceImpl` 实现会议转录文件的初始化和导出功能 - 定义 `MeetingTranscriptExportResult` 数据传输对象,用于封装导出结果 - 定义 `MeetingTranscriptFileService` 接口,提供初始化和导出会议转录文件的方法dev_na
parent
a8b93a46f8
commit
4904526e09
|
|
@ -43,6 +43,10 @@ public final class RedisKeys {
|
||||||
return "biz:meeting:polling:lock:" + meetingId;
|
return "biz:meeting:polling:lock:" + meetingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String meetingSummaryLockKey(Long meetingId) {
|
||||||
|
return "biz:meeting:summary:lock:" + meetingId;
|
||||||
|
}
|
||||||
|
|
||||||
public static String realtimeMeetingSocketSessionKey(String sessionToken) {
|
public static String realtimeMeetingSocketSessionKey(String sessionToken) {
|
||||||
return "biz:meeting:realtime:socket:" + sessionToken;
|
return "biz:meeting:realtime:socket:" + sessionToken;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,7 @@ public final class SysParamKeys {
|
||||||
|
|
||||||
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
|
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
|
||||||
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt";
|
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt";
|
||||||
|
public static final String MEETING_TRANSCRIPT_CLEANUP_FILLER_WORDS = "meeting.transcript.cleanup.filler_words";
|
||||||
|
public static final String MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS = "meeting.transcript.cleanup.replacements";
|
||||||
public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb";
|
public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,20 @@ public class AndroidMeetingController {
|
||||||
|
|
||||||
Integer realtimeProgress = resolveRealtimeProgress(meetingId);
|
Integer realtimeProgress = resolveRealtimeProgress(meetingId);
|
||||||
if (realtimeProgress != null) {
|
if (realtimeProgress != null) {
|
||||||
|
if (realtimeProgress >= 100) {
|
||||||
|
MeetingVO completedDetail = detail != null ? detail : meetingQueryService.getDetail(meetingId);
|
||||||
|
boolean completedHasSummary = completedDetail != null
|
||||||
|
&& completedDetail.getSummaryContent() != null
|
||||||
|
&& !completedDetail.getSummaryContent().isBlank();
|
||||||
|
if (completedHasSummary) {
|
||||||
|
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, completedDetail, summaryTask));
|
||||||
|
}
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"504",
|
||||||
|
"处理已完成,但摘要尚未同步,请稍后重试",
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可查看详情", 100, STAGE_COMPLETED))
|
||||||
|
);
|
||||||
|
}
|
||||||
if (realtimeProgress < 90) {
|
if (realtimeProgress < 90) {
|
||||||
return new LegacyMeetingPreviewResult(
|
return new LegacyMeetingPreviewResult(
|
||||||
"400",
|
"400",
|
||||||
|
|
@ -258,7 +272,7 @@ public class AndroidMeetingController {
|
||||||
buildProcessingPreview(meeting, summaryTask, processingStatus("正在转写音频", 50, STAGE_AUDIO_TRANSCRIPTION))
|
buildProcessingPreview(meeting, summaryTask, processingStatus("正在转写音频", 50, STAGE_AUDIO_TRANSCRIPTION))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (realtimeProgress == 90) {
|
if (realtimeProgress >= 90) {
|
||||||
return new LegacyMeetingPreviewResult(
|
return new LegacyMeetingPreviewResult(
|
||||||
"400",
|
"400",
|
||||||
"会议正在处理中",
|
"会议正在处理中",
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,9 @@ public class LegacyMeetingController {
|
||||||
: null;
|
: null;
|
||||||
boolean hasSummary = detail != null && detail.getSummaryContent() != null && !detail.getSummaryContent().isBlank();
|
boolean hasSummary = detail != null && detail.getSummaryContent() != null && !detail.getSummaryContent().isBlank();
|
||||||
|
|
||||||
|
if (hasSummary) {
|
||||||
|
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask));
|
||||||
|
}
|
||||||
if (summaryCompleted) {
|
if (summaryCompleted) {
|
||||||
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask));
|
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask));
|
||||||
}
|
}
|
||||||
|
|
@ -264,17 +267,31 @@ public class LegacyMeetingController {
|
||||||
|
|
||||||
Integer realtimeProgress = resolveRealtimeProgress(meetingId);
|
Integer realtimeProgress = resolveRealtimeProgress(meetingId);
|
||||||
if (realtimeProgress != null) {
|
if (realtimeProgress != null) {
|
||||||
|
if (realtimeProgress >= 100) {
|
||||||
|
MeetingVO completedDetail = detail != null ? detail : meetingQueryService.getDetail(meetingId);
|
||||||
|
boolean completedHasSummary = completedDetail != null
|
||||||
|
&& completedDetail.getSummaryContent() != null
|
||||||
|
&& !completedDetail.getSummaryContent().isBlank();
|
||||||
|
if (completedHasSummary) {
|
||||||
|
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, completedDetail, summaryTask));
|
||||||
|
}
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"504",
|
||||||
|
"处理已完成,但摘要尚未同步,请稍后重试",
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED))
|
||||||
|
);
|
||||||
|
}
|
||||||
if (realtimeProgress < 90) {
|
if (realtimeProgress < 90) {
|
||||||
return new LegacyMeetingPreviewResult(
|
return new LegacyMeetingPreviewResult(
|
||||||
"400",
|
"400",
|
||||||
"浼氳姝e湪澶勭悊涓?",
|
"会议正在处理中",
|
||||||
buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪杞瘧闊抽", 50, STAGE_AUDIO_TRANSCRIPTION))
|
buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪杞瘧闊抽", 50, STAGE_AUDIO_TRANSCRIPTION))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (realtimeProgress == 90) {
|
if (realtimeProgress >= 90) {
|
||||||
return new LegacyMeetingPreviewResult(
|
return new LegacyMeetingPreviewResult(
|
||||||
"400",
|
"400",
|
||||||
"浼氳姝e湪澶勭悊涓?",
|
"会议正在处理中",
|
||||||
buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪鐢熸垚鎬荤粨", 75, STAGE_SUMMARY_GENERATION))
|
buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪鐢熸垚鎬荤粨", 75, STAGE_SUMMARY_GENERATION))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class MeetingSummarySource {
|
||||||
|
private String text;
|
||||||
|
private String sourceType;
|
||||||
|
private Long revisionId;
|
||||||
|
private boolean fallbackUsed;
|
||||||
|
private String sourceFingerprint;
|
||||||
|
private String triggerTaskType;
|
||||||
|
private String semanticCorrector;
|
||||||
|
private String ruleProfileVersion;
|
||||||
|
|
||||||
|
public Map<String, Object> toSnapshot() {
|
||||||
|
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||||
|
snapshot.put("sourceType", sourceType);
|
||||||
|
snapshot.put("revisionId", revisionId);
|
||||||
|
snapshot.put("fallbackUsed", fallbackUsed);
|
||||||
|
snapshot.put("sourceFingerprint", sourceFingerprint);
|
||||||
|
snapshot.put("triggerTaskType", triggerTaskType);
|
||||||
|
snapshot.put("semanticCorrector", semanticCorrector);
|
||||||
|
snapshot.put("ruleProfileVersion", ruleProfileVersion);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package com.imeeting.entity.biz;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "会议转录修正版")
|
||||||
|
@TableName("biz_meeting_transcript_revisions")
|
||||||
|
public class MeetingTranscriptRevision {
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
@Schema(description = "修正版ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Schema(description = "会议ID")
|
||||||
|
private Long meetingId;
|
||||||
|
|
||||||
|
@Schema(description = "触发本次修正版生成的任务ID")
|
||||||
|
private Long sourceTaskId;
|
||||||
|
|
||||||
|
@Schema(description = "版本号")
|
||||||
|
private Integer revisionNo;
|
||||||
|
|
||||||
|
@Schema(description = "状态")
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@Schema(description = "修正版全文")
|
||||||
|
private String cleanedFullText;
|
||||||
|
|
||||||
|
@Schema(description = "结果文件路径")
|
||||||
|
private String resultFilePath;
|
||||||
|
|
||||||
|
@Schema(description = "规则配置快照")
|
||||||
|
private String ruleProfile;
|
||||||
|
|
||||||
|
@Schema(description = "片段数")
|
||||||
|
private Integer segmentCount;
|
||||||
|
|
||||||
|
@Schema(description = "删除片段数")
|
||||||
|
private Integer droppedSegmentCount;
|
||||||
|
|
||||||
|
@Schema(description = "合并组数")
|
||||||
|
private Integer mergedGroupCount;
|
||||||
|
|
||||||
|
@Schema(description = "是否当前生效")
|
||||||
|
private Integer isCurrent;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.imeeting.entity.biz;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "会议转录修正版明细")
|
||||||
|
@TableName("biz_meeting_transcript_revision_items")
|
||||||
|
public class MeetingTranscriptRevisionItem {
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
@Schema(description = "修正版明细ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Schema(description = "修正版ID")
|
||||||
|
private Long revisionId;
|
||||||
|
|
||||||
|
@Schema(description = "原始转录ID")
|
||||||
|
private Long sourceTranscriptId;
|
||||||
|
|
||||||
|
@Schema(description = "原始排序值")
|
||||||
|
private Integer sourceSortOrder;
|
||||||
|
|
||||||
|
@Schema(description = "原始说话人ID")
|
||||||
|
private String sourceSpeakerId;
|
||||||
|
|
||||||
|
@Schema(description = "原始说话人名称")
|
||||||
|
private String sourceSpeakerName;
|
||||||
|
|
||||||
|
@Schema(description = "原始内容")
|
||||||
|
private String sourceContent;
|
||||||
|
|
||||||
|
@Schema(description = "清洗后内容")
|
||||||
|
private String cleanedContent;
|
||||||
|
|
||||||
|
@Schema(description = "清洗后说话人名称")
|
||||||
|
private String cleanedSpeakerName;
|
||||||
|
|
||||||
|
@Schema(description = "动作类型")
|
||||||
|
private String actionType;
|
||||||
|
|
||||||
|
@Schema(description = "合并组ID")
|
||||||
|
private String mergeGroupId;
|
||||||
|
|
||||||
|
@Schema(description = "置信度")
|
||||||
|
private java.math.BigDecimal confidence;
|
||||||
|
|
||||||
|
@Schema(description = "命中规则")
|
||||||
|
private String ruleHits;
|
||||||
|
|
||||||
|
@Schema(description = "上下文快照")
|
||||||
|
private String contextSnapshot;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,7 @@ public class MeetingTaskRecoveryListener implements ApplicationRunner {
|
||||||
|
|
||||||
// 2. 清理旧的 Redis 锁和进度缓存,确保恢复线程能拿到控制权
|
// 2. 清理旧的 Redis 锁和进度缓存,确保恢复线程能拿到控制权
|
||||||
redisTemplate.delete(RedisKeys.meetingPollingLockKey(meeting.getId()));
|
redisTemplate.delete(RedisKeys.meetingPollingLockKey(meeting.getId()));
|
||||||
|
redisTemplate.delete(RedisKeys.meetingSummaryLockKey(meeting.getId()));
|
||||||
|
|
||||||
// 3. 根据状态重新派发任务 (平滑拉起)
|
// 3. 根据状态重新派发任务 (平滑拉起)
|
||||||
if (meeting.getStatus() == 1) {
|
if (meeting.getStatus() == 1) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.imeeting.mapper.biz;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.imeeting.entity.biz.MeetingTranscriptRevisionItem;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface MeetingTranscriptRevisionItemMapper extends BaseMapper<MeetingTranscriptRevisionItem> {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.imeeting.mapper.biz;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.imeeting.entity.biz.MeetingTranscriptRevision;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface MeetingTranscriptRevisionMapper extends BaseMapper<MeetingTranscriptRevision> {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.imeeting.service.biz;
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import com.imeeting.common.RedisKeys;
|
import com.imeeting.common.RedisKeys;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
|
import com.imeeting.dto.biz.MeetingSummarySource;
|
||||||
import com.imeeting.entity.biz.AiTask;
|
import com.imeeting.entity.biz.AiTask;
|
||||||
import com.imeeting.entity.biz.HotWord;
|
import com.imeeting.entity.biz.HotWord;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
|
|
@ -20,6 +21,7 @@ import com.imeeting.service.biz.AiTaskService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||||
|
|
||||||
import com.unisbase.entity.SysUser;
|
import com.unisbase.entity.SysUser;
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
|
|
@ -60,6 +62,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||||
|
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
||||||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||||
|
|
||||||
|
|
@ -99,7 +102,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
|
|
||||||
String asrText = "";
|
String asrText = "";
|
||||||
if (asrTask != null && asrTask.getStatus() == 0) {
|
if (asrTask != null && canExecuteTask(asrTask)) {
|
||||||
asrText = processAsrTask(meeting, asrTask);
|
asrText = processAsrTask(meeting, asrTask);
|
||||||
} else {
|
} else {
|
||||||
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
||||||
|
|
@ -129,13 +132,11 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0);
|
updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sumTask != null && sumTask.getStatus() == 0) {
|
if (sumTask != null && canExecuteTask(sumTask)) {
|
||||||
processSummaryTask(meeting, asrText, sumTask);
|
executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask));
|
||||||
} else if (meeting.getStatus() != 3) {
|
} else if (meeting.getStatus() != 3) {
|
||||||
updateMeetingStatus(meetingId, 3);
|
updateMeetingStatus(meetingId, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId));
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Meeting {} AI Task Flow failed", meetingId, e);
|
log.error("Meeting {} AI Task Flow failed", meetingId, e);
|
||||||
failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务:" + e.getMessage());
|
failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务:" + e.getMessage());
|
||||||
|
|
@ -154,34 +155,19 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
|
|
||||||
private void doDispatchSummaryTask(Long meetingId) {
|
private void doDispatchSummaryTask(Long meetingId) {
|
||||||
Meeting meeting = meetingMapper.selectById(meetingId);
|
Meeting meeting = meetingMapper.selectById(meetingId);
|
||||||
if (meeting == null) return;
|
if (meeting == null) {
|
||||||
AiTask sumTask = this.getOne(new LambdaQueryWrapper<AiTask>()
|
return;
|
||||||
.eq(AiTask::getMeetingId, meetingId)
|
}
|
||||||
.eq(AiTask::getTaskType, "SUMMARY")
|
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
|
||||||
.orderByDesc(AiTask::getId)
|
AiTask asrTask = findLatestTask(meetingId, "ASR");
|
||||||
.last("limit 1"));
|
|
||||||
try {
|
try {
|
||||||
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
if (sumTask != null && canExecuteTask(sumTask)) {
|
||||||
.eq(MeetingTranscript::getMeetingId, meetingId)
|
executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask));
|
||||||
.orderByAsc(MeetingTranscript::getStartTime));
|
|
||||||
|
|
||||||
if (transcripts.isEmpty()) {
|
|
||||||
failPendingSummaryTask(sumTask, "没有可用于总结的转录内容");
|
|
||||||
throw new RuntimeException("没有找到可用的转录文本,无法生成总结");
|
|
||||||
}
|
|
||||||
|
|
||||||
String asrText = buildTranscriptText(transcripts);
|
|
||||||
if (asrText == null || asrText.isBlank()) {
|
|
||||||
failPendingSummaryTask(sumTask, "没有可用于总结的转录内容");
|
|
||||||
throw new RuntimeException("没有可用于总结的转录内容");
|
|
||||||
}
|
|
||||||
if (sumTask != null && sumTask.getStatus() == 0) {
|
|
||||||
processSummaryTask(meeting, asrText, sumTask);
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Re-summary failed for meeting {}", meetingId, e);
|
log.error("Re-summary failed for meeting {}", meetingId, e);
|
||||||
updateMeetingStatus(meetingId, 4);
|
updateMeetingStatus(meetingId, 4);
|
||||||
updateProgress(meetingId, -1, "总结失败: " + e.getMessage(), 0);
|
updateProgress(meetingId, -1, "Summary flow failed: " + e.getMessage(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,21 +183,28 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
if (asrModel == null) throw new RuntimeException("ASR模型配置不存在");
|
if (asrModel == null) throw new RuntimeException("ASR模型配置不存在");
|
||||||
|
|
||||||
String submitUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions");
|
String submitUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions");
|
||||||
String taskId = null;
|
String taskId = taskRecord.getResponseData() != null
|
||||||
|
? String.valueOf(taskRecord.getResponseData().getOrDefault("task_id", ""))
|
||||||
|
: "";
|
||||||
|
|
||||||
updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0);
|
if (taskId == null || taskId.isBlank()) {
|
||||||
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
|
updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0);
|
||||||
taskRecord.setRequestData(req);
|
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
|
||||||
this.updateById(taskRecord);
|
taskRecord.setRequestData(req);
|
||||||
|
this.updateById(taskRecord);
|
||||||
|
|
||||||
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
||||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||||
if (submitNode.path("code").asInt() != 0) {
|
if (submitNode.path("code").asInt() != 0) {
|
||||||
updateAiTaskFail(taskRecord, "提交失败:" + respBody);
|
updateAiTaskFail(taskRecord, "提交失败: " + respBody);
|
||||||
throw new RuntimeException("ASR引擎拒绝请求: " + submitNode.path("msg").asText());
|
throw new RuntimeException("ASR引擎拒绝请求: " + submitNode.path("msg").asText());
|
||||||
|
}
|
||||||
|
taskId = submitNode.path("data").path("task_id").asText();
|
||||||
|
taskRecord.setResponseData(Map.of("task_id", taskId));
|
||||||
|
this.updateById(taskRecord);
|
||||||
|
} else {
|
||||||
|
updateProgress(meeting.getId(), 5, "Resuming ASR polling...", 0);
|
||||||
}
|
}
|
||||||
taskId = submitNode.path("data").path("task_id").asText();
|
|
||||||
taskRecord.setResponseData(Map.of("task_id", taskId));
|
|
||||||
this.updateById(taskRecord);
|
this.updateById(taskRecord);
|
||||||
|
|
||||||
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
|
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
|
||||||
|
|
@ -473,12 +466,16 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processSummaryTask(Meeting meeting, String asrText, AiTask taskRecord) throws Exception {
|
private void processSummaryTask(Meeting meeting, MeetingSummarySource summarySource, AiTask taskRecord) throws Exception {
|
||||||
|
String asrText = summarySource.getText();
|
||||||
updateMeetingStatus(meeting.getId(), 2);
|
updateMeetingStatus(meeting.getId(), 2);
|
||||||
updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0);
|
updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0);
|
||||||
|
|
||||||
taskRecord.setStatus(1);
|
taskRecord.setStatus(1);
|
||||||
taskRecord.setStartedAt(LocalDateTime.now());
|
taskRecord.setStartedAt(LocalDateTime.now());
|
||||||
|
Map<String, Object> initialResponseData = new HashMap<>();
|
||||||
|
initialResponseData.put("summarySource", summarySource.toSnapshot());
|
||||||
|
taskRecord.setResponseData(initialResponseData);
|
||||||
this.updateById(taskRecord);
|
this.updateById(taskRecord);
|
||||||
|
|
||||||
Long summaryModelId = Long.valueOf(taskRecord.getTaskConfig().get("summaryModelId").toString());
|
Long summaryModelId = Long.valueOf(taskRecord.getTaskConfig().get("summaryModelId").toString());
|
||||||
|
|
@ -555,6 +552,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
|
|
||||||
taskRecord.setResultFilePath("meetings/" + meeting.getId() + "/summaries/" + fileName);
|
taskRecord.setResultFilePath("meetings/" + meeting.getId() + "/summaries/" + fileName);
|
||||||
Map<String, Object> responseData = objectMapper.convertValue(respNode, Map.class);
|
Map<String, Object> responseData = objectMapper.convertValue(respNode, Map.class);
|
||||||
|
responseData.put("summarySource", summarySource.toSnapshot());
|
||||||
if (summaryBundle != null) {
|
if (summaryBundle != null) {
|
||||||
responseData.put("summaryBundle", summaryBundle);
|
responseData.put("summaryBundle", summaryBundle);
|
||||||
}
|
}
|
||||||
|
|
@ -572,11 +570,62 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
|
|
||||||
updateProgress(meeting.getId(), 100, "全流程分析完成", 0);
|
updateProgress(meeting.getId(), 100, "全流程分析完成", 0);
|
||||||
} else {
|
} else {
|
||||||
updateAiTaskFail(taskRecord, "LLM 总结失败:" + response.body());
|
updateAiTaskFail(taskRecord, "LLM 总结失败: " + response.body());
|
||||||
throw new RuntimeException("AI总结生成异常");
|
throw new RuntimeException("AI总结生成异常");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiModelVO asrModel) throws Exception {
|
||||||
|
String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId());
|
||||||
|
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(summaryLockKey, "locked", 30, TimeUnit.MINUTES);
|
||||||
|
if (Boolean.FALSE.equals(acquired)) {
|
||||||
|
log.warn("Meeting {} summary is already being processed", meeting.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
MeetingSummarySource summarySource = meetingTranscriptRevisionService.resolveSummarySource(meeting, sumTask, asrModel);
|
||||||
|
if (summarySource.getText() == null || summarySource.getText().isBlank()) {
|
||||||
|
failPendingSummaryTask(sumTask, "没有转录内容");
|
||||||
|
updateMeetingStatus(meeting.getId(), 4);
|
||||||
|
updateProgress(meeting.getId(), -1, "没有转录内容", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processSummaryTask(meeting, summarySource, sumTask);
|
||||||
|
} finally {
|
||||||
|
redisTemplate.delete(summaryLockKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AiTask findLatestTask(Long meetingId, String taskType) {
|
||||||
|
return this.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meetingId)
|
||||||
|
.eq(AiTask::getTaskType, taskType)
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("limit 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean canExecuteTask(AiTask task) {
|
||||||
|
return task != null
|
||||||
|
&& !Integer.valueOf(2).equals(task.getStatus())
|
||||||
|
&& !Integer.valueOf(3).equals(task.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private AiModelVO resolveAsrModelForRevision(AiTask asrTask) {
|
||||||
|
if (asrTask == null || asrTask.getTaskConfig() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object asrModelId = asrTask.getTaskConfig().get("asrModelId");
|
||||||
|
if (asrModelId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return aiModelService.getModelById(Long.parseLong(String.valueOf(asrModelId)), "ASR");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Failed to resolve ASR model for transcript revision, taskId={}", asrTask.getId(), ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void updateProgress(Long meetingId, int percent, String msg, int eta) {
|
private void updateProgress(Long meetingId, int percent, String msg, int eta) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> progress = new HashMap<>();
|
Map<String, Object> progress = new HashMap<>();
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
import com.imeeting.service.biz.MeetingService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
@ -55,6 +56,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
|
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||||
|
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
||||||
private final MeetingDomainSupport meetingDomainSupport;
|
private final MeetingDomainSupport meetingDomainSupport;
|
||||||
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
||||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||||
|
|
@ -436,6 +438,17 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
throw new RuntimeException("转录内容不能为空");
|
throw new RuntimeException("转录内容不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MeetingTranscript existing = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
|
||||||
|
.eq(MeetingTranscript::getMeetingId, command.getMeetingId())
|
||||||
|
.eq(MeetingTranscript::getId, command.getTranscriptId())
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (existing == null) {
|
||||||
|
throw new RuntimeException("转录记录不存在");
|
||||||
|
}
|
||||||
|
if (Objects.equals(normalizeTranscriptContent(existing.getContent()), content)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
int updated = transcriptMapper.update(null, new LambdaUpdateWrapper<MeetingTranscript>()
|
int updated = transcriptMapper.update(null, new LambdaUpdateWrapper<MeetingTranscript>()
|
||||||
.eq(MeetingTranscript::getMeetingId, command.getMeetingId())
|
.eq(MeetingTranscript::getMeetingId, command.getMeetingId())
|
||||||
.eq(MeetingTranscript::getId, command.getTranscriptId())
|
.eq(MeetingTranscript::getId, command.getTranscriptId())
|
||||||
|
|
@ -443,6 +456,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
if (updated <= 0) {
|
if (updated <= 0) {
|
||||||
throw new RuntimeException("转录记录不存在");
|
throw new RuntimeException("转录记录不存在");
|
||||||
}
|
}
|
||||||
|
meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -464,6 +478,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
return normalized.isEmpty() ? null : normalized;
|
return normalized.isEmpty() ? null : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeTranscriptContent(String content) {
|
||||||
|
return content == null ? "" : content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void updateMeetingParticipants(Long meetingId, String participants) {
|
public void updateMeetingParticipants(Long meetingId, String participants) {
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,8 @@ public class MeetingTranscriptFileServiceImpl implements MeetingTranscriptFileSe
|
||||||
.orderByAsc(MeetingTranscript::getStartTime)
|
.orderByAsc(MeetingTranscript::getStartTime)
|
||||||
.orderByAsc(MeetingTranscript::getId));
|
.orderByAsc(MeetingTranscript::getId));
|
||||||
|
|
||||||
String title = firstNonBlank(meetingDetail != null ? meetingDetail.getTitle() : null, meeting.getTitle(), "未命名会议");
|
String title = firstNonBlank(meetingDetail != null ? meetingDetail.getTitle() : null, meeting.getTitle(), "Untitled Meeting");
|
||||||
String hostName = firstNonBlank(meetingDetail != null ? meetingDetail.getHostName() : null, meeting.getHostName(), "未知");
|
String hostName = firstNonBlank(meetingDetail != null ? meetingDetail.getHostName() : null, meeting.getHostName(), "Unknown");
|
||||||
String meetingTime = formatDateTime(meetingDetail != null ? meetingDetail.getMeetingTime() : meeting.getMeetingTime());
|
String meetingTime = formatDateTime(meetingDetail != null ? meetingDetail.getMeetingTime() : meeting.getMeetingTime());
|
||||||
|
|
||||||
StringBuilder builder = new StringBuilder();
|
StringBuilder builder = new StringBuilder();
|
||||||
|
|
@ -104,13 +104,13 @@ public class MeetingTranscriptFileServiceImpl implements MeetingTranscriptFileSe
|
||||||
}
|
}
|
||||||
|
|
||||||
for (MeetingTranscript transcript : transcripts) {
|
for (MeetingTranscript transcript : transcripts) {
|
||||||
String speaker = firstNonBlank(transcript.getSpeakerName(), transcript.getSpeakerId(), "未知发言人");
|
String speaker = firstNonBlank(transcript.getSpeakerName(), transcript.getSpeakerId(), "Unknown Speaker");
|
||||||
builder.append("- ");
|
builder.append("- ");
|
||||||
String timeRange = buildTimeRange(transcript.getStartTime(), transcript.getEndTime());
|
String timeRange = buildTimeRange(transcript.getStartTime(), transcript.getEndTime());
|
||||||
if (!timeRange.isBlank()) {
|
if (!timeRange.isBlank()) {
|
||||||
builder.append(timeRange).append(' ');
|
builder.append(timeRange).append(' ');
|
||||||
}
|
}
|
||||||
builder.append(speaker).append(":").append(normalizeTranscriptContent(transcript.getContent())).append("\n");
|
builder.append(speaker).append(": ").append(normalizeTranscriptContent(transcript.getContent())).append("\n");
|
||||||
}
|
}
|
||||||
return builder.toString();
|
return builder.toString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,833 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -614,4 +614,47 @@ class LegacyMeetingControllerTest {
|
||||||
assertEquals(75, data.getProcessingStatus().getOverallProgress());
|
assertEquals(75, data.getProcessingStatus().getOverallProgress());
|
||||||
assertEquals("summary_generation", data.getProcessingStatus().getCurrentStage());
|
assertEquals("summary_generation", data.getProcessingStatus().getCurrentStage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void previewDataShouldTreatHundredPercentProgressAsCompletedWhenSummaryAlreadyReadable() {
|
||||||
|
MeetingService meetingService = mock(MeetingService.class);
|
||||||
|
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||||
|
MeetingQueryService meetingQueryService = mock(MeetingQueryService.class);
|
||||||
|
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||||
|
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(28L);
|
||||||
|
meeting.setTitle("completed by progress");
|
||||||
|
meeting.setStatus(0);
|
||||||
|
when(meetingService.getById(28L)).thenReturn(meeting);
|
||||||
|
when(aiTaskService.getOne(any())).thenReturn((AiTask) null, (AiTask) null);
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||||
|
when(valueOperations.get("biz:meeting:progress:28")).thenReturn("{\"percent\":100}");
|
||||||
|
|
||||||
|
MeetingVO detail = new MeetingVO();
|
||||||
|
detail.setSummaryContent("done");
|
||||||
|
when(meetingQueryService.getDetail(28L)).thenReturn(detail);
|
||||||
|
|
||||||
|
LegacyMeetingController controller = new LegacyMeetingController(
|
||||||
|
mock(LegacyMeetingAdapterService.class),
|
||||||
|
meetingQueryService,
|
||||||
|
mock(MeetingAccessService.class),
|
||||||
|
mock(MeetingCommandService.class),
|
||||||
|
meetingService,
|
||||||
|
aiTaskService,
|
||||||
|
mock(PromptTemplateService.class),
|
||||||
|
transcriptMapper,
|
||||||
|
mock(SysUserMapper.class),
|
||||||
|
redisTemplate,
|
||||||
|
new ObjectMapper()
|
||||||
|
);
|
||||||
|
|
||||||
|
LegacyApiResponse<?> response = controller.previewData(28L);
|
||||||
|
|
||||||
|
assertEquals("200", response.getCode());
|
||||||
|
assertNotNull(response.getData());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.imeeting.service.biz.impl;
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.imeeting.dto.biz.MeetingSummarySource;
|
||||||
import com.imeeting.entity.biz.AiTask;
|
import com.imeeting.entity.biz.AiTask;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.MeetingTranscript;
|
import com.imeeting.entity.biz.MeetingTranscript;
|
||||||
|
|
@ -10,6 +11,7 @@ import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||||
import com.imeeting.support.TaskSecurityContextRunner;
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
@ -22,15 +24,15 @@ import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.doReturn;
|
import static org.mockito.Mockito.doReturn;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.spy;
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
|
@ -94,7 +96,8 @@ class AiTaskServiceImplTest {
|
||||||
aiModelService,
|
aiModelService,
|
||||||
redisTemplate,
|
redisTemplate,
|
||||||
new TaskSecurityContextRunner(),
|
new TaskSecurityContextRunner(),
|
||||||
mock(MeetingTranscriptFileService.class)
|
mock(MeetingTranscriptFileService.class),
|
||||||
|
mock(MeetingTranscriptRevisionService.class)
|
||||||
));
|
));
|
||||||
doReturn(true).when(service).updateById(any());
|
doReturn(true).when(service).updateById(any());
|
||||||
|
|
||||||
|
|
@ -118,7 +121,7 @@ class AiTaskServiceImplTest {
|
||||||
service.dispatchTasks(66L, 1L, 2L);
|
service.dispatchTasks(66L, 1L, 2L);
|
||||||
|
|
||||||
assertEquals(3, summaryTask.getStatus());
|
assertEquals(3, summaryTask.getStatus());
|
||||||
assertTrue(summaryTask.getErrorMsg().contains("没有可用于总结的转录内容"));
|
assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank());
|
||||||
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,9 +131,19 @@ class AiTaskServiceImplTest {
|
||||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||||
AiModelService aiModelService = mock(AiModelService.class);
|
AiModelService aiModelService = mock(AiModelService.class);
|
||||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||||
|
MeetingTranscriptRevisionService revisionService = mock(MeetingTranscriptRevisionService.class);
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||||
|
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
|
||||||
|
when(revisionService.resolveSummarySource(any(), any(), any())).thenReturn(MeetingSummarySource.builder()
|
||||||
|
.text(" ")
|
||||||
|
.sourceType("RAW_FALLBACK")
|
||||||
|
.fallbackUsed(true)
|
||||||
|
.triggerTaskType("SUMMARY")
|
||||||
|
.semanticCorrector("NONE_V1")
|
||||||
|
.ruleProfileVersion("v1")
|
||||||
|
.build());
|
||||||
|
|
||||||
AiTaskServiceImpl service = spy(createService(
|
AiTaskServiceImpl service = spy(createService(
|
||||||
meetingMapper,
|
meetingMapper,
|
||||||
|
|
@ -138,7 +151,8 @@ class AiTaskServiceImplTest {
|
||||||
aiModelService,
|
aiModelService,
|
||||||
redisTemplate,
|
redisTemplate,
|
||||||
new TaskSecurityContextRunner(),
|
new TaskSecurityContextRunner(),
|
||||||
mock(MeetingTranscriptFileService.class)
|
mock(MeetingTranscriptFileService.class),
|
||||||
|
revisionService
|
||||||
));
|
));
|
||||||
doReturn(true).when(service).updateById(any());
|
doReturn(true).when(service).updateById(any());
|
||||||
|
|
||||||
|
|
@ -146,22 +160,17 @@ class AiTaskServiceImplTest {
|
||||||
meeting.setId(77L);
|
meeting.setId(77L);
|
||||||
when(meetingMapper.selectById(77L)).thenReturn(meeting);
|
when(meetingMapper.selectById(77L)).thenReturn(meeting);
|
||||||
|
|
||||||
MeetingTranscript transcript = new MeetingTranscript();
|
|
||||||
transcript.setSpeakerId("spk-1");
|
|
||||||
transcript.setContent(" ");
|
|
||||||
when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript));
|
|
||||||
|
|
||||||
AiTask summaryTask = new AiTask();
|
AiTask summaryTask = new AiTask();
|
||||||
summaryTask.setId(100L);
|
summaryTask.setId(100L);
|
||||||
summaryTask.setMeetingId(77L);
|
summaryTask.setMeetingId(77L);
|
||||||
summaryTask.setTaskType("SUMMARY");
|
summaryTask.setTaskType("SUMMARY");
|
||||||
summaryTask.setStatus(0);
|
summaryTask.setStatus(0);
|
||||||
doReturn(summaryTask).when(service).getOne(any());
|
doReturn(summaryTask, null).when(service).getOne(any());
|
||||||
|
|
||||||
service.dispatchSummaryTask(77L, 1L, 2L);
|
service.dispatchSummaryTask(77L, 1L, 2L);
|
||||||
|
|
||||||
assertEquals(3, summaryTask.getStatus());
|
assertEquals(3, summaryTask.getStatus());
|
||||||
assertTrue(summaryTask.getErrorMsg().contains("没有可用于总结的转录内容"));
|
assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank());
|
||||||
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +185,8 @@ class AiTaskServiceImplTest {
|
||||||
mock(AiModelService.class),
|
mock(AiModelService.class),
|
||||||
mock(StringRedisTemplate.class),
|
mock(StringRedisTemplate.class),
|
||||||
mock(TaskSecurityContextRunner.class),
|
mock(TaskSecurityContextRunner.class),
|
||||||
transcriptFileService
|
transcriptFileService,
|
||||||
|
mock(MeetingTranscriptRevisionService.class)
|
||||||
);
|
);
|
||||||
|
|
||||||
Meeting meeting = new Meeting();
|
Meeting meeting = new Meeting();
|
||||||
|
|
@ -190,9 +200,9 @@ class AiTaskServiceImplTest {
|
||||||
{
|
{
|
||||||
"segments": [
|
"segments": [
|
||||||
{
|
{
|
||||||
"speaker_id": "spk-1",
|
"speaker_id": "123",
|
||||||
"speaker_name": "Alice",
|
"speaker_name": "Alice",
|
||||||
"text": "第一段转录",
|
"text": "hello world",
|
||||||
"timestamp": [0, 1200]
|
"timestamp": [0, 1200]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -211,7 +221,8 @@ class AiTaskServiceImplTest {
|
||||||
mock(AiModelService.class),
|
mock(AiModelService.class),
|
||||||
mock(StringRedisTemplate.class),
|
mock(StringRedisTemplate.class),
|
||||||
mock(TaskSecurityContextRunner.class),
|
mock(TaskSecurityContextRunner.class),
|
||||||
mock(MeetingTranscriptFileService.class)
|
mock(MeetingTranscriptFileService.class),
|
||||||
|
mock(MeetingTranscriptRevisionService.class)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,7 +231,8 @@ class AiTaskServiceImplTest {
|
||||||
AiModelService aiModelService,
|
AiModelService aiModelService,
|
||||||
StringRedisTemplate redisTemplate,
|
StringRedisTemplate redisTemplate,
|
||||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||||
MeetingTranscriptFileService meetingTranscriptFileService) {
|
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||||
|
MeetingTranscriptRevisionService revisionService) {
|
||||||
return new AiTaskServiceImpl(
|
return new AiTaskServiceImpl(
|
||||||
meetingMapper,
|
meetingMapper,
|
||||||
transcriptMapper,
|
transcriptMapper,
|
||||||
|
|
@ -231,6 +243,7 @@ class AiTaskServiceImplTest {
|
||||||
redisTemplate,
|
redisTemplate,
|
||||||
mock(MeetingSummaryFileService.class),
|
mock(MeetingSummaryFileService.class),
|
||||||
meetingTranscriptFileService,
|
meetingTranscriptFileService,
|
||||||
|
revisionService,
|
||||||
mock(MeetingSummaryPromptAssembler.class),
|
mock(MeetingSummaryPromptAssembler.class),
|
||||||
taskSecurityContextRunner
|
taskSecurityContextRunner
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.imeeting.entity.biz.HotWordGroup;
|
||||||
|
import com.imeeting.mapper.biz.HotWordMapper;
|
||||||
|
import com.imeeting.mapper.biz.PromptTemplateMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class HotWordGroupServiceImplTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pageGroupsShouldApplyNameAndStatusFilters() {
|
||||||
|
HotWordMapper hotWordMapper = mock(HotWordMapper.class);
|
||||||
|
PromptTemplateMapper promptTemplateMapper = mock(PromptTemplateMapper.class);
|
||||||
|
when(hotWordMapper.selectList(any())).thenReturn(java.util.List.of());
|
||||||
|
|
||||||
|
HotWordGroupServiceImpl service = spy(new HotWordGroupServiceImpl(hotWordMapper, promptTemplateMapper));
|
||||||
|
Page<HotWordGroup> page = new Page<>(2, 8);
|
||||||
|
page.setRecords(java.util.List.of());
|
||||||
|
page.setTotal(0);
|
||||||
|
doReturn(page).when(service).page(any(Page.class), any(LambdaQueryWrapper.class));
|
||||||
|
|
||||||
|
service.pageGroups(2, 8, "项目", 1, 9L);
|
||||||
|
|
||||||
|
ArgumentCaptor<LambdaQueryWrapper<HotWordGroup>> wrapperCaptor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||||
|
verify(service).page(any(Page.class), wrapperCaptor.capture());
|
||||||
|
String sqlSegment = wrapperCaptor.getValue().getSqlSegment().toLowerCase();
|
||||||
|
assertTrue(sqlSegment.contains("group_name"));
|
||||||
|
assertTrue(sqlSegment.contains("status"));
|
||||||
|
assertTrue(sqlSegment.contains("tenant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeGroupByIdShouldRejectWhenPromptTemplateStillReferencesGroup() {
|
||||||
|
HotWordMapper hotWordMapper = mock(HotWordMapper.class);
|
||||||
|
PromptTemplateMapper promptTemplateMapper = mock(PromptTemplateMapper.class);
|
||||||
|
when(promptTemplateMapper.selectCount(any())).thenReturn(1L);
|
||||||
|
|
||||||
|
HotWordGroup group = new HotWordGroup();
|
||||||
|
group.setId(9L);
|
||||||
|
group.setTenantId(9L);
|
||||||
|
HotWordGroupServiceImpl service = new HotWordGroupServiceImpl(hotWordMapper, promptTemplateMapper) {
|
||||||
|
@Override
|
||||||
|
public HotWordGroup getById(java.io.Serializable id) {
|
||||||
|
return Long.valueOf(9L).equals(id) ? group : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> service.removeGroupById(9L, 9L));
|
||||||
|
|
||||||
|
assertEquals("该热词组已被会议总结模板引用,无法删除", exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
|
import com.imeeting.dto.biz.HotWordDTO;
|
||||||
|
import com.imeeting.dto.biz.HotWordVO;
|
||||||
|
import com.imeeting.entity.biz.HotWord;
|
||||||
|
import com.imeeting.entity.biz.HotWordGroup;
|
||||||
|
import com.imeeting.mapper.biz.HotWordGroupMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class HotWordServiceImplTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveHotWordShouldRejectWhenGroupLimitReached() {
|
||||||
|
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
|
||||||
|
HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper));
|
||||||
|
doReturn(200L).when(service).count(any());
|
||||||
|
|
||||||
|
HotWordGroup group = new HotWordGroup();
|
||||||
|
group.setId(5L);
|
||||||
|
group.setTenantId(9L);
|
||||||
|
group.setGroupName("客户名单");
|
||||||
|
group.setStatus(1);
|
||||||
|
when(hotWordGroupMapper.selectById(5L)).thenReturn(group);
|
||||||
|
|
||||||
|
HotWordDTO dto = new HotWordDTO();
|
||||||
|
dto.setWord("阿里");
|
||||||
|
dto.setMatchStrategy(1);
|
||||||
|
dto.setWeight(2);
|
||||||
|
dto.setStatus(1);
|
||||||
|
dto.setHotWordGroupId(5L);
|
||||||
|
|
||||||
|
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> service.saveHotWord(dto, 7L, 9L));
|
||||||
|
|
||||||
|
assertEquals("热词组最多只能包含 200 个热词", exception.getMessage());
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void saveHotWordShouldGeneratePinyinWhenRequestDoesNotProvideIt() {
|
||||||
|
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
|
||||||
|
HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper));
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
HotWord entity = invocation.getArgument(0);
|
||||||
|
entity.setId(11L);
|
||||||
|
return true;
|
||||||
|
}).when(service).save(any(HotWord.class));
|
||||||
|
|
||||||
|
HotWordDTO dto = new HotWordDTO();
|
||||||
|
dto.setWord("会议");
|
||||||
|
dto.setMatchStrategy(1);
|
||||||
|
dto.setWeight(2);
|
||||||
|
dto.setStatus(1);
|
||||||
|
dto.setPinyinList(List.of());
|
||||||
|
|
||||||
|
HotWordVO result = service.saveHotWord(dto, 7L, 9L);
|
||||||
|
|
||||||
|
assertFalse(result.getPinyinList().isEmpty());
|
||||||
|
assertEquals("hui yi", result.getPinyinList().get(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
|
import com.imeeting.common.SysParamKeys;
|
||||||
|
import com.unisbase.service.SysParamService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class MeetingAudioUploadSupportTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldStoreValidatedWavInPrivateStagingDirectory() throws Exception {
|
||||||
|
MeetingAudioUploadSupport support = createSupport(null);
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "demo.wav", "audio/wav", buildWavHeader());
|
||||||
|
|
||||||
|
String token = support.storeUploadedAudio(file);
|
||||||
|
|
||||||
|
assertTrue(MeetingAudioUploadSupport.isStagingAudioToken(token));
|
||||||
|
Path storedPath = MeetingAudioUploadSupport.resolveStagingAudioPath(tempDir.resolve("uploads").toString(), token);
|
||||||
|
assertTrue(Files.exists(storedPath));
|
||||||
|
assertFalse(storedPath.startsWith(tempDir.resolve("uploads")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldStoreAacM4aInPrivateStagingDirectory() throws Exception {
|
||||||
|
MeetingAudioUploadSupport support = createSupport(null);
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "demo.m4a", "audio/mp4", buildM4a("mp4a"));
|
||||||
|
|
||||||
|
String token = support.storeUploadedAudio(file);
|
||||||
|
|
||||||
|
assertTrue(MeetingAudioUploadSupport.isStagingAudioToken(token));
|
||||||
|
Path storedPath = MeetingAudioUploadSupport.resolveStagingAudioPath(tempDir.resolve("uploads").toString(), token);
|
||||||
|
assertTrue(Files.exists(storedPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectM4aWithBrowserIncompatibleCodec() {
|
||||||
|
MeetingAudioUploadSupport support = createSupport(null);
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "demo.m4a", "audio/mp4", buildM4a("samr"));
|
||||||
|
|
||||||
|
RuntimeException ex = assertThrows(RuntimeException.class, () -> support.storeUploadedAudio(file));
|
||||||
|
assertTrue(ex.getMessage().contains("AAC"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectUnsupportedExtension() {
|
||||||
|
MeetingAudioUploadSupport support = createSupport(null);
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file",
|
||||||
|
"demo.html",
|
||||||
|
"text/html",
|
||||||
|
"<html>alert(1)</html>".getBytes(StandardCharsets.UTF_8)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(RuntimeException.class, () -> support.storeUploadedAudio(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectFakeMp3Payload() {
|
||||||
|
MeetingAudioUploadSupport support = createSupport(null);
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file",
|
||||||
|
"fake.mp3",
|
||||||
|
"audio/mpeg",
|
||||||
|
"<script>alert(1)</script>".getBytes(StandardCharsets.UTF_8)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(RuntimeException.class, () -> support.storeUploadedAudio(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectAudioLargerThanConfiguredSystemParamLimit() {
|
||||||
|
MeetingAudioUploadSupport support = createSupport("1");
|
||||||
|
byte[] payload = new byte[2 * 1024 * 1024];
|
||||||
|
byte[] header = buildWavHeader();
|
||||||
|
System.arraycopy(header, 0, payload, 0, header.length);
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "large.wav", "audio/wav", payload);
|
||||||
|
|
||||||
|
assertThrows(RuntimeException.class, () -> support.storeUploadedAudio(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingAudioUploadSupport createSupport(String maxSizeMb) {
|
||||||
|
SysParamService sysParamService = mock(SysParamService.class);
|
||||||
|
when(sysParamService.getCachedParamValue(
|
||||||
|
eq(SysParamKeys.MEETING_OFFLINE_AUDIO_MAX_SIZE_MB),
|
||||||
|
eq("1024")
|
||||||
|
)).thenReturn(maxSizeMb);
|
||||||
|
MeetingAudioUploadSupport support = new MeetingAudioUploadSupport(sysParamService);
|
||||||
|
ReflectionTestUtils.setField(support, "uploadPath", tempDir.resolve("uploads").toString());
|
||||||
|
return support;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildWavHeader() {
|
||||||
|
byte[] header = new byte[16];
|
||||||
|
System.arraycopy("RIFF".getBytes(StandardCharsets.US_ASCII), 0, header, 0, 4);
|
||||||
|
System.arraycopy("WAVE".getBytes(StandardCharsets.US_ASCII), 0, header, 8, 4);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildM4a(String sampleEntryType) {
|
||||||
|
byte[] sampleEntry = atom(sampleEntryType, new byte[8]);
|
||||||
|
byte[] stsdPayload = concat(new byte[4], intBytes(1), sampleEntry);
|
||||||
|
byte[] stsd = atom("stsd", stsdPayload);
|
||||||
|
byte[] stbl = atom("stbl", stsd);
|
||||||
|
byte[] minf = atom("minf", stbl);
|
||||||
|
byte[] mdia = atom("mdia", minf);
|
||||||
|
byte[] trak = atom("trak", mdia);
|
||||||
|
byte[] moov = atom("moov", trak);
|
||||||
|
byte[] ftyp = atom("ftyp", concat("M4A ".getBytes(StandardCharsets.US_ASCII), new byte[8]));
|
||||||
|
return concat(ftyp, moov);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] atom(String type, byte[] payload) {
|
||||||
|
return concat(intBytes(payload.length + 8), type.getBytes(StandardCharsets.US_ASCII), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] intBytes(int value) {
|
||||||
|
return new byte[]{
|
||||||
|
(byte) ((value >> 24) & 0xFF),
|
||||||
|
(byte) ((value >> 16) & 0xFF),
|
||||||
|
(byte) ((value >> 8) & 0xFF),
|
||||||
|
(byte) (value & 0xFF)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] concat(byte[]... parts) {
|
||||||
|
int totalLength = Arrays.stream(parts).mapToInt(part -> part.length).sum();
|
||||||
|
byte[] result = new byte[totalLength];
|
||||||
|
int offset = 0;
|
||||||
|
for (byte[] part : parts) {
|
||||||
|
System.arraycopy(part, 0, result, offset, part.length);
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.FileTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class MeetingPlaybackAudioResolverTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldGenerateBrowserPlaybackWavFor16000Source() throws Exception {
|
||||||
|
MeetingPlaybackAudioResolver resolver = newResolver();
|
||||||
|
Path sourcePath = tempDir.resolve("uploads/meetings/101/source_audio.wav");
|
||||||
|
byte[] sourceFrames = new byte[]{1, 2, 3, 4};
|
||||||
|
Files.createDirectories(sourcePath.getParent());
|
||||||
|
Files.write(sourcePath, buildWav(16_000, sourceFrames));
|
||||||
|
|
||||||
|
String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/101/source_audio.wav");
|
||||||
|
|
||||||
|
assertEquals("/api/static/meetings/101/source_audio_browser_48000.wav", playbackUrl);
|
||||||
|
Path convertedPath = tempDir.resolve("uploads/meetings/101/source_audio_browser_48000.wav");
|
||||||
|
assertTrue(Files.exists(convertedPath));
|
||||||
|
|
||||||
|
byte[] converted = Files.readAllBytes(convertedPath);
|
||||||
|
assertEquals(48_000, readIntLe(converted, 24));
|
||||||
|
assertEquals(12, readIntLe(converted, 40));
|
||||||
|
assertArrayEquals(
|
||||||
|
new byte[]{1, 2, 1, 2, 1, 2, 3, 4, 3, 4, 3, 4},
|
||||||
|
Arrays.copyOfRange(converted, 44, 56)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReuseExistingBrowserPlaybackWavFile() throws Exception {
|
||||||
|
MeetingPlaybackAudioResolver resolver = newResolver();
|
||||||
|
Path meetingDir = tempDir.resolve("uploads/meetings/102");
|
||||||
|
Path sourcePath = meetingDir.resolve("source_audio.wav");
|
||||||
|
Path convertedPath = meetingDir.resolve("source_audio_browser_48000.wav");
|
||||||
|
Files.createDirectories(meetingDir);
|
||||||
|
Files.write(sourcePath, buildWav(16_000, new byte[]{10, 20, 30, 40}));
|
||||||
|
Files.write(convertedPath, buildWav(48_000, new byte[]{9, 9, 8, 8, 7, 7}));
|
||||||
|
Files.setLastModifiedTime(sourcePath, FileTime.fromMillis(1_000));
|
||||||
|
Files.setLastModifiedTime(convertedPath, FileTime.fromMillis(2_000));
|
||||||
|
|
||||||
|
String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/102/source_audio.wav");
|
||||||
|
|
||||||
|
assertEquals("/api/static/meetings/102/source_audio_browser_48000.wav", playbackUrl);
|
||||||
|
byte[] converted = Files.readAllBytes(convertedPath);
|
||||||
|
assertEquals(48_000, readIntLe(converted, 24));
|
||||||
|
assertArrayEquals(new byte[]{9, 9, 8, 8, 7, 7}, Arrays.copyOfRange(converted, 44, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldKeep44100WavSourceForBrowserPlayback() throws Exception {
|
||||||
|
MeetingPlaybackAudioResolver resolver = newResolver();
|
||||||
|
Path sourcePath = tempDir.resolve("uploads/meetings/103/source_audio.wav");
|
||||||
|
Files.createDirectories(sourcePath.getParent());
|
||||||
|
Files.write(sourcePath, buildWav(44_100, new byte[]{1, 1, 2, 2}));
|
||||||
|
|
||||||
|
String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/103/source_audio.wav");
|
||||||
|
|
||||||
|
assertEquals("/api/static/meetings/103/source_audio.wav", playbackUrl);
|
||||||
|
assertFalse(Files.exists(tempDir.resolve("uploads/meetings/103/source_audio_browser_48000.wav")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldKeep44100M4aSourceForBrowserPlayback() throws Exception {
|
||||||
|
MeetingPlaybackAudioResolver resolver = newResolver();
|
||||||
|
Path sourcePath = tempDir.resolve("uploads/meetings/104/source_audio.m4a");
|
||||||
|
Files.createDirectories(sourcePath.getParent());
|
||||||
|
Files.write(sourcePath, buildM4a("mp4a", 44_100));
|
||||||
|
|
||||||
|
String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/104/source_audio.m4a");
|
||||||
|
|
||||||
|
assertEquals("/api/static/meetings/104/source_audio.m4a", playbackUrl);
|
||||||
|
assertFalse(Files.exists(tempDir.resolve("uploads/meetings/104/source_audio_browser_48000.m4a")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReuseExistingBrowserPlaybackM4aFile() throws Exception {
|
||||||
|
MeetingPlaybackAudioResolver resolver = newResolver();
|
||||||
|
Path meetingDir = tempDir.resolve("uploads/meetings/105");
|
||||||
|
Path sourcePath = meetingDir.resolve("source_audio.m4a");
|
||||||
|
Path convertedPath = meetingDir.resolve("source_audio_browser_48000.m4a");
|
||||||
|
Files.createDirectories(meetingDir);
|
||||||
|
Files.write(sourcePath, buildM4a("mp4a", 16_000));
|
||||||
|
Files.write(convertedPath, buildM4a("mp4a", 48_000));
|
||||||
|
Files.setLastModifiedTime(sourcePath, FileTime.fromMillis(1_000));
|
||||||
|
Files.setLastModifiedTime(convertedPath, FileTime.fromMillis(2_000));
|
||||||
|
|
||||||
|
String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/105/source_audio.m4a");
|
||||||
|
|
||||||
|
assertEquals("/api/static/meetings/105/source_audio_browser_48000.m4a", playbackUrl);
|
||||||
|
assertTrue(Files.exists(convertedPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFallbackWhenM4aNeedsConversionButFfmpegIsUnavailable() throws Exception {
|
||||||
|
MeetingPlaybackAudioResolver resolver = newResolver();
|
||||||
|
Path sourcePath = tempDir.resolve("uploads/meetings/106/source_audio.m4a");
|
||||||
|
Files.createDirectories(sourcePath.getParent());
|
||||||
|
Files.write(sourcePath, buildM4a("mp4a", 16_000));
|
||||||
|
|
||||||
|
String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/106/source_audio.m4a");
|
||||||
|
|
||||||
|
assertEquals("/api/static/meetings/106/source_audio.m4a", playbackUrl);
|
||||||
|
assertFalse(Files.exists(tempDir.resolve("uploads/meetings/106/source_audio_browser_48000.m4a")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldKeepOriginalExtensionForTemporaryM4aOutput() {
|
||||||
|
MeetingPlaybackAudioResolver resolver = newResolver();
|
||||||
|
Path targetPath = tempDir.resolve("uploads/meetings/107/source_audio_browser_48000.m4a");
|
||||||
|
|
||||||
|
Path tempPath = ReflectionTestUtils.invokeMethod(resolver, "buildTemporaryOutputPath", targetPath);
|
||||||
|
|
||||||
|
assertEquals("source_audio_browser_48000.tmp.m4a", tempPath.getFileName().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingPlaybackAudioResolver newResolver() {
|
||||||
|
MeetingPlaybackAudioResolver resolver = new MeetingPlaybackAudioResolver();
|
||||||
|
ReflectionTestUtils.setField(resolver, "uploadPath", tempDir.resolve("uploads").toString());
|
||||||
|
ReflectionTestUtils.setField(resolver, "resourcePrefix", "/api/static/");
|
||||||
|
ReflectionTestUtils.setField(resolver, "ffmpegPath", "ffmpeg");
|
||||||
|
return resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildWav(int sampleRate, byte[] data) throws IOException {
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
int channels = 1;
|
||||||
|
int bitsPerSample = 16;
|
||||||
|
int blockAlign = channels * bitsPerSample / 8;
|
||||||
|
long byteRate = (long) sampleRate * blockAlign;
|
||||||
|
|
||||||
|
output.write(new byte[]{'R', 'I', 'F', 'F'});
|
||||||
|
writeIntLe(output, 36 + data.length);
|
||||||
|
output.write(new byte[]{'W', 'A', 'V', 'E'});
|
||||||
|
output.write(new byte[]{'f', 'm', 't', ' '});
|
||||||
|
writeIntLe(output, 16);
|
||||||
|
writeShortLe(output, 1);
|
||||||
|
writeShortLe(output, channels);
|
||||||
|
writeIntLe(output, sampleRate);
|
||||||
|
writeIntLe(output, byteRate);
|
||||||
|
writeShortLe(output, blockAlign);
|
||||||
|
writeShortLe(output, bitsPerSample);
|
||||||
|
output.write(new byte[]{'d', 'a', 't', 'a'});
|
||||||
|
writeIntLe(output, data.length);
|
||||||
|
output.write(data);
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildM4a(String sampleEntryType, int sampleRate) {
|
||||||
|
byte[] sampleEntryPayload = new byte[28];
|
||||||
|
int fixedPointSampleRate = sampleRate << 16;
|
||||||
|
sampleEntryPayload[24] = (byte) ((fixedPointSampleRate >> 24) & 0xFF);
|
||||||
|
sampleEntryPayload[25] = (byte) ((fixedPointSampleRate >> 16) & 0xFF);
|
||||||
|
sampleEntryPayload[26] = (byte) ((fixedPointSampleRate >> 8) & 0xFF);
|
||||||
|
sampleEntryPayload[27] = (byte) (fixedPointSampleRate & 0xFF);
|
||||||
|
|
||||||
|
byte[] sampleEntry = atom(sampleEntryType, sampleEntryPayload);
|
||||||
|
byte[] stsdPayload = concat(new byte[4], intBytes(1), sampleEntry);
|
||||||
|
byte[] stsd = atom("stsd", stsdPayload);
|
||||||
|
byte[] stbl = atom("stbl", stsd);
|
||||||
|
byte[] minf = atom("minf", stbl);
|
||||||
|
byte[] mdia = atom("mdia", minf);
|
||||||
|
byte[] trak = atom("trak", mdia);
|
||||||
|
byte[] moov = atom("moov", trak);
|
||||||
|
byte[] ftyp = atom("ftyp", concat("M4A ".getBytes(StandardCharsets.US_ASCII), new byte[8]));
|
||||||
|
return concat(ftyp, moov);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] atom(String type, byte[] payload) {
|
||||||
|
return concat(intBytes(payload.length + 8), type.getBytes(StandardCharsets.US_ASCII), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] intBytes(int value) {
|
||||||
|
return new byte[]{
|
||||||
|
(byte) ((value >> 24) & 0xFF),
|
||||||
|
(byte) ((value >> 16) & 0xFF),
|
||||||
|
(byte) ((value >> 8) & 0xFF),
|
||||||
|
(byte) (value & 0xFF)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] concat(byte[]... parts) {
|
||||||
|
int totalLength = Arrays.stream(parts).mapToInt(part -> part.length).sum();
|
||||||
|
byte[] result = new byte[totalLength];
|
||||||
|
int offset = 0;
|
||||||
|
for (byte[] part : parts) {
|
||||||
|
System.arraycopy(part, 0, result, offset, part.length);
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeShortLe(ByteArrayOutputStream output, int value) {
|
||||||
|
output.write(value & 0xff);
|
||||||
|
output.write((value >> 8) & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeIntLe(ByteArrayOutputStream output, long value) {
|
||||||
|
output.write((int) (value & 0xff));
|
||||||
|
output.write((int) ((value >> 8) & 0xff));
|
||||||
|
output.write((int) ((value >> 16) & 0xff));
|
||||||
|
output.write((int) ((value >> 24) & 0xff));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int readIntLe(byte[] bytes, int offset) {
|
||||||
|
return (bytes[offset] & 0xff)
|
||||||
|
| ((bytes[offset + 1] & 0xff) << 8)
|
||||||
|
| ((bytes[offset + 2] & 0xff) << 16)
|
||||||
|
| ((bytes[offset + 3] & 0xff) << 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptExportResult;
|
||||||
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
|
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 org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class MeetingTranscriptFileServiceImplTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void initializeTranscriptFileIfAbsentShouldCreateMarkdownFile() throws Exception {
|
||||||
|
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||||
|
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||||
|
MeetingTranscriptFileServiceImpl service = newService(meetingMapper, transcriptMapper);
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(1001L);
|
||||||
|
meeting.setTitle("Weekly Sync");
|
||||||
|
meeting.setHostName("Alice");
|
||||||
|
meeting.setMeetingTime(LocalDateTime.of(2026, 4, 27, 10, 0, 0));
|
||||||
|
|
||||||
|
MeetingTranscript transcript = new MeetingTranscript();
|
||||||
|
transcript.setSpeakerName("Bob");
|
||||||
|
transcript.setContent("Confirm this week's release plan");
|
||||||
|
transcript.setStartTime(0);
|
||||||
|
transcript.setEndTime(5000);
|
||||||
|
|
||||||
|
when(meetingMapper.selectById(1001L)).thenReturn(meeting);
|
||||||
|
when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript));
|
||||||
|
|
||||||
|
service.initializeTranscriptFileIfAbsent(1001L);
|
||||||
|
|
||||||
|
Path transcriptPath = tempDir.resolve("uploads/meetings/1001/transcripts/current.md");
|
||||||
|
assertTrue(Files.exists(transcriptPath));
|
||||||
|
String markdown = Files.readString(transcriptPath, StandardCharsets.UTF_8);
|
||||||
|
assertTrue(markdown.contains("# Weekly Sync Transcript"));
|
||||||
|
assertTrue(markdown.contains("Bob: Confirm this week's release plan"));
|
||||||
|
verify(meetingMapper, times(1)).selectById(1001L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportTranscriptShouldRewriteFileWithLatestTranscriptContent() throws Exception {
|
||||||
|
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||||
|
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||||
|
MeetingTranscriptFileServiceImpl service = newService(meetingMapper, transcriptMapper);
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(1002L);
|
||||||
|
meeting.setTitle("Architecture Review");
|
||||||
|
meeting.setHostName("Carol");
|
||||||
|
meeting.setMeetingTime(LocalDateTime.of(2026, 4, 27, 14, 30, 0));
|
||||||
|
|
||||||
|
MeetingVO meetingVO = new MeetingVO();
|
||||||
|
meetingVO.setTitle("Architecture Review");
|
||||||
|
meetingVO.setHostName("Carol");
|
||||||
|
meetingVO.setMeetingTime(LocalDateTime.of(2026, 4, 27, 14, 30, 0));
|
||||||
|
|
||||||
|
MeetingTranscript transcript = new MeetingTranscript();
|
||||||
|
transcript.setSpeakerName("Dave");
|
||||||
|
transcript.setContent("Ship download support first");
|
||||||
|
transcript.setStartTime(1000);
|
||||||
|
transcript.setEndTime(4000);
|
||||||
|
|
||||||
|
when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript));
|
||||||
|
|
||||||
|
Path transcriptPath = tempDir.resolve("uploads/meetings/1002/transcripts/current.md");
|
||||||
|
Files.createDirectories(transcriptPath.getParent());
|
||||||
|
Files.writeString(transcriptPath, "old content", StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
MeetingTranscriptExportResult result = service.exportTranscript(meeting, meetingVO);
|
||||||
|
|
||||||
|
String markdown = Files.readString(transcriptPath, StandardCharsets.UTF_8);
|
||||||
|
assertTrue(markdown.contains("Dave: Ship download support first"));
|
||||||
|
assertEquals("text/markdown; charset=UTF-8", result.getContentType());
|
||||||
|
assertEquals("Architecture Review-Transcript.md", result.getFileName());
|
||||||
|
assertTrue(new String(result.getContent(), StandardCharsets.UTF_8).contains("Ship download support first"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingTranscriptFileServiceImpl newService(MeetingMapper meetingMapper, MeetingTranscriptMapper transcriptMapper) {
|
||||||
|
MeetingTranscriptFileServiceImpl service = new MeetingTranscriptFileServiceImpl(meetingMapper, transcriptMapper);
|
||||||
|
ReflectionTestUtils.setField(service, "uploadPath", tempDir.resolve("uploads").toString());
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.imeeting.dto.biz.PromptTemplateDTO;
|
||||||
|
import com.imeeting.dto.biz.PromptTemplateVO;
|
||||||
|
import com.imeeting.entity.biz.HotWord;
|
||||||
|
import com.imeeting.entity.biz.HotWordGroup;
|
||||||
|
import com.imeeting.entity.biz.PromptTemplate;
|
||||||
|
import com.imeeting.mapper.biz.HotWordGroupMapper;
|
||||||
|
import com.imeeting.mapper.biz.PromptTemplateUserConfigMapper;
|
||||||
|
import com.imeeting.service.biz.HotWordService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class PromptTemplateServiceImplTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveTemplateShouldRejectPlatformTemplateBindingTenantGroup() {
|
||||||
|
PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class);
|
||||||
|
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
|
||||||
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
|
PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService));
|
||||||
|
|
||||||
|
HotWordGroup tenantGroup = new HotWordGroup();
|
||||||
|
tenantGroup.setId(11L);
|
||||||
|
tenantGroup.setTenantId(9L);
|
||||||
|
tenantGroup.setGroupName("租户组");
|
||||||
|
tenantGroup.setStatus(1);
|
||||||
|
when(hotWordGroupMapper.selectById(11L)).thenReturn(tenantGroup);
|
||||||
|
|
||||||
|
PromptTemplateDTO dto = new PromptTemplateDTO();
|
||||||
|
dto.setTenantId(0L);
|
||||||
|
dto.setTemplateName("平台模板");
|
||||||
|
dto.setCategory("default");
|
||||||
|
dto.setIsSystem(1);
|
||||||
|
dto.setPromptContent("content");
|
||||||
|
dto.setStatus(1);
|
||||||
|
dto.setHotWordGroupId(11L);
|
||||||
|
|
||||||
|
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> service.saveTemplate(dto, 7L, 9L));
|
||||||
|
|
||||||
|
assertEquals("平台级模板只能绑定平台级热词组", exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveTemplateShouldReturnBoundGroupInfo() {
|
||||||
|
PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class);
|
||||||
|
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
|
||||||
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
|
PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService));
|
||||||
|
doReturn(true).when(service).save(any(PromptTemplate.class));
|
||||||
|
|
||||||
|
HotWordGroup group = new HotWordGroup();
|
||||||
|
group.setId(11L);
|
||||||
|
group.setTenantId(9L);
|
||||||
|
group.setGroupName("项目术语");
|
||||||
|
group.setStatus(1);
|
||||||
|
when(hotWordGroupMapper.selectById(11L)).thenReturn(group);
|
||||||
|
when(hotWordGroupMapper.selectByIdsIgnoreTenant(any())).thenReturn(java.util.List.of(group));
|
||||||
|
|
||||||
|
PromptTemplateDTO dto = new PromptTemplateDTO();
|
||||||
|
dto.setTemplateName("租户模板");
|
||||||
|
dto.setCategory("default");
|
||||||
|
dto.setIsSystem(0);
|
||||||
|
dto.setPromptContent("content");
|
||||||
|
dto.setStatus(1);
|
||||||
|
dto.setHotWordGroupId(11L);
|
||||||
|
|
||||||
|
PromptTemplateVO result = service.saveTemplate(dto, 7L, 9L);
|
||||||
|
|
||||||
|
assertEquals(11L, result.getHotWordGroupId());
|
||||||
|
assertEquals("项目术语", result.getHotWordGroupName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveTemplateShouldAllowPlatformTemplateBindingPlatformGroup() {
|
||||||
|
PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class);
|
||||||
|
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
|
||||||
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
|
PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService));
|
||||||
|
doReturn(true).when(service).save(any(PromptTemplate.class));
|
||||||
|
|
||||||
|
HotWordGroup group = new HotWordGroup();
|
||||||
|
group.setId(11L);
|
||||||
|
group.setTenantId(0L);
|
||||||
|
group.setGroupName("平台术语");
|
||||||
|
group.setStatus(1);
|
||||||
|
when(hotWordGroupMapper.selectById(11L)).thenReturn(group);
|
||||||
|
when(hotWordGroupMapper.selectByIdsIgnoreTenant(any())).thenReturn(java.util.List.of(group));
|
||||||
|
|
||||||
|
PromptTemplateDTO dto = new PromptTemplateDTO();
|
||||||
|
dto.setTenantId(0L);
|
||||||
|
dto.setTemplateName("平台模板");
|
||||||
|
dto.setCategory("default");
|
||||||
|
dto.setIsSystem(1);
|
||||||
|
dto.setPromptContent("content");
|
||||||
|
dto.setStatus(1);
|
||||||
|
dto.setHotWordGroupId(11L);
|
||||||
|
|
||||||
|
PromptTemplateVO result = service.saveTemplate(dto, 7L, 9L);
|
||||||
|
|
||||||
|
assertEquals(11L, result.getHotWordGroupId());
|
||||||
|
assertEquals("平台术语", result.getHotWordGroupName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pageTemplatesShouldHandleTemplateWithoutBoundGroup() {
|
||||||
|
PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class);
|
||||||
|
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
|
||||||
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
|
PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService));
|
||||||
|
|
||||||
|
PromptTemplate template = new PromptTemplate();
|
||||||
|
template.setId(21L);
|
||||||
|
template.setTenantId(9L);
|
||||||
|
template.setCreatorId(7L);
|
||||||
|
template.setTemplateName("未绑定模板");
|
||||||
|
template.setCategory("default");
|
||||||
|
template.setIsSystem(0);
|
||||||
|
template.setPromptContent("content");
|
||||||
|
template.setStatus(1);
|
||||||
|
template.setHotWordGroupId(null);
|
||||||
|
|
||||||
|
Page<PromptTemplate> page = new Page<>(1, 10);
|
||||||
|
page.setRecords(List.of(template));
|
||||||
|
page.setTotal(1);
|
||||||
|
|
||||||
|
doReturn(page).when(service).page(any(Page.class), any(LambdaQueryWrapper.class));
|
||||||
|
when(userConfigMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
PromptTemplateVO result = service.pageTemplates(1, 10, null, null, 9L, 7L, false, false)
|
||||||
|
.getRecords()
|
||||||
|
.get(0);
|
||||||
|
|
||||||
|
assertEquals(21L, result.getId());
|
||||||
|
assertNull(result.getHotWordGroupId());
|
||||||
|
assertNull(result.getHotWordGroupName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getTemplateDetailShouldReturnBoundHotWords() {
|
||||||
|
PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class);
|
||||||
|
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
|
||||||
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
|
PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService));
|
||||||
|
|
||||||
|
PromptTemplate template = new PromptTemplate();
|
||||||
|
template.setId(31L);
|
||||||
|
template.setTenantId(0L);
|
||||||
|
template.setCreatorId(1L);
|
||||||
|
template.setTemplateName("平台模板");
|
||||||
|
template.setCategory("default");
|
||||||
|
template.setIsSystem(1);
|
||||||
|
template.setPromptContent("content");
|
||||||
|
template.setStatus(1);
|
||||||
|
template.setHotWordGroupId(11L);
|
||||||
|
|
||||||
|
HotWordGroup group = new HotWordGroup();
|
||||||
|
group.setId(11L);
|
||||||
|
group.setTenantId(0L);
|
||||||
|
group.setGroupName("平台热词组");
|
||||||
|
|
||||||
|
HotWord word1 = new HotWord();
|
||||||
|
word1.setWord("OpenAI");
|
||||||
|
HotWord word2 = new HotWord();
|
||||||
|
word2.setWord("Codex");
|
||||||
|
|
||||||
|
doReturn(template).when(service).getOne(any(LambdaQueryWrapper.class));
|
||||||
|
when(hotWordGroupMapper.selectByIdsIgnoreTenant(any())).thenReturn(List.of(group));
|
||||||
|
when(hotWordService.listEnabledByGroupIdIgnoreTenant(11L)).thenReturn(List.of(word1, word2));
|
||||||
|
|
||||||
|
PromptTemplateVO result = service.getTemplateDetail(31L, 9L, 7L, false, false);
|
||||||
|
|
||||||
|
assertEquals("平台热词组", result.getHotWordGroupName());
|
||||||
|
assertEquals(List.of("OpenAI", "Codex"), result.getHotWords());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getTemplateDetailShouldHandleTemplateWithoutBoundGroup() {
|
||||||
|
PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class);
|
||||||
|
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
|
||||||
|
HotWordService hotWordService = mock(HotWordService.class);
|
||||||
|
PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService));
|
||||||
|
|
||||||
|
PromptTemplate template = new PromptTemplate();
|
||||||
|
template.setId(32L);
|
||||||
|
template.setTenantId(9L);
|
||||||
|
template.setCreatorId(7L);
|
||||||
|
template.setTemplateName("detail-without-group");
|
||||||
|
template.setCategory("default");
|
||||||
|
template.setIsSystem(0);
|
||||||
|
template.setPromptContent("content");
|
||||||
|
template.setStatus(1);
|
||||||
|
template.setHotWordGroupId(null);
|
||||||
|
|
||||||
|
doReturn(template).when(service).getOne(any(LambdaQueryWrapper.class));
|
||||||
|
|
||||||
|
PromptTemplateVO result = service.getTemplateDetail(32L, 9L, 7L, false, false);
|
||||||
|
|
||||||
|
assertEquals(32L, result.getId());
|
||||||
|
assertNull(result.getHotWordGroupId());
|
||||||
|
assertNull(result.getHotWordGroupName());
|
||||||
|
assertEquals(List.of(), result.getHotWords());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue