feat: 添加会议转录文件服务
- 新增 `MeetingTranscriptFileServiceImpl` 实现会议转录文件的初始化和导出功能 - 定义 `MeetingTranscriptExportResult` 数据传输对象,用于封装导出结果 - 定义 `MeetingTranscriptFileService` 接口,提供初始化和导出会议转录文件的方法dev_na
parent
a8b93a46f8
commit
4904526e09
|
|
@ -43,6 +43,10 @@ public final class RedisKeys {
|
|||
return "biz:meeting:polling:lock:" + meetingId;
|
||||
}
|
||||
|
||||
public static String meetingSummaryLockKey(Long meetingId) {
|
||||
return "biz:meeting:summary:lock:" + meetingId;
|
||||
}
|
||||
|
||||
public static String realtimeMeetingSocketSessionKey(String 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 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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,6 +251,20 @@ public class AndroidMeetingController {
|
|||
|
||||
Integer realtimeProgress = resolveRealtimeProgress(meetingId);
|
||||
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) {
|
||||
return new LegacyMeetingPreviewResult(
|
||||
"400",
|
||||
|
|
@ -258,7 +272,7 @@ public class AndroidMeetingController {
|
|||
buildProcessingPreview(meeting, summaryTask, processingStatus("正在转写音频", 50, STAGE_AUDIO_TRANSCRIPTION))
|
||||
);
|
||||
}
|
||||
if (realtimeProgress == 90) {
|
||||
if (realtimeProgress >= 90) {
|
||||
return new LegacyMeetingPreviewResult(
|
||||
"400",
|
||||
"会议正在处理中",
|
||||
|
|
|
|||
|
|
@ -237,6 +237,9 @@ public class LegacyMeetingController {
|
|||
: null;
|
||||
boolean hasSummary = detail != null && detail.getSummaryContent() != null && !detail.getSummaryContent().isBlank();
|
||||
|
||||
if (hasSummary) {
|
||||
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask));
|
||||
}
|
||||
if (summaryCompleted) {
|
||||
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask));
|
||||
}
|
||||
|
|
@ -264,17 +267,31 @@ public class LegacyMeetingController {
|
|||
|
||||
Integer realtimeProgress = resolveRealtimeProgress(meetingId);
|
||||
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) {
|
||||
return new LegacyMeetingPreviewResult(
|
||||
"400",
|
||||
"浼氳姝e湪澶勭悊涓?",
|
||||
"会议正在处理中",
|
||||
buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪杞瘧闊抽", 50, STAGE_AUDIO_TRANSCRIPTION))
|
||||
);
|
||||
}
|
||||
if (realtimeProgress == 90) {
|
||||
if (realtimeProgress >= 90) {
|
||||
return new LegacyMeetingPreviewResult(
|
||||
"400",
|
||||
"浼氳姝e湪澶勭悊涓?",
|
||||
"会议正在处理中",
|
||||
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 锁和进度缓存,确保恢复线程能拿到控制权
|
||||
redisTemplate.delete(RedisKeys.meetingPollingLockKey(meeting.getId()));
|
||||
redisTemplate.delete(RedisKeys.meetingSummaryLockKey(meeting.getId()));
|
||||
|
||||
// 3. 根据状态重新派发任务 (平滑拉起)
|
||||
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.dto.biz.AiModelVO;
|
||||
import com.imeeting.dto.biz.MeetingSummarySource;
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.HotWord;
|
||||
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.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||
|
||||
import com.unisbase.entity.SysUser;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
|
|
@ -60,6 +62,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
private final StringRedisTemplate redisTemplate;
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
||||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||
|
||||
|
|
@ -99,7 +102,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
.last("limit 1"));
|
||||
|
||||
String asrText = "";
|
||||
if (asrTask != null && asrTask.getStatus() == 0) {
|
||||
if (asrTask != null && canExecuteTask(asrTask)) {
|
||||
asrText = processAsrTask(meeting, asrTask);
|
||||
} else {
|
||||
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
|
|
@ -129,13 +132,11 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0);
|
||||
return;
|
||||
}
|
||||
if (sumTask != null && sumTask.getStatus() == 0) {
|
||||
processSummaryTask(meeting, asrText, sumTask);
|
||||
if (sumTask != null && canExecuteTask(sumTask)) {
|
||||
executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask));
|
||||
} else if (meeting.getStatus() != 3) {
|
||||
updateMeetingStatus(meetingId, 3);
|
||||
}
|
||||
|
||||
redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId));
|
||||
} catch (Exception e) {
|
||||
log.error("Meeting {} AI Task Flow failed", meetingId, e);
|
||||
failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务:" + e.getMessage());
|
||||
|
|
@ -154,34 +155,19 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
private void doDispatchSummaryTask(Long meetingId) {
|
||||
Meeting meeting = meetingMapper.selectById(meetingId);
|
||||
if (meeting == null) return;
|
||||
AiTask sumTask = this.getOne(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getMeetingId, meetingId)
|
||||
.eq(AiTask::getTaskType, "SUMMARY")
|
||||
.orderByDesc(AiTask::getId)
|
||||
.last("limit 1"));
|
||||
if (meeting == null) {
|
||||
return;
|
||||
}
|
||||
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
|
||||
AiTask asrTask = findLatestTask(meetingId, "ASR");
|
||||
try {
|
||||
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, meetingId)
|
||||
.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);
|
||||
if (sumTask != null && canExecuteTask(sumTask)) {
|
||||
executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Re-summary failed for meeting {}", meetingId, e);
|
||||
updateMeetingStatus(meetingId, 4);
|
||||
updateProgress(meetingId, -1, "总结失败: " + e.getMessage(), 0);
|
||||
updateProgress(meetingId, -1, "Summary flow failed: " + e.getMessage(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,8 +183,11 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
if (asrModel == null) throw new RuntimeException("ASR模型配置不存在");
|
||||
|
||||
String submitUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions");
|
||||
String taskId = null;
|
||||
String taskId = taskRecord.getResponseData() != null
|
||||
? String.valueOf(taskRecord.getResponseData().getOrDefault("task_id", ""))
|
||||
: "";
|
||||
|
||||
if (taskId == null || taskId.isBlank()) {
|
||||
updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0);
|
||||
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
|
||||
taskRecord.setRequestData(req);
|
||||
|
|
@ -207,12 +196,16 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||
if (submitNode.path("code").asInt() != 0) {
|
||||
updateAiTaskFail(taskRecord, "提交失败:" + respBody);
|
||||
updateAiTaskFail(taskRecord, "提交失败: " + respBody);
|
||||
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);
|
||||
}
|
||||
this.updateById(taskRecord);
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
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);
|
||||
updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0);
|
||||
|
||||
taskRecord.setStatus(1);
|
||||
taskRecord.setStartedAt(LocalDateTime.now());
|
||||
Map<String, Object> initialResponseData = new HashMap<>();
|
||||
initialResponseData.put("summarySource", summarySource.toSnapshot());
|
||||
taskRecord.setResponseData(initialResponseData);
|
||||
this.updateById(taskRecord);
|
||||
|
||||
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);
|
||||
Map<String, Object> responseData = objectMapper.convertValue(respNode, Map.class);
|
||||
responseData.put("summarySource", summarySource.toSnapshot());
|
||||
if (summaryBundle != null) {
|
||||
responseData.put("summaryBundle", summaryBundle);
|
||||
}
|
||||
|
|
@ -572,11 +570,62 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
updateProgress(meeting.getId(), 100, "全流程分析完成", 0);
|
||||
} else {
|
||||
updateAiTaskFail(taskRecord, "LLM 总结失败:" + response.body());
|
||||
updateAiTaskFail(taskRecord, "LLM 总结失败: " + response.body());
|
||||
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) {
|
||||
try {
|
||||
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.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
|
@ -55,6 +56,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
||||
private final MeetingDomainSupport meetingDomainSupport;
|
||||
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
|
|
@ -436,6 +438,17 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
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>()
|
||||
.eq(MeetingTranscript::getMeetingId, command.getMeetingId())
|
||||
.eq(MeetingTranscript::getId, command.getTranscriptId())
|
||||
|
|
@ -443,6 +456,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
if (updated <= 0) {
|
||||
throw new RuntimeException("转录记录不存在");
|
||||
}
|
||||
meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -464,6 +478,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
private String normalizeTranscriptContent(String content) {
|
||||
return content == null ? "" : content.trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateMeetingParticipants(Long meetingId, String participants) {
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@ public class MeetingTranscriptFileServiceImpl implements MeetingTranscriptFileSe
|
|||
.orderByAsc(MeetingTranscript::getStartTime)
|
||||
.orderByAsc(MeetingTranscript::getId));
|
||||
|
||||
String title = firstNonBlank(meetingDetail != null ? meetingDetail.getTitle() : null, meeting.getTitle(), "未命名会议");
|
||||
String hostName = firstNonBlank(meetingDetail != null ? meetingDetail.getHostName() : null, meeting.getHostName(), "未知");
|
||||
String title = firstNonBlank(meetingDetail != null ? meetingDetail.getTitle() : null, meeting.getTitle(), "Untitled Meeting");
|
||||
String hostName = firstNonBlank(meetingDetail != null ? meetingDetail.getHostName() : null, meeting.getHostName(), "Unknown");
|
||||
String meetingTime = formatDateTime(meetingDetail != null ? meetingDetail.getMeetingTime() : meeting.getMeetingTime());
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
|
@ -104,13 +104,13 @@ public class MeetingTranscriptFileServiceImpl implements MeetingTranscriptFileSe
|
|||
}
|
||||
|
||||
for (MeetingTranscript transcript : transcripts) {
|
||||
String speaker = firstNonBlank(transcript.getSpeakerName(), transcript.getSpeakerId(), "未知发言人");
|
||||
String speaker = firstNonBlank(transcript.getSpeakerName(), transcript.getSpeakerId(), "Unknown Speaker");
|
||||
builder.append("- ");
|
||||
String timeRange = buildTimeRange(transcript.getStartTime(), transcript.getEndTime());
|
||||
if (!timeRange.isBlank()) {
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("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;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.dto.biz.MeetingSummarySource;
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
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.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||
import com.imeeting.support.TaskSecurityContextRunner;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
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.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
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.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
|
@ -94,7 +96,8 @@ class AiTaskServiceImplTest {
|
|||
aiModelService,
|
||||
redisTemplate,
|
||||
new TaskSecurityContextRunner(),
|
||||
mock(MeetingTranscriptFileService.class)
|
||||
mock(MeetingTranscriptFileService.class),
|
||||
mock(MeetingTranscriptRevisionService.class)
|
||||
));
|
||||
doReturn(true).when(service).updateById(any());
|
||||
|
||||
|
|
@ -118,7 +121,7 @@ class AiTaskServiceImplTest {
|
|||
service.dispatchTasks(66L, 1L, 2L);
|
||||
|
||||
assertEquals(3, summaryTask.getStatus());
|
||||
assertTrue(summaryTask.getErrorMsg().contains("没有可用于总结的转录内容"));
|
||||
assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank());
|
||||
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
||||
}
|
||||
|
||||
|
|
@ -128,9 +131,19 @@ class AiTaskServiceImplTest {
|
|||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
AiModelService aiModelService = mock(AiModelService.class);
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
MeetingTranscriptRevisionService revisionService = mock(MeetingTranscriptRevisionService.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
|
||||
when(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(
|
||||
meetingMapper,
|
||||
|
|
@ -138,7 +151,8 @@ class AiTaskServiceImplTest {
|
|||
aiModelService,
|
||||
redisTemplate,
|
||||
new TaskSecurityContextRunner(),
|
||||
mock(MeetingTranscriptFileService.class)
|
||||
mock(MeetingTranscriptFileService.class),
|
||||
revisionService
|
||||
));
|
||||
doReturn(true).when(service).updateById(any());
|
||||
|
||||
|
|
@ -146,22 +160,17 @@ class AiTaskServiceImplTest {
|
|||
meeting.setId(77L);
|
||||
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();
|
||||
summaryTask.setId(100L);
|
||||
summaryTask.setMeetingId(77L);
|
||||
summaryTask.setTaskType("SUMMARY");
|
||||
summaryTask.setStatus(0);
|
||||
doReturn(summaryTask).when(service).getOne(any());
|
||||
doReturn(summaryTask, null).when(service).getOne(any());
|
||||
|
||||
service.dispatchSummaryTask(77L, 1L, 2L);
|
||||
|
||||
assertEquals(3, summaryTask.getStatus());
|
||||
assertTrue(summaryTask.getErrorMsg().contains("没有可用于总结的转录内容"));
|
||||
assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank());
|
||||
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +185,8 @@ class AiTaskServiceImplTest {
|
|||
mock(AiModelService.class),
|
||||
mock(StringRedisTemplate.class),
|
||||
mock(TaskSecurityContextRunner.class),
|
||||
transcriptFileService
|
||||
transcriptFileService,
|
||||
mock(MeetingTranscriptRevisionService.class)
|
||||
);
|
||||
|
||||
Meeting meeting = new Meeting();
|
||||
|
|
@ -190,9 +200,9 @@ class AiTaskServiceImplTest {
|
|||
{
|
||||
"segments": [
|
||||
{
|
||||
"speaker_id": "spk-1",
|
||||
"speaker_id": "123",
|
||||
"speaker_name": "Alice",
|
||||
"text": "第一段转录",
|
||||
"text": "hello world",
|
||||
"timestamp": [0, 1200]
|
||||
}
|
||||
]
|
||||
|
|
@ -211,7 +221,8 @@ class AiTaskServiceImplTest {
|
|||
mock(AiModelService.class),
|
||||
mock(StringRedisTemplate.class),
|
||||
mock(TaskSecurityContextRunner.class),
|
||||
mock(MeetingTranscriptFileService.class)
|
||||
mock(MeetingTranscriptFileService.class),
|
||||
mock(MeetingTranscriptRevisionService.class)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -220,7 +231,8 @@ class AiTaskServiceImplTest {
|
|||
AiModelService aiModelService,
|
||||
StringRedisTemplate redisTemplate,
|
||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||
MeetingTranscriptFileService meetingTranscriptFileService) {
|
||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||
MeetingTranscriptRevisionService revisionService) {
|
||||
return new AiTaskServiceImpl(
|
||||
meetingMapper,
|
||||
transcriptMapper,
|
||||
|
|
@ -231,6 +243,7 @@ class AiTaskServiceImplTest {
|
|||
redisTemplate,
|
||||
mock(MeetingSummaryFileService.class),
|
||||
meetingTranscriptFileService,
|
||||
revisionService,
|
||||
mock(MeetingSummaryPromptAssembler.class),
|
||||
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