feat: 添加会议章节导入和总结功能

- 在 `MeetingCommandService` 中添加 `importTranscriptChapters` 和 `finalizeSummary` 方法
- 更新 `MeetingSummaryPromptAssembler` 以支持章节模型和摘要源
- 在 `MeetingQueryService` 中添加获取章节和转录源的方法
- 新增 `MeetingSummaryFinalizeDTO` 和 `MeetingSummaryPromptContextVO` 数据传输对象
- 在 `MeetingCommandServiceImpl` 中实现章节导入和总结任务创建逻辑
- 更新前端 `meeting.ts` 以支持获取章节信息
dev_na
chenhao 2026-05-09 13:48:09 +08:00
parent 9b63a1ec4e
commit a34885111c
33 changed files with 2554 additions and 169 deletions

View File

@ -235,6 +235,16 @@ public class MeetingController {
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
}
@Operation(summary = "查询会议章节")
@GetMapping("/{id}/chapters")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<Map<String, Object>>> getChapters(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
return ApiResponse.ok(meetingQueryService.getChapters(id));
}
@Operation(summary = "下载会议转录 Markdown")
@GetMapping("/{id}/transcripts/export")
@PreAuthorize("isAuthenticated()")
@ -403,7 +413,7 @@ public class MeetingController {
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
dto.setMeetingId(id);
assertPromptAvailable(dto.getPromptId(), loginUser);
meetingCommandService.reSummary(dto.getMeetingId(), dto.getSummaryModelId(), dto.getPromptId(), dto.getUserPrompt());
meetingCommandService.reSummary(dto.getMeetingId(), dto.getSummaryModelId(), dto.getChapterModelId(), dto.getPromptId(), dto.getUserPrompt());
return ApiResponse.ok(true);
}

View File

@ -0,0 +1,124 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO;
import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO;
import com.imeeting.dto.biz.MeetingSummaryPromptContextVO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingQueryService;
import com.unisbase.common.ApiResponse;
import com.unisbase.config.properties.UnisBaseProperties;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "会议内部编排接口")
@RestController
@RequestMapping("/sys/internal/meetings")
public class MeetingInternalWorkflowController {
private final MeetingCommandService meetingCommandService;
private final MeetingQueryService meetingQueryService;
private final UnisBaseProperties unisBaseProperties;
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
private String summaryOrchestrationMode;
public MeetingInternalWorkflowController(MeetingCommandService meetingCommandService,
MeetingQueryService meetingQueryService,
UnisBaseProperties unisBaseProperties) {
this.meetingCommandService = meetingCommandService;
this.meetingQueryService = meetingQueryService;
this.unisBaseProperties = unisBaseProperties;
}
@Operation(summary = "导入会议章节")
@PostMapping("/{meetingId}/chapters/import")
public ApiResponse<MeetingTranscriptChapterImportResultVO> importChapters(HttpServletRequest request,
@PathVariable Long meetingId,
@Valid @RequestBody MeetingTranscriptChapterImportDTO command) {
if (!isExternalModeEnabled()) {
return ApiResponse.error("External n8n summary orchestration is disabled");
}
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
command.setMeetingId(meetingId);
return ApiResponse.ok(meetingCommandService.importTranscriptChapters(command));
}
@Operation(summary = "获取会议原始转录源")
@GetMapping("/{meetingId}/transcript-source")
public ApiResponse<MeetingTranscriptSourceVO> getTranscriptSource(HttpServletRequest request, @PathVariable Long meetingId) {
if (!isExternalModeEnabled()) {
return ApiResponse.error("External n8n summary orchestration is disabled");
}
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
return ApiResponse.ok(meetingQueryService.getTranscriptSource(meetingId));
}
@Operation(summary = "获取会议总结提示词上下文")
@PostMapping("/{meetingId}/summary-prompt-context")
public ApiResponse<MeetingSummaryPromptContextVO> getSummaryPromptContext(HttpServletRequest request,
@PathVariable Long meetingId,
@RequestBody(required = false) MeetingSummaryPromptContextRequestDTO requestDTO) {
if (!isExternalModeEnabled()) {
return ApiResponse.error("External n8n summary orchestration is disabled");
}
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
return ApiResponse.ok(meetingQueryService.buildSummaryPromptContext(
meetingId,
requestDTO == null ? new MeetingSummaryPromptContextRequestDTO() : requestDTO
));
}
@Operation(summary = "回填会议总结")
@PostMapping("/{meetingId}/summary/finalize")
public ApiResponse<Boolean> finalizeSummary(HttpServletRequest request,
@PathVariable Long meetingId,
@Valid @RequestBody MeetingSummaryFinalizeDTO command) {
if (!isExternalModeEnabled()) {
return ApiResponse.error("External n8n summary orchestration is disabled");
}
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
command.setMeetingId(meetingId);
meetingCommandService.finalizeSummary(command);
return ApiResponse.ok(true);
}
private boolean isExternalModeEnabled() {
return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode);
}
private boolean isInternalSecretValid(HttpServletRequest request) {
if (request == null || unisBaseProperties == null || unisBaseProperties.getInternalAuth() == null) {
return false;
}
if (!unisBaseProperties.getInternalAuth().isEnabled()) {
return false;
}
String headerName = unisBaseProperties.getInternalAuth().getHeaderName();
String expectedSecret = unisBaseProperties.getInternalAuth().getSecret();
if (headerName == null || headerName.isBlank() || expectedSecret == null || expectedSecret.isBlank()) {
return false;
}
String actual = request.getHeader(headerName);
return expectedSecret.equals(actual);
}
}

View File

@ -32,6 +32,8 @@ public class CreateMeetingCommand {
@NotNull(message = "summaryModelId must not be null")
private Long summaryModelId;
private Long chapterModelId;
@NotNull(message = "promptId must not be null")
private Long promptId;

View File

@ -29,6 +29,8 @@ public class CreateRealtimeMeetingCommand {
@NotNull(message = "summaryModelId must not be null")
private Long summaryModelId;
private Long chapterModelId;
@NotNull(message = "promptId must not be null")
private Long promptId;

View File

@ -7,6 +7,7 @@ import jakarta.validation.constraints.Size;
public class MeetingResummaryDTO {
private Long meetingId;
private Long summaryModelId;
private Long chapterModelId;
private Long promptId;
@Size(max = 2000, message = "userPrompt length must be <= 2000")

View File

@ -0,0 +1,34 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Map;
@Data
@Schema(description = "会议总结回填请求")
public class MeetingSummaryFinalizeDTO {
@Schema(description = "会议ID")
private Long meetingId;
@NotNull(message = "summaryTaskId must not be null")
@Schema(description = "总结任务ID")
private Long summaryTaskId;
@NotBlank(message = "sourceFingerprint must not be blank")
@Schema(description = "转录指纹")
private String sourceFingerprint;
@NotNull(message = "chapterVersionId must not be null")
@Schema(description = "章节版本ID")
private Long chapterVersionId;
@NotBlank(message = "summaryContent must not be blank")
@Schema(description = "最终总结正文")
private String summaryContent;
@Schema(description = "结构化分析结果")
private Map<String, Object> analysis;
}

View File

@ -0,0 +1,22 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
@Schema(description = "会议总结提示词上下文请求")
public class MeetingSummaryPromptContextRequestDTO {
@Schema(description = "提示词模板ID")
private Long promptId;
@Schema(description = "总结模型ID")
private Long summaryModelId;
@Schema(description = "章节模型ID")
private Long chapterModelId;
@Size(max = 2000, message = "userPrompt length must be <= 2000")
@Schema(description = "附加用户提示词")
private String userPrompt;
}

View File

@ -0,0 +1,29 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "会议总结提示词上下文")
public class MeetingSummaryPromptContextVO {
@Schema(description = "提示词协议版本")
private String promptSchemaVersion;
@Schema(description = "系统消息")
private String systemMessage;
@Schema(description = "带占位符的用户消息模板")
private String userMessageTemplate;
@Schema(description = "有效模板提示词")
private String effectiveTemplatePrompt;
@Schema(description = "有效用户提示词")
private String effectiveUserPrompt;
@Schema(description = "总结模型ID")
private Long summaryModelId;
@Schema(description = "章节模型ID")
private Long chapterModelId;
}

View File

@ -4,6 +4,7 @@ import lombok.Builder;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Data
@ -17,6 +18,14 @@ public class MeetingSummarySource {
private String triggerTaskType;
private String semanticCorrector;
private String ruleProfileVersion;
private Long chapterVersionId;
private Integer chapterCount;
private String algorithmVersion;
private String generationMode;
private String rawTranscriptText;
private String chapterOutlineText;
private String chapterFilePath;
private List<Map<String, Object>> chapters;
public Map<String, Object> toSnapshot() {
Map<String, Object> snapshot = new LinkedHashMap<>();
@ -27,6 +36,13 @@ public class MeetingSummarySource {
snapshot.put("triggerTaskType", triggerTaskType);
snapshot.put("semanticCorrector", semanticCorrector);
snapshot.put("ruleProfileVersion", ruleProfileVersion);
snapshot.put("chapterVersionId", chapterVersionId);
snapshot.put("chapterCount", chapterCount);
snapshot.put("algorithmVersion", algorithmVersion);
snapshot.put("generationMode", generationMode);
snapshot.put("hasRawTranscriptText", rawTranscriptText != null && !rawTranscriptText.isBlank());
snapshot.put("hasChapterOutlineText", chapterOutlineText != null && !chapterOutlineText.isBlank());
snapshot.put("chapterFilePath", chapterFilePath);
return snapshot;
}
}

View File

@ -0,0 +1,75 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
@Schema(description = "会议转录章节导入请求")
public class MeetingTranscriptChapterImportDTO {
@Schema(description = "会议ID")
private Long meetingId;
@Schema(description = "章节生成来源标识")
private String chapterGeneratorLabel;
@Schema(description = "章节算法版本")
private String algorithmVersion;
@Schema(description = "导入成功后是否触发总结")
private Boolean triggerSummary;
@Schema(description = "本次总结模型ID")
private Long summaryModelId;
@Schema(description = "本次章节模型ID")
private Long chapterModelId;
@Schema(description = "本次提示词模板ID")
private Long promptId;
@Schema(description = "本次附加用户提示词")
private String userPrompt;
@Valid
@NotEmpty(message = "chapters must not be empty")
@Schema(description = "章节列表")
private List<ChapterItem> chapters;
@Data
@Schema(description = "章节项")
public static class ChapterItem {
@NotNull(message = "chapterNo must not be null")
@Schema(description = "章节序号")
private Integer chapterNo;
@Schema(description = "章节标题")
private String title;
@Schema(description = "章节摘要")
private String summary;
@Schema(description = "章节关键词")
private List<String> keywords;
@NotNull(message = "startTranscriptId must not be null")
@Schema(description = "起始转录ID")
private Long startTranscriptId;
@NotNull(message = "endTranscriptId must not be null")
@Schema(description = "结束转录ID")
private Long endTranscriptId;
@DecimalMin(value = "0.0", inclusive = true, message = "confidence must be >= 0")
@DecimalMax(value = "1.0", inclusive = true, message = "confidence must be <= 1")
@Schema(description = "置信度")
private BigDecimal confidence;
}
}

View File

@ -0,0 +1,32 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "会议转录章节导入结果")
public class MeetingTranscriptChapterImportResultVO {
@Schema(description = "章节版本ID")
private Long chapterVersionId;
@Schema(description = "章节数量")
private Integer chapterCount;
@Schema(description = "章节生成模式")
private String chapterGenerationMode;
@Schema(description = "章节生成来源标识")
private String chapterGeneratorLabel;
@Schema(description = "章节算法版本")
private String algorithmVersion;
@Schema(description = "转录指纹")
private String sourceFingerprint;
@Schema(description = "是否触发总结")
private Boolean summaryTriggered;
@Schema(description = "触发的总结任务ID")
private Long summaryTaskId;
}

View File

@ -0,0 +1,22 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "会议原始转录源")
public class MeetingTranscriptSourceVO {
@Schema(description = "会议ID")
private Long meetingId;
@Schema(description = "转录指纹")
private String sourceFingerprint;
@Schema(description = "原始转录全文")
private String transcriptText;
@Schema(description = "原始转录分段")
private List<MeetingTranscriptVO> segments;
}

View File

@ -0,0 +1,67 @@
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.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Schema(description = "会议转录章节")
@TableName("biz_meeting_transcript_chapters")
public class MeetingTranscriptChapter {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "章节ID")
private Long id;
@Schema(description = "租户ID")
private Long tenantId;
@Schema(description = "章节版本ID")
private Long versionId;
@Schema(description = "章节序号")
private Integer chapterNo;
@Schema(description = "章节标题")
private String title;
@Schema(description = "章节摘要")
private String summary;
@Schema(description = "章节关键词JSON")
private String keywordsJson;
@Schema(description = "起始转录ID")
private Long startTranscriptId;
@Schema(description = "结束转录ID")
private Long endTranscriptId;
@Schema(description = "起始排序值")
private Integer startSortOrder;
@Schema(description = "结束排序值")
private Integer endSortOrder;
@Schema(description = "开始时间,毫秒")
private Integer startTime;
@Schema(description = "结束时间,毫秒")
private Integer endTime;
@Schema(description = "章节片段数")
private Integer segmentCount;
@Schema(description = "章节置信度")
private BigDecimal confidence;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}

View File

@ -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_chapter_versions")
public class MeetingTranscriptChapterVersion {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "章节版本ID")
private Long id;
@Schema(description = "租户ID")
private Long tenantId;
@Schema(description = "会议ID")
private Long meetingId;
@Schema(description = "触发任务ID")
private Long sourceTaskId;
@Schema(description = "版本号")
private Integer versionNo;
@Schema(description = "状态")
private Integer status;
@Schema(description = "原始转录指纹")
private String sourceFingerprint;
@Schema(description = "算法版本")
private String algorithmVersion;
@Schema(description = "章节生成模式")
private String generationMode;
@Schema(description = "章节生成来源标识")
private String generatorLabel;
@Schema(description = "章节数量")
private Integer chapterCount;
@Schema(description = "是否当前生效")
private Integer isCurrent;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.MeetingTranscriptChapter;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MeetingTranscriptChapterMapper extends BaseMapper<MeetingTranscriptChapter> {
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MeetingTranscriptChapterVersionMapper extends BaseMapper<MeetingTranscriptChapterVersion> {
}

View File

@ -150,6 +150,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
meetingService.updateById(meeting);
resetOrCreateAsrTask(meetingId, profile);
resetOrCreateChapterTask(meetingId, profile);
resetOrCreateSummaryTask(meetingId, profile);
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
@ -256,6 +257,17 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig);
}
private void resetOrCreateChapterTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) {
AiTask task = findLatestTask(meetingId, "CHAPTER");
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
profile.getResolvedSummaryModelId(),
profile.getResolvedSummaryModelId(),
profile.getResolvedPromptId(),
null
);
resetOrCreateTask(task, meetingId, "CHAPTER", taskConfig);
}
private AiTask findLatestTask(Long meetingId, String taskType) {
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)

View File

@ -2,6 +2,9 @@ package com.imeeting.service.biz;
import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
@ -32,7 +35,11 @@ public interface MeetingCommandService {
void updateSummaryContent(Long meetingId, String summaryContent);
void reSummary(Long meetingId, Long summaryModelId, Long promptId, String userPrompt);
void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt);
void retryTranscription(Long meetingId);
MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command);
void finalizeSummary(MeetingSummaryFinalizeDTO command);
}

View File

@ -1,5 +1,8 @@
package com.imeeting.service.biz;
import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO;
import com.imeeting.dto.biz.MeetingSummaryPromptContextVO;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.unisbase.dto.PageResult;
@ -17,6 +20,12 @@ public interface MeetingQueryService {
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
List<Map<String, Object>> getChapters(Long meetingId);
MeetingTranscriptSourceVO getTranscriptSource(Long meetingId);
MeetingSummaryPromptContextVO buildSummaryPromptContext(Long meetingId, MeetingSummaryPromptContextRequestDTO request);
Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);

View File

@ -1,6 +1,7 @@
package com.imeeting.service.biz;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.AiTask;
import java.nio.file.Path;
import java.util.Map;
@ -20,5 +21,9 @@ public interface MeetingSummaryFileService {
void updateSummaryContent(Meeting meeting, String summaryContent);
String saveSummaryContent(Meeting meeting, AiTask summaryTask, String summaryContent);
Map<String, Object> normalizeSummaryAnalysis(Map<String, Object> analysis);
String stripFrontMatter(String markdown);
}

View File

@ -0,0 +1,25 @@
package com.imeeting.service.biz;
import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import java.util.List;
import java.util.Map;
public interface MeetingTranscriptChapterService {
MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask);
List<Map<String, Object>> listCurrentChapterAnalysis(Long meetingId);
void invalidateCurrentVersion(Long meetingId);
MeetingTranscriptChapterVersion importExternalChapters(Meeting meeting, AiTask sourceTask, MeetingTranscriptChapterImportDTO command);
MeetingTranscriptSourceVO buildTranscriptSource(Long meetingId);
MeetingTranscriptChapterVersion getCurrentVersion(Long meetingId);
}

View File

@ -20,6 +20,7 @@ import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
@ -63,6 +64,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingTranscriptFileService meetingTranscriptFileService;
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@ -72,6 +74,9 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
@Value("${unisbase.app.upload-path}")
private String uploadPath;
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
private String summaryOrchestrationMode;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(300))
.version(HttpClient.Version.HTTP_1_1)
@ -121,19 +126,25 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return;
}
AiTask sumTask = this.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("limit 1"));
AiTask chapterTask = findLatestTask(meetingId, "CHAPTER");
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
if (asrText == null || asrText.isBlank()) {
failPendingSummaryTask(sumTask, "没有可用于总结的转录内容");
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0);
return;
}
if (chapterTask != null && canExecuteTask(chapterTask)) {
executeChapterFlow(meeting, chapterTask);
}
if (chapterTask != null && Integer.valueOf(3).equals(chapterTask.getStatus())) {
failPendingSummaryTask(sumTask, "章节生成失败,无法继续总结");
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "章节生成失败,无法继续总结", 0);
return;
}
if (sumTask != null && canExecuteTask(sumTask)) {
executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask));
executeSummaryFlow(meeting, sumTask, chapterTask);
} else if (meeting.getStatus() != 3) {
updateMeetingStatus(meetingId, 3);
}
@ -158,11 +169,24 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (meeting == null) {
return;
}
if (isExternalSummaryModeEnabled()) {
updateProgress(meetingId, 95, "等待外部章节与总结编排...", 0);
return;
}
AiTask chapterTask = findLatestTask(meetingId, "CHAPTER");
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
AiTask asrTask = findLatestTask(meetingId, "ASR");
try {
if (chapterTask != null && canExecuteTask(chapterTask)) {
executeChapterFlow(meeting, chapterTask);
}
if (chapterTask != null && Integer.valueOf(3).equals(chapterTask.getStatus())) {
failPendingSummaryTask(sumTask, "章节生成失败,无法继续总结");
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "章节生成失败,无法继续总结", 0);
return;
}
if (sumTask != null && canExecuteTask(sumTask)) {
executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask));
executeSummaryFlow(meeting, sumTask, chapterTask);
}
} catch (Exception e) {
log.error("Re-summary failed for meeting {}", meetingId, e);
@ -467,7 +491,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
private void processSummaryTask(Meeting meeting, MeetingSummarySource summarySource, AiTask taskRecord) throws Exception {
String asrText = summarySource.getText();
updateMeetingStatus(meeting.getId(), 2);
updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0);
@ -497,7 +520,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
req.put("temperature", llmModel.getTemperature());
req.put("messages", List.of(
Map.of("role", "system", "content", meetingSummaryPromptAssembler.buildSystemMessage(taskRecord.getTaskConfig())),
Map.of("role", "user", "content", meetingSummaryPromptAssembler.buildUserMessage(meeting, asrText, userPrompt))
Map.of("role", "user", "content", meetingSummaryPromptAssembler.buildUserMessage(meeting, summarySource, userPrompt))
));
taskRecord.setRequestData(req);
@ -575,7 +598,54 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
}
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiModelVO asrModel) throws Exception {
private void executeChapterFlow(Meeting meeting, AiTask chapterTask) {
if (chapterTask == null || !canExecuteTask(chapterTask)) {
return;
}
if (isExternalSummaryModeEnabled()) {
updateMeetingStatus(meeting.getId(), 2);
updateProgress(meeting.getId(), 85, "等待外部章节编排...", 0);
return;
}
try {
chapterTask.setStatus(1);
chapterTask.setStartedAt(LocalDateTime.now());
this.updateById(chapterTask);
MeetingSummarySource summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask);
if (summarySource.getRawTranscriptText() == null || summarySource.getRawTranscriptText().isBlank()) {
updateAiTaskFail(chapterTask, "没有可用转录,无法生成章节");
return;
}
Map<String, Object> responseData = chapterTask.getResponseData() == null
? new HashMap<>()
: new HashMap<>(chapterTask.getResponseData());
responseData.put("summarySource", summarySource.toSnapshot());
responseData.put("chapterOutlineText", summarySource.getChapterOutlineText());
responseData.put("sourceFingerprint", summarySource.getSourceFingerprint());
responseData.put("chapterVersionId", summarySource.getChapterVersionId());
responseData.put("chapterCount", summarySource.getChapterCount());
responseData.put("chapterFilePath", summarySource.getChapterFilePath());
chapterTask.setResultFilePath(summarySource.getChapterFilePath());
chapterTask.setResponseData(responseData);
chapterTask.setStatus(2);
chapterTask.setErrorMsg(null);
chapterTask.setCompletedAt(LocalDateTime.now());
this.updateById(chapterTask);
updateProgress(meeting.getId(), 88, "章节生成完成,准备生成总结...", 0);
} catch (Exception ex) {
log.error("Chapter flow failed for meeting {}", meeting.getId(), ex);
updateAiTaskFail(chapterTask, "章节生成失败: " + ex.getMessage());
}
}
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception {
if (isExternalSummaryModeEnabled()) {
updateMeetingStatus(meeting.getId(), 2);
updateProgress(meeting.getId(), 95, "等待外部章节与总结编排...", 0);
return;
}
String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId());
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(summaryLockKey, "locked", 30, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(acquired)) {
@ -583,7 +653,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return;
}
try {
MeetingSummarySource summarySource = meetingTranscriptRevisionService.resolveSummarySource(meeting, sumTask, asrModel);
MeetingSummarySource summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask != null ? chapterTask : sumTask);
if (summarySource.getText() == null || summarySource.getText().isBlank()) {
failPendingSummaryTask(sumTask, "没有转录内容");
updateMeetingStatus(meeting.getId(), 4);
@ -604,6 +674,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
.last("limit 1"));
}
private boolean isExternalSummaryModeEnabled() {
return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode);
}
private boolean canExecuteTask(AiTask task) {
return task != null
&& !Integer.valueOf(2).equals(task.getStatus())

View File

@ -7,6 +7,9 @@ import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
@ -18,18 +21,21 @@ import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import lombok.extern.slf4j.Slf4j;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -57,6 +63,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingTranscriptFileService meetingTranscriptFileService;
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
private final MeetingDomainSupport meetingDomainSupport;
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
@ -64,6 +71,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
private String summaryOrchestrationMode;
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
@ -99,12 +109,30 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
asrTask.setTaskConfig(asrConfig);
aiTaskService.save(asrTask);
meetingDomainSupport.createSummaryTask(
Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId();
meetingDomainSupport.createChapterTask(
meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(),
chapterModelId,
runtimeProfile.getResolvedPromptId(),
command.getUserPrompt()
);
if (Objects.equals(chapterModelId, runtimeProfile.getResolvedSummaryModelId())) {
meetingDomainSupport.createSummaryTask(
meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(),
runtimeProfile.getResolvedPromptId(),
command.getUserPrompt()
);
} else {
meetingDomainSupport.createSummaryTask(
meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(),
chapterModelId,
runtimeProfile.getResolvedPromptId(),
command.getUserPrompt()
);
}
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
meetingService.updateById(meeting);
meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl());
@ -124,12 +152,30 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
meetingService.save(meeting);
meetingDomainSupport.createSummaryTask(
Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId();
meetingDomainSupport.createChapterTask(
meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(),
chapterModelId,
runtimeProfile.getResolvedPromptId(),
command.getUserPrompt()
);
if (Objects.equals(chapterModelId, runtimeProfile.getResolvedSummaryModelId())) {
meetingDomainSupport.createSummaryTask(
meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(),
runtimeProfile.getResolvedPromptId(),
command.getUserPrompt()
);
} else {
meetingDomainSupport.createSummaryTask(
meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(),
chapterModelId,
runtimeProfile.getResolvedPromptId(),
command.getUserPrompt()
);
}
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
realtimeMeetingSessionStateService.rememberResumeConfig(meeting.getId(), buildRealtimeResumeConfig(command, tenantId, runtimeProfile));
@ -457,6 +503,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
throw new RuntimeException("转录记录不存在");
}
meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId());
meetingTranscriptChapterService.invalidateCurrentVersion(command.getMeetingId());
}
@Override
@ -502,13 +549,190 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override
@Transactional(rollbackFor = Exception.class)
public void reSummary(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
public MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command) {
ensureExternalSummaryModeEnabled();
if (command == null || command.getMeetingId() == null) {
throw new RuntimeException("缺少会议ID无法导入章节");
}
Meeting meeting = meetingService.getById(command.getMeetingId());
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
AiTask latestChapterTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "CHAPTER")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
AiTask latestSummaryTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (latestChapterTask == null) {
Long summaryModelId = resolveSummaryModelId(command, latestSummaryTask);
Long chapterModelId = resolveChapterModelId(command, latestSummaryTask, summaryModelId);
Long promptId = resolvePromptId(command, latestSummaryTask);
latestChapterTask = meetingDomainSupport.createChapterTask(
meeting.getId(),
summaryModelId,
chapterModelId,
promptId,
command.getUserPrompt()
);
}
MeetingTranscriptChapterVersion version = meetingTranscriptChapterService.importExternalChapters(meeting, latestChapterTask, command);
latestChapterTask.setStatus(2);
latestChapterTask.setErrorMsg(null);
latestChapterTask.setCompletedAt(java.time.LocalDateTime.now());
Map<String, Object> chapterResponse = latestChapterTask.getResponseData() == null
? new HashMap<>()
: new HashMap<>(latestChapterTask.getResponseData());
chapterResponse.put("chapterVersionId", version.getId());
chapterResponse.put("chapterCount", version.getChapterCount());
chapterResponse.put("sourceFingerprint", version.getSourceFingerprint());
chapterResponse.put("generationMode", version.getGenerationMode());
chapterResponse.put("algorithmVersion", version.getAlgorithmVersion());
chapterResponse.put("chapterFilePath", "meetings/" + meeting.getId() + "/chapters/current.md");
latestChapterTask.setResultFilePath("meetings/" + meeting.getId() + "/chapters/current.md");
latestChapterTask.setResponseData(chapterResponse);
aiTaskService.updateById(latestChapterTask);
MeetingTranscriptChapterImportResultVO result = new MeetingTranscriptChapterImportResultVO();
result.setChapterVersionId(version.getId());
result.setChapterCount(version.getChapterCount());
result.setChapterGenerationMode(version.getGenerationMode());
result.setChapterGeneratorLabel(version.getGeneratorLabel());
result.setAlgorithmVersion(version.getAlgorithmVersion());
result.setSourceFingerprint(version.getSourceFingerprint());
result.setSummaryTriggered(false);
if (Boolean.TRUE.equals(command.getTriggerSummary())) {
Long summaryModelId = resolveSummaryModelId(command, latestSummaryTask);
Long chapterModelId = resolveChapterModelId(command, latestSummaryTask, summaryModelId);
Long promptId = resolvePromptId(command, latestSummaryTask);
String userPrompt = command.getUserPrompt() != null
? command.getUserPrompt()
: latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null
? null
: stringValue(latestSummaryTask.getTaskConfig().get("userPrompt"));
AiTask createdSummaryTask = Objects.equals(chapterModelId, summaryModelId)
? meetingDomainSupport.createSummaryTask(
meeting.getId(),
summaryModelId,
promptId,
userPrompt
)
: meetingDomainSupport.createSummaryTask(
meeting.getId(),
summaryModelId,
chapterModelId,
promptId,
userPrompt
);
meeting.setLatestSummaryTaskId(createdSummaryTask.getId());
meeting.setStatus(2);
meetingService.updateById(meeting);
aiTaskService.dispatchSummaryTask(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId());
result.setSummaryTriggered(true);
result.setSummaryTaskId(createdSummaryTask.getId());
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void finalizeSummary(MeetingSummaryFinalizeDTO command) {
ensureExternalSummaryModeEnabled();
if (command == null || command.getMeetingId() == null) {
throw new RuntimeException("缺少会议ID无法回填总结");
}
Meeting meeting = meetingService.getById(command.getMeetingId());
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
AiTask summaryTask = aiTaskService.getById(command.getSummaryTaskId());
if (summaryTask == null || !Objects.equals(summaryTask.getMeetingId(), meeting.getId()) || !"SUMMARY".equals(summaryTask.getTaskType())) {
throw new RuntimeException("总结任务不存在或不属于当前会议");
}
MeetingTranscriptChapterVersion currentVersion = meetingTranscriptChapterService.getCurrentVersion(meeting.getId());
if (currentVersion == null) {
throw new RuntimeException("当前会议不存在有效章节版本");
}
if (!Objects.equals(currentVersion.getId(), command.getChapterVersionId())) {
throw new RuntimeException("章节版本不是当前生效版本,拒绝回填总结");
}
if (!Objects.equals(currentVersion.getSourceFingerprint(), command.getSourceFingerprint())) {
throw new RuntimeException("转录指纹已变化,拒绝回填过期总结结果");
}
Map<String, Object> normalizedAnalysis = meetingSummaryFileService.normalizeSummaryAnalysis(command.getAnalysis());
String relativePath = meetingSummaryFileService.saveSummaryContent(meeting, summaryTask, command.getSummaryContent());
Map<String, Object> responseData = summaryTask.getResponseData() == null
? new HashMap<>()
: new HashMap<>(summaryTask.getResponseData());
Map<String, Object> summarySource = new HashMap<>();
summarySource.put("sourceType", "CHAPTER_VERSION");
summarySource.put("chapterVersionId", currentVersion.getId());
summarySource.put("chapterCount", currentVersion.getChapterCount());
summarySource.put("sourceFingerprint", currentVersion.getSourceFingerprint());
summarySource.put("algorithmVersion", currentVersion.getAlgorithmVersion());
summarySource.put("generationMode", currentVersion.getGenerationMode());
responseData.put("summarySource", summarySource);
Map<String, Object> summaryBundle = new HashMap<>();
summaryBundle.put("summaryContent", command.getSummaryContent());
summaryBundle.put("analysis", normalizedAnalysis);
responseData.put("summaryBundle", summaryBundle);
responseData.put("normalizedAnalysis", normalizedAnalysis);
summaryTask.setResultFilePath(relativePath);
summaryTask.setResponseData(responseData);
summaryTask.setStatus(2);
summaryTask.setErrorMsg(null);
summaryTask.setCompletedAt(java.time.LocalDateTime.now());
aiTaskService.updateById(summaryTask);
meeting.setLatestSummaryTaskId(summaryTask.getId());
meeting.setStatus(3);
meetingService.updateById(meeting);
updateMeetingProgress(meeting.getId(), 100, "外部总结回填完成", 0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt);
Long effectiveChapterModelId = chapterModelId != null ? chapterModelId : summaryModelId;
meetingDomainSupport.createChapterTask(
meetingId,
summaryModelId,
effectiveChapterModelId,
promptId,
userPrompt
);
if (Objects.equals(effectiveChapterModelId, summaryModelId)) {
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt);
} else {
meetingDomainSupport.createSummaryTask(
meetingId,
summaryModelId,
effectiveChapterModelId,
promptId,
userPrompt
);
}
meeting.setStatus(2);
meetingService.updateById(meeting);
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
@ -551,6 +775,27 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
resetAiTask(asrTask, new HashMap<>(asrTask.getTaskConfig()));
aiTaskService.updateById(asrTask);
AiTask chapterTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "CHAPTER")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (chapterTask == null) {
Long summaryModelId = longValue(summaryTask.getTaskConfig().get("summaryModelId"));
Long chapterModelId = longValue(summaryTask.getTaskConfig().get("chapterModelId"));
Long promptId = longValue(summaryTask.getTaskConfig().get("promptId"));
String userPrompt = stringValue(summaryTask.getTaskConfig().get("userPrompt"));
chapterTask = meetingDomainSupport.createChapterTask(
meetingId,
summaryModelId,
chapterModelId != null ? chapterModelId : summaryModelId,
promptId,
userPrompt
);
} else {
resetAiTask(chapterTask, new HashMap<>(chapterTask.getTaskConfig()));
aiTaskService.updateById(chapterTask);
}
resetAiTask(summaryTask, new HashMap<>(summaryTask.getTaskConfig()));
aiTaskService.updateById(summaryTask);
@ -560,6 +805,63 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
}
private void ensureExternalSummaryModeEnabled() {
if (!"EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode)) {
throw new RuntimeException("外部 n8n 总结编排模式未开启");
}
}
private Long resolveSummaryModelId(MeetingTranscriptChapterImportDTO command, AiTask latestSummaryTask) {
if (command.getSummaryModelId() != null) {
return command.getSummaryModelId();
}
Long value = latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null
? null
: longValue(latestSummaryTask.getTaskConfig().get("summaryModelId"));
if (value == null) {
throw new RuntimeException("缺少 summaryModelId无法创建总结任务");
}
return value;
}
private Long resolveChapterModelId(MeetingTranscriptChapterImportDTO command, AiTask latestSummaryTask, Long fallbackSummaryModelId) {
if (command.getChapterModelId() != null) {
return command.getChapterModelId();
}
Long value = latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null
? null
: longValue(latestSummaryTask.getTaskConfig().get("chapterModelId"));
return value != null ? value : fallbackSummaryModelId;
}
private Long resolvePromptId(MeetingTranscriptChapterImportDTO command, AiTask latestSummaryTask) {
if (command.getPromptId() != null) {
return command.getPromptId();
}
Long value = latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null
? null
: longValue(latestSummaryTask.getTaskConfig().get("promptId"));
if (value == null) {
throw new RuntimeException("缺少 promptId无法创建总结任务");
}
return value;
}
private Long longValue(Object value) {
if (value == null) {
return null;
}
try {
return Long.parseLong(String.valueOf(value).trim());
} catch (Exception ex) {
return null;
}
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
private void dispatchSummaryTaskAfterCommit(Long meetingId, Long tenantId, Long userId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId);

View File

@ -71,13 +71,28 @@ public class MeetingDomainSupport {
return meeting;
}
public void createSummaryTask(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
return createSummaryTask(meetingId, summaryModelId, summaryModelId, promptId, userPrompt);
}
public AiTask createChapterTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
AiTask chapterTask = new AiTask();
chapterTask.setMeetingId(meetingId);
chapterTask.setTaskType("CHAPTER");
chapterTask.setStatus(0);
chapterTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt));
aiTaskService.save(chapterTask);
return chapterTask;
}
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
AiTask sumTask = new AiTask();
sumTask.setMeetingId(meetingId);
sumTask.setTaskType("SUMMARY");
sumTask.setStatus(0);
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, promptId, userPrompt));
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt));
aiTaskService.save(sumTask);
return sumTask;
}
public void publishMeetingCreated(Long meetingId, Long tenantId, Long userId) {

View File

@ -2,14 +2,20 @@ 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.MeetingSummaryPromptContextRequestDTO;
import com.imeeting.dto.biz.MeetingSummaryPromptContextVO;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.unisbase.dto.PageResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@ -28,6 +34,9 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
private final MeetingMapper meetingMapper;
private final MeetingTranscriptMapper transcriptMapper;
private final MeetingDomainSupport meetingDomainSupport;
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final AiTaskService aiTaskService;
@Override
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
@ -94,6 +103,50 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
}).collect(Collectors.toList());
}
@Override
public List<Map<String, Object>> getChapters(Long meetingId) {
return meetingTranscriptChapterService.listCurrentChapterAnalysis(meetingId);
}
@Override
public MeetingTranscriptSourceVO getTranscriptSource(Long meetingId) {
return meetingTranscriptChapterService.buildTranscriptSource(meetingId);
}
@Override
public MeetingSummaryPromptContextVO buildSummaryPromptContext(Long meetingId, MeetingSummaryPromptContextRequestDTO request) {
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
AiTask latestSummaryTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null) {
throw new RuntimeException("缺少可用的总结任务配置");
}
Long summaryModelId = firstLong(request == null ? null : request.getSummaryModelId(), latestSummaryTask.getTaskConfig().get("summaryModelId"));
Long chapterModelId = firstLong(request == null ? null : request.getChapterModelId(), latestSummaryTask.getTaskConfig().get("chapterModelId"), summaryModelId);
Long promptId = firstLong(request == null ? null : request.getPromptId(), latestSummaryTask.getTaskConfig().get("promptId"));
String userPrompt = request != null && request.getUserPrompt() != null
? request.getUserPrompt()
: stringValue(latestSummaryTask.getTaskConfig().get("userPrompt"));
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt);
MeetingSummaryPromptContextVO context = new MeetingSummaryPromptContextVO();
context.setPromptSchemaVersion(String.valueOf(taskConfig.get("promptSchemaVersion")));
context.setSystemMessage(meetingSummaryPromptAssembler.buildSystemMessage(taskConfig));
context.setUserMessageTemplate(meetingSummaryPromptAssembler.buildUserMessageTemplate(meeting, userPrompt));
context.setEffectiveTemplatePrompt(stringValue(taskConfig.get("effectiveTemplatePrompt")));
context.setEffectiveUserPrompt(meetingSummaryPromptAssembler.normalizeOptionalText(userPrompt));
context.setSummaryModelId(summaryModelId);
context.setChapterModelId(chapterModelId);
return context;
}
@Override
public Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin) {
Map<String, Object> stats = new HashMap<>();
@ -134,4 +187,24 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
meetingDomainSupport.fillMeetingVO(meeting, vo, includeSummary, includeSummary);
return vo;
}
private Long firstLong(Object... candidates) {
if (candidates == null) {
return null;
}
for (Object candidate : candidates) {
if (candidate == null) {
continue;
}
try {
return Long.parseLong(String.valueOf(candidate).trim());
} catch (Exception ignored) {
}
}
return null;
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
}

View File

@ -145,7 +145,6 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
Map<String, Object> normalized = new LinkedHashMap<>();
normalized.put("overview", clipText(asText(parsed.get("overview")), 500));
normalized.put("keywords", normalizeStringList(parsed.get("keywords")));
normalized.put("chapters", normalizeChapterList(parsed.get("chapters")));
normalized.put("speakerSummaries", normalizeSpeakerSummaries(parsed.get("speakerSummaries")));
normalized.put("keyPoints", normalizeKeyPoints(parsed.get("keyPoints")));
List<String> todos = normalizeStringList(parsed.containsKey("todos") ? parsed.get("todos") : parsed.get("actionItems"));
@ -240,6 +239,41 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
}
}
@Override
public String saveSummaryContent(Meeting meeting, AiTask summaryTask, String summaryContent) {
if (meeting == null || summaryTask == null) {
throw new RuntimeException("保存总结文件缺少会议或任务上下文");
}
try {
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meeting.getId()), "summaries");
Files.createDirectories(targetDir);
String relativePath = summaryTask.getResultFilePath();
if (relativePath == null || relativePath.isBlank()) {
String timestamp = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
relativePath = "meetings/" + meeting.getId() + "/summaries/summary_" + timestamp + ".md";
}
Path summaryPath = Paths.get(basePath, relativePath.replace("\\", "/"));
Path parent = summaryPath.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
String existingContent = Files.exists(summaryPath) ? Files.readString(summaryPath, StandardCharsets.UTF_8) : "";
String frontMatter = extractFrontMatter(existingContent, meeting, summaryTask);
Files.writeString(summaryPath, frontMatter + normalizeSummaryMarkdown(summaryContent), StandardCharsets.UTF_8);
return relativePath.replace("\\", "/");
} catch (Exception ex) {
throw new RuntimeException("保存总结文件失败", ex);
}
}
@Override
public Map<String, Object> normalizeSummaryAnalysis(Map<String, Object> analysis) {
return analysis == null ? new LinkedHashMap<>() : parseSummaryAnalysisFromMap(analysis);
}
@Override
public String stripFrontMatter(String markdown) {
if (markdown == null || markdown.isBlank()) {

View File

@ -1,6 +1,7 @@
package com.imeeting.service.biz.impl;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.service.biz.PromptTemplateService;
@ -17,6 +18,7 @@ import java.util.Map;
public class MeetingSummaryPromptAssembler {
public static final String PROMPT_SCHEMA_VERSION = "v2";
private static final String SUMMARY_SOURCE_PLACEHOLDER = "{{SUMMARY_SOURCE_TEXT}}";
private static final String SYSTEM_PROMPT_NOT_CONFIGURED_MESSAGE =
"系统提示词未配置,请先维护系统参数 " + SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT;
@ -24,8 +26,13 @@ public class MeetingSummaryPromptAssembler {
private final SysParamService sysParamService;
public Map<String, Object> buildTaskConfig(Long summaryModelId, Long promptId, String userPrompt) {
return buildTaskConfig(summaryModelId, summaryModelId, promptId, userPrompt);
}
public Map<String, Object> buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
Map<String, Object> taskConfig = new HashMap<>();
taskConfig.put("summaryModelId", summaryModelId);
taskConfig.put("chapterModelId", chapterModelId != null ? chapterModelId : summaryModelId);
taskConfig.put("promptSchemaVersion", PROMPT_SCHEMA_VERSION);
taskConfig.put("effectiveSystemPrompt", resolveSystemPrompt());
@ -50,31 +57,55 @@ public class MeetingSummaryPromptAssembler {
String templatePrompt = firstNonBlank(
stringValue(taskConfig, "effectiveTemplatePrompt"),
stringValue(taskConfig, "promptContent"),
"请输出结构清晰、信息完整、适合直接阅读和导出的会议纪要。"
"请输出结构清晰、信息完整、适合直接阅读和导出的正式会议纪要。"
);
return String.join("\n\n",
"你是一名擅长中文会议纪要、结构化分析和待办提取的助手。",
"系统提示词(基础边界,优先级最高):\n" + systemPrompt,
"模板提示词(结构与风格要求):\n" + templatePrompt,
"输出要求:",
"1. 最终只能输出一个 JSON 对象,不要输出 Markdown 代码块、解释说明或额外前后缀。",
"2. JSON 必须包含 `summaryContent` 和 `analysis` 两个顶级字段。",
"3. `summaryContent` 必须是完整、自然、可直接保存和导出的正式会议纪要正文。",
"4. `analysis` 仅作为结构化附加结果,不能替代 `summaryContent`。",
"5. 如果系统提示词、模板提示词和用户提示词存在冲突,优先级为:系统提示词 > 模板提示词 > 用户提示词。"
"系统提示词(最高优先级):\n" + systemPrompt,
"模板提示词(结构和风格要求):\n" + templatePrompt,
"""
1. JSON Markdown
2. JSON `summaryContent` `analysis`
3. `summaryContent`
4. `analysis` `summaryContent`
5. > >
"""
);
}
public String buildUserMessage(Meeting meeting, String asrText, String userPrompt) {
public String buildUserMessage(Meeting meeting, String transcriptText, String userPrompt) {
return buildUserMessage(
meeting,
MeetingSummarySource.builder()
.text(transcriptText)
.rawTranscriptText(transcriptText)
.chapterOutlineText("")
.build(),
userPrompt
);
}
public String buildUserMessage(Meeting meeting, MeetingSummarySource summarySource, String userPrompt) {
String participants = meeting.getParticipants() == null || meeting.getParticipants().isBlank()
? "未填写"
: meeting.getParticipants();
String meetingTime = meeting.getMeetingTime() == null ? "未知" : meeting.getMeetingTime().toString();
String normalizedUserPrompt = normalizeOptionalText(userPrompt);
String rawTranscriptText = summarySource == null ? null : normalizeOptionalText(summarySource.getRawTranscriptText());
String chapterOutlineText = summarySource == null ? null : normalizeOptionalText(summarySource.getChapterOutlineText());
String fallbackText = summarySource == null ? null : normalizeOptionalText(summarySource.getText());
if (!StringUtils.hasText(rawTranscriptText)) {
rawTranscriptText = fallbackText;
}
if (!StringUtils.hasText(chapterOutlineText)) {
chapterOutlineText = "无章节辅助结构";
}
StringBuilder message = new StringBuilder()
.append("请基于以下会议转写内容生成会议纪要与结构化分析结果。\n")
.append("请基于以下会议信息、章节辅助结构和原始会议转录生成会议纪要与结构化分析结果。\n")
.append("会议信息:\n")
.append("标题:").append(StringUtils.hasText(meeting.getTitle()) ? meeting.getTitle() : "未命名会议").append("\n")
.append("会议时间:").append(meetingTime).append("\n")
@ -87,30 +118,35 @@ public class MeetingSummaryPromptAssembler {
.append("\n");
}
message.append("\n")
.append("返回 JSON格式固定如下\n")
.append("{\n")
.append(" \"summaryContent\": \"完整会议纪要正文,使用 markdown\",\n")
.append(" \"analysis\": {\n")
// .append(" \"overview\": \"会议概览\",\n")
.append(" \"keywords\": [\"关键词1\", \"关键词2\"],\n")
// .append(" \"chapters\": [{\"time\":\"00:00\",\"title\":\"章节标题\",\"summary\":\"章节摘要\"}],\n")
// .append(" \"speakerSummaries\": [{\"speaker\":\"发言人\",\"summary\":\"观点总结\"}],\n")
// .append(" \"keyPoints\": [{\"title\":\"关键点\",\"summary\":\"具体说明\",\"speaker\":\"发言人\"}],\n")
// .append(" \"todos\": [\"待办事项1\", \"待办事项2\"]\n")
.append(" }\n")
.append("}\n")
.append("要求:\n")
.append("1. `summaryContent` 必须优先遵循模板提示词中的结构、标题层级、章节顺序和写作风格。\n")
.append("2. `analysis.keywords` 必须基于完整转写内容生成,不得脱离上下文。并且在会议转写中能找到对应的原文\n")
// .append("3. 若无待办事项,`todos` 返回空数组。\n")
.append("3. 仅输出 JSON。\n")
.append("\n")
.append("会议转写如下:\n")
.append(asrText == null ? "" : asrText);
message.append("""
JSON
{
"summaryContent": "完整会议纪要正文,使用 markdown",
"analysis": {
"keywords": ["关键词1", "关键词2"]
}
}
1. `summaryContent`
2. `analysis.keywords`
3.
4. `analysis`
5. JSON
""").append(chapterOutlineText)
.append("\n\n")
.append("原始会议转录如下:\n")
.append(rawTranscriptText == null ? "" : rawTranscriptText);
return message.toString();
}
public String buildUserMessageTemplate(Meeting meeting, String userPrompt) {
return buildUserMessage(meeting, SUMMARY_SOURCE_PLACEHOLDER, userPrompt);
}
public String resolveSystemPrompt() {
String configured = sysParamService.getCachedParamValue(
SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT,

View File

@ -0,0 +1,881 @@
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.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
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.MeetingTranscriptChapter;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import com.imeeting.mapper.biz.MeetingTranscriptChapterMapper;
import com.imeeting.mapper.biz.MeetingTranscriptChapterVersionMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class MeetingTranscriptChapterServiceImpl implements MeetingTranscriptChapterService {
private static final String SOURCE_TYPE_CHAPTER_VERSION = "CHAPTER_VERSION";
private static final String SOURCE_TYPE_RAW_FALLBACK = "RAW_FALLBACK";
private static final String GENERATION_MODE_INTERNAL = "INTERNAL_LLM";
private static final String GENERATION_MODE_EXTERNAL = "EXTERNAL_IMPORT";
private static final String DEFAULT_ALGORITHM_VERSION = "chapter-llm-v1";
private static final String DEFAULT_GENERATOR_LABEL = "builtin-chapter-llm";
private static final String CHAPTER_RELATIVE_PATH_TEMPLATE = "meetings/%s/chapters/current.md";
private static final Pattern FIDELITY_POINT_PATTERN = Pattern.compile(
"(\\d{4}年\\d{1,2}月\\d{1,2}日|\\d{1,2}:\\d{2}|\\d+(?:\\.\\d+)?(?:万|亿|元|%||人|天|月|年))"
);
private final MeetingTranscriptMapper transcriptMapper;
private final MeetingTranscriptChapterVersionMapper versionMapper;
private final MeetingTranscriptChapterMapper chapterMapper;
private final ObjectMapper objectMapper;
private AiModelService aiModelService;
@Value("${imeeting.summary-orchestration.chapter-policy:INTERNAL_LLM}")
private String chapterGenerationPolicy;
@Value("${unisbase.app.upload-path}")
private String uploadPath;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(60))
.version(HttpClient.Version.HTTP_1_1)
.build();
@Autowired(required = false)
public void setAiModelService(AiModelService aiModelService) {
this.aiModelService = aiModelService;
}
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask) {
List<MeetingTranscript> transcripts = loadRawTranscripts(meeting.getId());
String transcriptText = buildTranscriptText(transcripts);
String fingerprint = buildSourceFingerprint(transcripts);
if (transcripts.isEmpty() || transcriptText.isBlank()) {
return MeetingSummarySource.builder()
.text(transcriptText)
.sourceType(SOURCE_TYPE_RAW_FALLBACK)
.fallbackUsed(true)
.sourceFingerprint(fingerprint)
.algorithmVersion(DEFAULT_ALGORITHM_VERSION)
.generationMode("NONE")
.rawTranscriptText(transcriptText)
.chapterOutlineText("")
.build();
}
MeetingTranscriptChapterVersion current = findReusableCurrentVersion(meeting.getId(), fingerprint);
if (current != null) {
return buildSummarySource(meeting, current, transcripts, fingerprint);
}
if ("EXTERNAL_IMPORT_REQUIRED".equalsIgnoreCase(chapterGenerationPolicy)) {
throw new RuntimeException("缺少外部章节化保真结果,无法继续总结");
}
MeetingTranscriptChapterVersion generated = generateInternalVersion(meeting, summaryTask, transcripts, fingerprint);
return buildSummarySource(meeting, generated, transcripts, fingerprint);
}
@Override
public List<Map<String, Object>> listCurrentChapterAnalysis(Long meetingId) {
MeetingTranscriptChapterVersion current = findCurrentVersion(meetingId);
if (current == null) {
return List.of();
}
List<MeetingTranscript> transcripts = loadRawTranscripts(meetingId);
Map<Long, MeetingTranscript> transcriptById = transcripts.stream()
.collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new));
return loadVersionChapters(current.getId()).stream()
.map(chapter -> toChapterAnalysis(chapter, transcriptById))
.toList();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void invalidateCurrentVersion(Long meetingId) {
versionMapper.update(null, new LambdaUpdateWrapper<MeetingTranscriptChapterVersion>()
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
.eq(MeetingTranscriptChapterVersion::getIsCurrent, 1)
.set(MeetingTranscriptChapterVersion::getIsCurrent, 0));
}
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingTranscriptChapterVersion importExternalChapters(Meeting meeting, AiTask sourceTask, MeetingTranscriptChapterImportDTO command) {
List<MeetingTranscript> transcripts = loadRawTranscripts(meeting.getId());
if (transcripts.isEmpty()) {
throw new RuntimeException("当前会议没有可用转录,无法导入章节");
}
if (command.getChapters() == null || command.getChapters().isEmpty()) {
throw new RuntimeException("章节导入数据不能为空");
}
String fingerprint = buildSourceFingerprint(transcripts);
List<ChapterCandidate> candidates = command.getChapters().stream()
.sorted(Comparator.comparing(MeetingTranscriptChapterImportDTO.ChapterItem::getChapterNo))
.map(item -> new ChapterCandidate(
item.getChapterNo(),
normalizeOptionalText(item.getTitle()),
normalizeOptionalText(item.getSummary()),
normalizeKeywords(item.getKeywords()),
item.getStartTranscriptId(),
item.getEndTranscriptId(),
item.getConfidence() == null ? BigDecimal.ONE : item.getConfidence()
))
.toList();
validateCandidatesAgainstTranscripts(candidates, transcripts);
MeetingTranscriptChapterVersion version = persistVersion(
meeting,
sourceTask,
fingerprintsafe(fingerprint),
nonBlank(command.getAlgorithmVersion(), "external-import-v1"),
GENERATION_MODE_EXTERNAL,
nonBlank(command.getChapterGeneratorLabel(), "external-import"),
transcripts,
candidates
);
writeCurrentChapterMarkdown(meeting, version, transcripts);
return version;
}
@Override
public MeetingTranscriptSourceVO buildTranscriptSource(Long meetingId) {
List<MeetingTranscript> transcripts = loadRawTranscripts(meetingId);
MeetingTranscriptSourceVO source = new MeetingTranscriptSourceVO();
source.setMeetingId(meetingId);
source.setSourceFingerprint(buildSourceFingerprint(transcripts));
source.setTranscriptText(buildTranscriptText(transcripts));
source.setSegments(transcripts.stream().map(this::toTranscriptVO).toList());
return source;
}
public MeetingTranscriptChapterVersion getCurrentVersion(Long meetingId) {
return findCurrentVersion(meetingId);
}
private MeetingTranscriptChapterVersion generateInternalVersion(Meeting meeting,
AiTask summaryTask,
List<MeetingTranscript> transcripts,
String fingerprint) {
List<MeetingTranscriptChapterImportDTO.ChapterItem> chapterItems = generateInternalChapterItems(summaryTask, transcripts);
if (chapterItems == null || chapterItems.isEmpty()) {
throw new RuntimeException("章节模型未返回有效章节结果,无法继续总结");
}
List<ChapterCandidate> candidates = chapterItems.stream()
.sorted(Comparator.comparing(MeetingTranscriptChapterImportDTO.ChapterItem::getChapterNo))
.map(item -> new ChapterCandidate(
item.getChapterNo(),
normalizeOptionalText(item.getTitle()),
normalizeOptionalText(item.getSummary()),
normalizeKeywords(item.getKeywords()),
item.getStartTranscriptId(),
item.getEndTranscriptId(),
item.getConfidence() == null ? BigDecimal.valueOf(0.88D) : item.getConfidence()
))
.toList();
Object chapterModelId = summaryTask == null || summaryTask.getTaskConfig() == null
? null
: summaryTask.getTaskConfig().get("chapterModelId");
String generatorLabel = chapterModelId == null ? DEFAULT_GENERATOR_LABEL : DEFAULT_GENERATOR_LABEL + "-" + chapterModelId;
validateCandidatesAgainstTranscripts(candidates, transcripts);
return persistVersion(
meeting,
summaryTask,
fingerprintsafe(fingerprint),
DEFAULT_ALGORITHM_VERSION,
GENERATION_MODE_INTERNAL,
generatorLabel,
transcripts,
candidates
);
}
protected List<MeetingTranscriptChapterImportDTO.ChapterItem> generateInternalChapterItems(AiTask summaryTask, List<MeetingTranscript> transcripts) {
if (aiModelService == null || summaryTask == null || summaryTask.getTaskConfig() == null) {
throw new RuntimeException("章节模型未配置,无法生成章节");
}
Long chapterModelId = longValue(summaryTask.getTaskConfig().get("chapterModelId"));
if (chapterModelId == null) {
chapterModelId = longValue(summaryTask.getTaskConfig().get("summaryModelId"));
}
if (chapterModelId == null) {
throw new RuntimeException("缺少 chapterModelId无法生成章节");
}
AiModelVO llmModel;
try {
llmModel = aiModelService.getModelById(chapterModelId, "LLM");
} catch (Exception ex) {
throw new RuntimeException("解析章节模型失败: " + ex.getMessage(), ex);
}
if (llmModel == null || !Integer.valueOf(1).equals(llmModel.getStatus())) {
throw new RuntimeException("章节模型不存在或未启用");
}
try {
Map<String, Object> requestBody = new LinkedHashMap<>();
requestBody.put("model", llmModel.getModelCode());
requestBody.put("temperature", llmModel.getTemperature());
requestBody.put("messages", List.of(
Map.of("role", "system", "content", buildChapterSystemPrompt()),
Map.of("role", "user", "content", buildChapterUserPrompt(transcripts))
));
String payload = objectMapper.writeValueAsString(requestBody);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(appendPath(llmModel.getBaseUrl(),
nonBlank(llmModel.getApiPath(), "v1/chat/completions"))))
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json")
.header("Authorization", "Bearer " + llmModel.getApiKey())
.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("章节模型调用失败HTTP " + response.statusCode());
}
JsonNode root = objectMapper.readTree(response.body());
String content = sanitizeResponseContent(root.path("choices").path(0).path("message").path("content").asText(""));
if (content.isBlank()) {
throw new RuntimeException("章节模型未返回内容");
}
JsonNode parsed = objectMapper.readTree(content);
JsonNode chaptersNode = parsed.path("chapters");
if (!chaptersNode.isArray()) {
throw new RuntimeException("章节模型返回格式不正确,缺少 chapters 数组");
}
List<MeetingTranscriptChapterImportDTO.ChapterItem> result = new ArrayList<>();
for (JsonNode item : chaptersNode) {
Long startTranscriptId = longValue(item.path("startTranscriptId").asText(null));
Long endTranscriptId = longValue(item.path("endTranscriptId").asText(null));
Integer chapterNo = item.path("chapterNo").isInt() ? item.path("chapterNo").asInt() : null;
if (chapterNo == null || startTranscriptId == null || endTranscriptId == null) {
throw new RuntimeException("章节模型返回了不完整的章节边界");
}
List<String> keywords = new ArrayList<>();
if (item.path("keywords").isArray()) {
for (JsonNode keyword : item.path("keywords")) {
String text = normalizeOptionalText(keyword.asText(""));
if (text != null && !keywords.contains(text)) {
keywords.add(text);
}
}
}
MeetingTranscriptChapterImportDTO.ChapterItem chapterItem = new MeetingTranscriptChapterImportDTO.ChapterItem();
chapterItem.setChapterNo(chapterNo);
chapterItem.setTitle(normalizeOptionalText(item.path("title").asText("")));
chapterItem.setSummary(normalizeOptionalText(item.path("summary").asText("")));
chapterItem.setKeywords(keywords);
chapterItem.setStartTranscriptId(startTranscriptId);
chapterItem.setEndTranscriptId(endTranscriptId);
chapterItem.setConfidence(item.path("confidence").isNumber() ? item.path("confidence").decimalValue() : BigDecimal.valueOf(0.88D));
result.add(chapterItem);
}
return result;
} catch (Exception ex) {
throw new RuntimeException("章节模型生成失败: " + ex.getMessage(), ex);
}
}
private String buildChapterSystemPrompt() {
return """
JSON
chapters
chapterNo,title,summary,keywords,startTranscriptId,endTranscriptId,confidence
transcript
""";
}
private String buildChapterUserPrompt(List<MeetingTranscript> transcripts) throws Exception {
List<Map<String, Object>> segments = new ArrayList<>();
for (MeetingTranscript transcript : transcripts) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("transcriptId", transcript.getId());
item.put("sortOrder", transcript.getSortOrder());
item.put("speakerName", transcript.getSpeakerName());
item.put("startTime", transcript.getStartTime());
item.put("endTime", transcript.getEndTime());
item.put("content", transcript.getContent());
segments.add(item);
}
return "请根据以下 transcript 分段识别章节边界并返回 JSON\n" + objectMapper.writeValueAsString(segments);
}
private MeetingTranscriptChapterVersion persistVersion(Meeting meeting,
AiTask sourceTask,
String sourceFingerprint,
String algorithmVersion,
String generationMode,
String generatorLabel,
List<MeetingTranscript> transcripts,
List<ChapterCandidate> candidates) {
invalidateCurrentVersion(meeting.getId());
MeetingTranscriptChapterVersion version = new MeetingTranscriptChapterVersion();
version.setTenantId(meeting.getTenantId());
version.setMeetingId(meeting.getId());
version.setSourceTaskId(sourceTask == null ? null : sourceTask.getId());
version.setVersionNo(resolveNextVersionNo(meeting.getId()));
version.setStatus(2);
version.setSourceFingerprint(sourceFingerprint);
version.setAlgorithmVersion(algorithmVersion);
version.setGenerationMode(generationMode);
version.setGeneratorLabel(generatorLabel);
version.setChapterCount(candidates.size());
version.setIsCurrent(1);
versionMapper.insert(version);
Map<Long, MeetingTranscript> transcriptById = transcripts.stream()
.collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new));
for (ChapterCandidate candidate : candidates) {
MeetingTranscript start = transcriptById.get(candidate.startTranscriptId());
MeetingTranscript end = transcriptById.get(candidate.endTranscriptId());
MeetingTranscriptChapter chapter = new MeetingTranscriptChapter();
chapter.setTenantId(meeting.getTenantId());
chapter.setVersionId(version.getId());
chapter.setChapterNo(candidate.chapterNo());
chapter.setTitle(candidate.title());
chapter.setSummary(candidate.summary());
chapter.setKeywordsJson(writeKeywords(candidate.keywords()));
chapter.setStartTranscriptId(candidate.startTranscriptId());
chapter.setEndTranscriptId(candidate.endTranscriptId());
chapter.setStartSortOrder(start == null ? null : start.getSortOrder());
chapter.setEndSortOrder(end == null ? null : end.getSortOrder());
chapter.setStartTime(resolveStartTime(start));
chapter.setEndTime(resolveEndTime(end));
chapter.setSegmentCount(countSegmentsInRange(transcripts, candidate.startTranscriptId(), candidate.endTranscriptId()));
chapter.setConfidence(candidate.confidence());
chapterMapper.insert(chapter);
}
return version;
}
private MeetingSummarySource buildSummarySource(Meeting meeting,
MeetingTranscriptChapterVersion version,
List<MeetingTranscript> transcripts,
String fingerprint) {
Map<Long, MeetingTranscript> transcriptById = transcripts.stream()
.collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new));
List<MeetingTranscriptChapter> chapterEntities = loadVersionChapters(version.getId());
List<Map<String, Object>> chapters = chapterEntities.stream()
.map(chapter -> toChapterAnalysis(chapter, transcriptById))
.toList();
String chapterOutlineText = buildChapterOutlineText(chapterEntities, transcriptById);
String rawTranscriptText = buildTranscriptText(transcripts);
String text = buildCombinedSummaryInputText(chapterOutlineText, rawTranscriptText);
String chapterFilePath = writeCurrentChapterMarkdown(meeting, version, chapterEntities, transcriptById);
return MeetingSummarySource.builder()
.text(text)
.sourceType(SOURCE_TYPE_CHAPTER_VERSION)
.fallbackUsed(false)
.sourceFingerprint(fingerprint)
.chapterVersionId(version.getId())
.chapterCount(version.getChapterCount())
.algorithmVersion(version.getAlgorithmVersion())
.generationMode(version.getGenerationMode())
.rawTranscriptText(rawTranscriptText)
.chapterOutlineText(chapterOutlineText)
.chapterFilePath(chapterFilePath)
.chapters(chapters)
.build();
}
private String writeCurrentChapterMarkdown(Meeting meeting,
MeetingTranscriptChapterVersion version,
List<MeetingTranscript> transcripts) {
Map<Long, MeetingTranscript> transcriptById = transcripts.stream()
.collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new));
List<MeetingTranscriptChapter> chapters = loadVersionChapters(version.getId());
return writeCurrentChapterMarkdown(meeting, version, chapters, transcriptById);
}
private String writeCurrentChapterMarkdown(Meeting meeting,
MeetingTranscriptChapterVersion version,
List<MeetingTranscriptChapter> chapters,
Map<Long, MeetingTranscript> transcriptById) {
try {
String relativePath = CHAPTER_RELATIVE_PATH_TEMPLATE.formatted(meeting.getId());
String basePath = uploadPath.endsWith("/") || uploadPath.endsWith("\\") ? uploadPath : uploadPath + "/";
Path targetPath = Paths.get(basePath, relativePath.replace("\\", "/"));
Path parent = targetPath.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(targetPath, buildChapterMarkdown(meeting, version, chapters, transcriptById), StandardCharsets.UTF_8);
return relativePath;
} catch (Exception ex) {
throw new RuntimeException("保存会议章节文件失败", ex);
}
}
private String buildChapterMarkdown(Meeting meeting,
MeetingTranscriptChapterVersion version,
List<MeetingTranscriptChapter> chapters,
Map<Long, MeetingTranscript> transcriptById) {
String nowText = LocalDateTime.now().toString();
StringBuilder builder = new StringBuilder();
builder.append("---\n");
builder.append("updatedAt: ").append(nowText).append("\n");
builder.append("meetingId: ").append(meeting.getId()).append("\n");
builder.append("chapterVersionId: ").append(version.getId()).append("\n");
if (version.getVersionNo() != null) {
builder.append("versionNo: ").append(version.getVersionNo()).append("\n");
}
builder.append("sourceFingerprint: ").append(nonBlank(version.getSourceFingerprint(), "")).append("\n");
builder.append("generationMode: ").append(nonBlank(version.getGenerationMode(), "")).append("\n");
builder.append("algorithmVersion: ").append(nonBlank(version.getAlgorithmVersion(), "")).append("\n");
builder.append("---\n\n");
builder.append("# ").append(nonBlank(meeting.getTitle(), "会议")).append(" 章节目录\n\n");
if (version.getVersionNo() != null) {
builder.append("- 章节版本V").append(version.getVersionNo()).append("\n");
}
if (nonBlank(version.getGenerationMode()) != null) {
builder.append("- 生成方式:").append(version.getGenerationMode()).append("\n");
}
if (nonBlank(version.getAlgorithmVersion()) != null) {
builder.append("- 算法版本:").append(version.getAlgorithmVersion()).append("\n");
}
builder.append("- 更新时间:").append(nowText).append("\n\n");
builder.append("## 章节内容\n\n");
if (chapters == null || chapters.isEmpty()) {
builder.append("_当前暂无章节内容_\n");
return builder.toString();
}
for (MeetingTranscriptChapter chapter : chapters) {
String title = nonBlank(chapter.getTitle(), "第" + chapter.getChapterNo() + "章");
builder.append("### 第").append(chapter.getChapterNo()).append("章 ").append(title).append("\n");
builder.append("- 时间范围:").append(formatTimeRange(chapter.getStartTime(), chapter.getEndTime())).append("\n");
List<String> keywords = readKeywords(chapter.getKeywordsJson());
if (!keywords.isEmpty()) {
builder.append("- 关键词:").append(String.join("、", keywords)).append("\n");
}
if (chapter.getSummary() != null && !chapter.getSummary().isBlank()) {
builder.append("- 章节摘要:").append(chapter.getSummary().trim()).append("\n");
}
List<String> fidelityPoints = extractFidelityPoints(resolveTranscriptsInRange(chapter, transcriptById));
if (!fidelityPoints.isEmpty()) {
builder.append("- 保真锚点:").append(String.join("、", fidelityPoints)).append("\n");
}
builder.append("\n");
}
return builder.toString().trim() + "\n";
}
private String buildCombinedSummaryInputText(String chapterOutlineText, String rawTranscriptText) {
StringBuilder builder = new StringBuilder();
if (chapterOutlineText != null && !chapterOutlineText.isBlank()) {
builder.append("【章节辅助结构】\n").append(chapterOutlineText.trim());
}
if (rawTranscriptText != null && !rawTranscriptText.isBlank()) {
if (builder.length() > 0) {
builder.append("\n\n");
}
builder.append("【原始转录】\n").append(rawTranscriptText.trim());
}
return builder.toString().trim();
}
private String buildChapterOutlineText(List<MeetingTranscriptChapter> chapters, Map<Long, MeetingTranscript> transcriptById) {
StringBuilder builder = new StringBuilder();
for (MeetingTranscriptChapter chapter : chapters) {
if (builder.length() > 0) {
builder.append("\n\n");
}
String title = nonBlank(chapter.getTitle(), "第" + chapter.getChapterNo() + "章");
builder.append("### 第").append(chapter.getChapterNo()).append("章 ").append(title).append("\n");
builder.append("时间范围:").append(formatTimeRange(chapter.getStartTime(), chapter.getEndTime())).append("\n");
if (chapter.getSummary() != null && !chapter.getSummary().isBlank()) {
builder.append("章节摘要:").append(chapter.getSummary().trim()).append("\n");
}
List<String> fidelityPoints = extractFidelityPoints(resolveTranscriptsInRange(chapter, transcriptById));
if (!fidelityPoints.isEmpty()) {
builder.append("章节导航保真锚点:").append(String.join("、", fidelityPoints)).append("\n");
}
}
return builder.toString().trim();
}
private Map<String, Object> toChapterAnalysis(MeetingTranscriptChapter chapter, Map<Long, MeetingTranscript> transcriptById) {
List<MeetingTranscript> range = resolveTranscriptsInRange(chapter, transcriptById);
Map<String, Object> item = new LinkedHashMap<>();
item.put("chapterNo", chapter.getChapterNo());
item.put("title", nonBlank(chapter.getTitle(), "第" + chapter.getChapterNo() + "章"));
item.put("summary", nonBlank(chapter.getSummary(), ""));
item.put("time", formatTimeRange(chapter.getStartTime(), chapter.getEndTime()));
item.put("startTime", chapter.getStartTime());
item.put("endTime", chapter.getEndTime());
item.put("startTranscriptId", chapter.getStartTranscriptId());
item.put("endTranscriptId", chapter.getEndTranscriptId());
item.put("startSortOrder", chapter.getStartSortOrder());
item.put("endSortOrder", chapter.getEndSortOrder());
item.put("confidence", chapter.getConfidence());
item.put("keywords", readKeywords(chapter.getKeywordsJson()));
item.put("fidelityPoints", extractFidelityPoints(range));
item.put("sourceTranscriptIds", range.stream().map(MeetingTranscript::getId).toList());
return item;
}
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 MeetingTranscriptChapterVersion findReusableCurrentVersion(Long meetingId, String fingerprint) {
return versionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>()
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
.eq(MeetingTranscriptChapterVersion::getIsCurrent, 1)
.eq(MeetingTranscriptChapterVersion::getStatus, 2)
.eq(MeetingTranscriptChapterVersion::getSourceFingerprint, fingerprint)
.orderByDesc(MeetingTranscriptChapterVersion::getVersionNo)
.last("limit 1"));
}
private MeetingTranscriptChapterVersion findCurrentVersion(Long meetingId) {
return versionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>()
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
.eq(MeetingTranscriptChapterVersion::getIsCurrent, 1)
.eq(MeetingTranscriptChapterVersion::getStatus, 2)
.orderByDesc(MeetingTranscriptChapterVersion::getVersionNo)
.last("limit 1"));
}
private List<MeetingTranscriptChapter> loadVersionChapters(Long versionId) {
return chapterMapper.selectList(new LambdaQueryWrapper<MeetingTranscriptChapter>()
.eq(MeetingTranscriptChapter::getVersionId, versionId)
.orderByAsc(MeetingTranscriptChapter::getChapterNo)
.orderByAsc(MeetingTranscriptChapter::getId));
}
private int resolveNextVersionNo(Long meetingId) {
MeetingTranscriptChapterVersion latest = versionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>()
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
.orderByDesc(MeetingTranscriptChapterVersion::getVersionNo)
.last("limit 1"));
return latest == null || latest.getVersionNo() == null ? 1 : latest.getVersionNo() + 1;
}
private void validateCandidatesAgainstTranscripts(List<ChapterCandidate> candidates, List<MeetingTranscript> transcripts) {
if (candidates == null || candidates.isEmpty()) {
throw new RuntimeException("章节结果不能为空");
}
Map<Long, Integer> indexByTranscriptId = new LinkedHashMap<>();
for (int index = 0; index < transcripts.size(); index++) {
indexByTranscriptId.put(transcripts.get(index).getId(), index);
}
int expectedStartIndex = 0;
for (ChapterCandidate candidate : candidates.stream().sorted(Comparator.comparing(ChapterCandidate::chapterNo)).toList()) {
Integer startIndex = indexByTranscriptId.get(candidate.startTranscriptId());
Integer endIndex = indexByTranscriptId.get(candidate.endTranscriptId());
if (startIndex == null || endIndex == null) {
throw new RuntimeException("章节边界引用了不存在的 transcript");
}
if (startIndex > endIndex) {
throw new RuntimeException("章节边界顺序非法");
}
if (startIndex != expectedStartIndex) {
throw new RuntimeException("章节未完整覆盖全部转录,存在断档或重叠");
}
expectedStartIndex = endIndex + 1;
}
if (expectedStartIndex != transcripts.size()) {
throw new RuntimeException("章节未完整覆盖全部转录");
}
}
private List<MeetingTranscript> resolveTranscriptsInRange(MeetingTranscriptChapter chapter, Map<Long, MeetingTranscript> transcriptById) {
List<MeetingTranscript> ordered = new ArrayList<>(transcriptById.values());
int startIndex = -1;
int endIndex = -1;
for (int index = 0; index < ordered.size(); index++) {
Long transcriptId = ordered.get(index).getId();
if (Objects.equals(transcriptId, chapter.getStartTranscriptId())) {
startIndex = index;
}
if (Objects.equals(transcriptId, chapter.getEndTranscriptId())) {
endIndex = index;
}
}
if (startIndex < 0 || endIndex < startIndex) {
return List.of();
}
return ordered.subList(startIndex, endIndex + 1);
}
private int countSegmentsInRange(List<MeetingTranscript> transcripts, Long startTranscriptId, Long endTranscriptId) {
boolean started = false;
int count = 0;
for (MeetingTranscript transcript : transcripts) {
if (Objects.equals(transcript.getId(), startTranscriptId)) {
started = true;
}
if (started) {
count++;
}
if (Objects.equals(transcript.getId(), endTranscriptId)) {
break;
}
}
return count;
}
private String buildSourceFingerprint(List<MeetingTranscript> transcripts) {
String raw = transcripts.stream()
.map(transcript -> String.join("|",
String.valueOf(transcript.getId()),
String.valueOf(transcript.getSortOrder()),
nonBlank(transcript.getSpeakerId(), ""),
nonBlank(transcript.getSpeakerName(), ""),
nonBlank(transcript.getContent(), ""),
String.valueOf(transcript.getStartTime()),
String.valueOf(transcript.getEndTime())))
.collect(Collectors.joining("\n"));
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder();
for (byte item : bytes) {
builder.append(String.format("%02x", item));
}
return builder.toString();
} catch (Exception ex) {
throw new RuntimeException("计算转录指纹失败", ex);
}
}
private String buildTranscriptText(List<MeetingTranscript> transcripts) {
return transcripts.stream()
.map(this::formatTranscriptLine)
.filter(Objects::nonNull)
.filter(text -> !text.isBlank())
.collect(Collectors.joining("\n"));
}
private String formatTranscriptLine(MeetingTranscript transcript) {
if (transcript == null || transcript.getContent() == null || transcript.getContent().isBlank()) {
return null;
}
String speaker = nonBlank(transcript.getSpeakerName(), transcript.getSpeakerId());
if (speaker == null) {
return transcript.getContent().trim();
}
return speaker + ": " + transcript.getContent().trim();
}
private MeetingTranscriptVO toTranscriptVO(MeetingTranscript transcript) {
MeetingTranscriptVO vo = new MeetingTranscriptVO();
vo.setId(transcript.getId());
vo.setSpeakerId(transcript.getSpeakerId());
vo.setSpeakerName(transcript.getSpeakerName());
vo.setSpeakerLabel(transcript.getSpeakerLabel());
vo.setContent(transcript.getContent());
vo.setStartTime(transcript.getStartTime());
vo.setEndTime(transcript.getEndTime());
return vo;
}
private List<String> extractFidelityPoints(List<MeetingTranscript> transcripts) {
Set<String> result = new LinkedHashSet<>();
for (MeetingTranscript transcript : transcripts) {
String content = transcript.getContent();
if (content == null || content.isBlank()) {
continue;
}
Matcher matcher = FIDELITY_POINT_PATTERN.matcher(content);
while (matcher.find()) {
String point = normalizeOptionalText(matcher.group(1));
if (point != null) {
result.add(point);
}
}
}
return new ArrayList<>(result);
}
private String writeKeywords(List<String> keywords) {
try {
return objectMapper.writeValueAsString(normalizeKeywords(keywords));
} catch (Exception ex) {
return "[]";
}
}
private List<String> readKeywords(String keywordsJson) {
if (keywordsJson == null || keywordsJson.isBlank()) {
return List.of();
}
try {
return normalizeKeywords(objectMapper.readValue(keywordsJson, new TypeReference<List<String>>() {}));
} catch (Exception ex) {
return List.of();
}
}
private List<String> normalizeKeywords(List<String> keywords) {
return keywords == null ? List.of() : keywords.stream()
.map(this::normalizeOptionalText)
.filter(Objects::nonNull)
.distinct()
.toList();
}
private String sanitizeResponseContent(String content) {
if (content == null) {
return "";
}
String normalized = content.trim();
if (!normalized.startsWith("```")) {
return normalized;
}
int firstBreak = normalized.indexOf('\n');
int lastFence = normalized.lastIndexOf("\n```");
if (firstBreak < 0 || lastFence <= firstBreak) {
return normalized;
}
return normalized.substring(firstBreak + 1, lastFence).trim();
}
private String appendPath(String baseUrl, String path) {
String normalizedBaseUrl = baseUrl == null ? "" : baseUrl.trim();
String normalizedPath = path == null ? "" : path.trim();
while (normalizedPath.startsWith("/")) {
normalizedPath = normalizedPath.substring(1);
}
if (normalizedPath.startsWith("http://") || normalizedPath.startsWith("https://")) {
return normalizedPath;
}
if (normalizedBaseUrl.endsWith("/")) {
return normalizedBaseUrl + normalizedPath;
}
return normalizedBaseUrl + "/" + normalizedPath;
}
private String formatTimeRange(Integer startTime, Integer endTime) {
return formatTime(startTime) + "-" + formatTime(endTime);
}
private String formatTime(Integer millis) {
long safeMillis = millis == null || millis < 0 ? 0L : millis;
long totalSeconds = safeMillis / 1000L;
long hours = totalSeconds / 3600L;
long minutes = (totalSeconds % 3600L) / 60L;
long seconds = totalSeconds % 60L;
if (hours > 0) {
return String.format(Locale.ROOT, "%02d:%02d:%02d", hours, minutes, seconds);
}
return String.format(Locale.ROOT, "%02d:%02d", minutes, seconds);
}
private Integer resolveStartTime(MeetingTranscript transcript) {
if (transcript == null) {
return null;
}
return transcript.getStartTime() != null ? transcript.getStartTime() : transcript.getEndTime();
}
private Integer resolveEndTime(MeetingTranscript transcript) {
if (transcript == null) {
return null;
}
return transcript.getEndTime() != null ? transcript.getEndTime() : transcript.getStartTime();
}
private String nonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
String normalized = normalizeOptionalText(value);
if (normalized != null) {
return normalized;
}
}
return null;
}
private String normalizeOptionalText(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isEmpty() ? null : normalized;
}
private Long longValue(Object value) {
if (value == null) {
return null;
}
try {
return Long.parseLong(String.valueOf(value).trim());
} catch (Exception ex) {
return null;
}
}
private String fingerprintsafe(String fingerprint) {
return fingerprint == null ? "" : fingerprint;
}
private record ChapterCandidate(Integer chapterNo,
String title,
String summary,
List<String> keywords,
Long startTranscriptId,
Long endTranscriptId,
BigDecimal confidence) {
}
}

View File

@ -49,6 +49,8 @@ unisbase:
- biz_llm_models
- biz_asr_models
- biz_prompt_templates
- biz_meeting_transcript_chapter_versions
- biz_meeting_transcript_chapters
- biz_client_downloads
- biz_external_apps
security:
@ -83,6 +85,8 @@ unisbase:
refresh-default-days: 7
imeeting:
summary-orchestration:
mode: INTERNAL_BUILTIN
realtime:
resume-window-minutes: 30
empty-session-retention-minutes: 720

View File

@ -10,6 +10,7 @@ import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
import com.imeeting.support.TaskSecurityContextRunner;
@ -97,7 +98,8 @@ class AiTaskServiceImplTest {
redisTemplate,
new TaskSecurityContextRunner(),
mock(MeetingTranscriptFileService.class),
mock(MeetingTranscriptRevisionService.class)
mock(MeetingTranscriptRevisionService.class),
mock(MeetingTranscriptChapterService.class)
));
doReturn(true).when(service).updateById(any());
@ -116,7 +118,7 @@ class AiTaskServiceImplTest {
summaryTask.setMeetingId(66L);
summaryTask.setTaskType("SUMMARY");
summaryTask.setStatus(0);
doReturn(null, summaryTask).when(service).getOne(any());
doReturn(null, null, summaryTask).when(service).getOne(any());
service.dispatchTasks(66L, 1L, 2L);
@ -131,18 +133,16 @@ class AiTaskServiceImplTest {
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
AiModelService aiModelService = mock(AiModelService.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
MeetingTranscriptRevisionService revisionService = mock(MeetingTranscriptRevisionService.class);
MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
when(revisionService.resolveSummarySource(any(), any(), any())).thenReturn(MeetingSummarySource.builder()
when(chapterService.resolveSummarySource(any(), any())).thenReturn(MeetingSummarySource.builder()
.text(" ")
.sourceType("RAW_FALLBACK")
.fallbackUsed(true)
.triggerTaskType("SUMMARY")
.semanticCorrector("NONE_V1")
.ruleProfileVersion("v1")
.algorithmVersion("cohesion-v1")
.build());
AiTaskServiceImpl service = spy(createService(
@ -152,7 +152,8 @@ class AiTaskServiceImplTest {
redisTemplate,
new TaskSecurityContextRunner(),
mock(MeetingTranscriptFileService.class),
revisionService
mock(MeetingTranscriptRevisionService.class),
chapterService
));
doReturn(true).when(service).updateById(any());
@ -160,12 +161,17 @@ class AiTaskServiceImplTest {
meeting.setId(77L);
when(meetingMapper.selectById(77L)).thenReturn(meeting);
AiTask chapterTask = new AiTask();
chapterTask.setId(88L);
chapterTask.setMeetingId(77L);
chapterTask.setTaskType("CHAPTER");
chapterTask.setStatus(0);
AiTask summaryTask = new AiTask();
summaryTask.setId(100L);
summaryTask.setMeetingId(77L);
summaryTask.setTaskType("SUMMARY");
summaryTask.setStatus(0);
doReturn(summaryTask, null).when(service).getOne(any());
doReturn(chapterTask, summaryTask).when(service).getOne(any());
service.dispatchSummaryTask(77L, 1L, 2L);
@ -174,6 +180,47 @@ class AiTaskServiceImplTest {
verify(aiModelService, never()).getModelById(anyLong(), anyString());
}
@Test
void dispatchSummaryTaskShouldWaitForExternalOrchestrationWhenExternalModeEnabled() {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
AiModelService aiModelService = mock(AiModelService.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
AiTaskServiceImpl service = spy(createService(
meetingMapper,
transcriptMapper,
aiModelService,
redisTemplate,
new TaskSecurityContextRunner(),
mock(MeetingTranscriptFileService.class),
mock(MeetingTranscriptRevisionService.class),
chapterService
));
ReflectionTestUtils.setField(service, "summaryOrchestrationMode", "EXTERNAL_N8N");
Meeting meeting = new Meeting();
meeting.setId(78L);
when(meetingMapper.selectById(78L)).thenReturn(meeting);
AiTask summaryTask = new AiTask();
summaryTask.setId(101L);
summaryTask.setMeetingId(78L);
summaryTask.setTaskType("SUMMARY");
summaryTask.setStatus(0);
doReturn(summaryTask, null).when(service).getOne(any());
service.dispatchSummaryTask(78L, 1L, 2L);
verify(chapterService, never()).resolveSummarySource(any(), any());
verify(aiModelService, never()).getModelById(anyLong(), anyString());
}
@Test
void saveTranscriptsShouldInitializeTranscriptFileAfterFirstPersist() throws Exception {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
@ -186,7 +233,8 @@ class AiTaskServiceImplTest {
mock(StringRedisTemplate.class),
mock(TaskSecurityContextRunner.class),
transcriptFileService,
mock(MeetingTranscriptRevisionService.class)
mock(MeetingTranscriptRevisionService.class),
mock(MeetingTranscriptChapterService.class)
);
Meeting meeting = new Meeting();
@ -222,7 +270,8 @@ class AiTaskServiceImplTest {
mock(StringRedisTemplate.class),
mock(TaskSecurityContextRunner.class),
mock(MeetingTranscriptFileService.class),
mock(MeetingTranscriptRevisionService.class)
mock(MeetingTranscriptRevisionService.class),
mock(MeetingTranscriptChapterService.class)
);
}
@ -232,7 +281,8 @@ class AiTaskServiceImplTest {
StringRedisTemplate redisTemplate,
TaskSecurityContextRunner taskSecurityContextRunner,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService revisionService) {
MeetingTranscriptRevisionService revisionService,
MeetingTranscriptChapterService chapterService) {
return new AiTaskServiceImpl(
meetingMapper,
transcriptMapper,
@ -244,6 +294,7 @@ class AiTaskServiceImplTest {
mock(MeetingSummaryFileService.class),
meetingTranscriptFileService,
revisionService,
chapterService,
mock(MeetingSummaryPromptAssembler.class),
taskSecurityContextRunner
);

View File

@ -1,6 +1,7 @@
package com.imeeting.service.biz.impl;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.service.biz.PromptTemplateService;
@ -35,6 +36,7 @@ class MeetingSummaryPromptAssemblerTest {
Map<String, Object> taskConfig = assembler.buildTaskConfig(2L, 3L, " 关注风险项 ");
assertEquals(2L, taskConfig.get("summaryModelId"));
assertEquals(2L, taskConfig.get("chapterModelId"));
assertEquals(3L, taskConfig.get("promptId"));
assertEquals("v2", taskConfig.get("promptSchemaVersion"));
assertEquals("系统提示词", taskConfig.get("effectiveSystemPrompt"));
@ -69,10 +71,35 @@ class MeetingSummaryPromptAssemblerTest {
meeting.setMeetingTime(LocalDateTime.of(2026, 4, 16, 10, 0));
meeting.setParticipants("张三,李四");
String userMessage = assembler.buildUserMessage(meeting, "这里是转文本", " ");
String userMessage = assembler.buildUserMessage(meeting, "这里是转文本", " ");
assertFalse(userMessage.contains("用户提示词"));
assertTrue(userMessage.contains("这里是转写文本"));
assertTrue(userMessage.contains("这里是转录文本"));
}
@Test
void buildUserMessageShouldSeparateChapterOutlineAndRawTranscript() {
MeetingSummaryPromptAssembler assembler = new MeetingSummaryPromptAssembler(
mock(PromptTemplateService.class),
mock(SysParamService.class)
);
Meeting meeting = new Meeting();
meeting.setTitle("预算会");
meeting.setMeetingTime(LocalDateTime.of(2026, 5, 8, 10, 0));
meeting.setParticipants("Alice,Bob");
MeetingSummarySource source = MeetingSummarySource.builder()
.chapterOutlineText("第1章 预算评审\n章节摘要讨论320万预算")
.rawTranscriptText("Alice: 今天讨论320万预算时间定在2026年5月7日15:30。")
.build();
String userMessage = assembler.buildUserMessage(meeting, source, "关注预算结论");
assertTrue(userMessage.contains("章节辅助结构如下"));
assertTrue(userMessage.contains("第1章 预算评审"));
assertTrue(userMessage.contains("原始会议转录如下"));
assertTrue(userMessage.contains("Alice: 今天讨论320万预算"));
assertTrue(userMessage.contains("最终不要在 `analysis` 中返回章节列表"));
}
@Test

View File

@ -251,6 +251,18 @@ export interface MeetingPreviewAccessVO {
passwordRequired: boolean;
}
export interface MeetingChapterVO {
chapterNo?: number;
title?: string;
summary?: string;
time?: string;
startTime?: number;
endTime?: number;
startTranscriptId?: number;
endTranscriptId?: number;
sourceTranscriptIds?: number[];
}
export interface PublicMeetingPreviewVO {
meeting: MeetingVO;
transcripts: MeetingTranscriptVO[];
@ -271,6 +283,12 @@ export const getTranscripts = (id: number) => {
);
};
export const getMeetingChapters = (id: number) => {
return http.get<{ code: string; data: MeetingChapterVO[]; msg: string }>(
`/api/biz/meeting/${id}/chapters`
);
};
export const getMeetingPreviewAccess = (id: number) => {
return http.get<{ code: string; data: MeetingPreviewAccessVO; msg: string }>(
`/api/public/meetings/${id}/preview/access`

View File

@ -29,8 +29,10 @@ import {
downloadMeetingTranscript,
downloadMeetingSummary,
getMeetingDetail,
getMeetingChapters,
getMeetingProgress,
getTranscripts,
MeetingChapterVO,
MeetingProgress,
MeetingTranscriptVO,
MeetingVO,
@ -82,6 +84,17 @@ type MeetingAnalysis = {
todos: string[];
};
type WorkspaceTab = 'catalog' | 'transcript';
type ChapterTranscriptLink = {
key: string;
title: string;
timeLabel: string;
transcriptIds: number[];
firstTranscriptId: number | null;
firstTranscriptStartTime: number | null;
};
const ANALYSIS_EMPTY: MeetingAnalysis = {
overview: '',
keywords: [],
@ -318,6 +331,25 @@ function formatPlayerTime(seconds: number) {
return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
}
function parseChapterTimeToMs(value?: string) {
const raw = String(value || '').trim();
if (!raw) return null;
const matched = raw.match(/(\d{1,2}:\d{2}(?::\d{2})?)/)?.[1];
if (!matched) return null;
const parts = matched.split(':').map((item) => Number(item));
if (parts.some((item) => Number.isNaN(item))) {
return null;
}
const totalSeconds = parts.length === 3
? (parts[0] * 3600) + (parts[1] * 60) + parts[2]
: (parts[0] * 60) + parts[1];
return totalSeconds * 1000;
}
/**
* Markdown
*/
@ -602,12 +634,14 @@ type ActiveTranscriptRowProps = {
isEditing: boolean;
isSaving: boolean;
speakerLabelMap: Map<string, string>;
onSeek: (timeMs: number) => void;
onPlay: (timeMs: number) => void;
onStartEdit: (item: MeetingTranscriptVO, event: React.MouseEvent) => void;
onDraftBlur: (item: MeetingTranscriptVO, value: string) => void;
onDraftKeyDown: (item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onSpeakerUpdated: () => void;
registerRow: (id: number, node: HTMLDivElement | null) => void;
isActive: boolean;
isLinkedHighlight: boolean;
audioPlaying: boolean;
highlightKeyword?: string;
};
@ -619,12 +653,14 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
isEditing,
isSaving,
speakerLabelMap,
onSeek,
onPlay,
onStartEdit,
onDraftBlur,
onDraftKeyDown,
onSpeakerUpdated,
registerRow,
isActive,
isLinkedHighlight,
audioPlaying,
highlightKeyword = '',
}) => {
@ -648,8 +684,17 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
return (
<List.Item className={`transcript-row ${isActive ? 'active' : ''}`} onClick={() => onSeek(item.startTime)}>
<div className="transcript-entry" ref={rowRef}>
<List.Item
className={`transcript-row ${isActive ? 'active' : ''} ${isLinkedHighlight ? 'linked' : ''}`}
onClick={() => onPlay(item.startTime)}
>
<div
className="transcript-entry"
ref={(node) => {
rowRef.current = node;
registerRow(item.id, node);
}}
>
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
<div className="transcript-content-wrap">
<div className="transcript-meta">
@ -716,12 +761,14 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
&& prevProps.isEditing === nextProps.isEditing
&& prevProps.isSaving === nextProps.isSaving
&& prevProps.speakerLabelMap === nextProps.speakerLabelMap
&& prevProps.onSeek === nextProps.onSeek
&& prevProps.onPlay === nextProps.onPlay
&& prevProps.onStartEdit === nextProps.onStartEdit
&& prevProps.onDraftBlur === nextProps.onDraftBlur
&& prevProps.onDraftKeyDown === nextProps.onDraftKeyDown
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
&& prevProps.registerRow === nextProps.registerRow
&& prevProps.isActive === nextProps.isActive
&& prevProps.isLinkedHighlight === nextProps.isLinkedHighlight
&& prevProps.audioPlaying === nextProps.audioPlaying
&& prevProps.highlightKeyword === nextProps.highlightKeyword
));
@ -736,6 +783,7 @@ const MeetingDetail: React.FC = () => {
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [meetingChapters, setMeetingChapters] = useState<MeetingChapterVO[]>([]);
const [loading, setLoading] = useState(true);
const [editVisible, setEditVisible] = useState(false);
const [summaryVisible, setSummaryVisible] = useState(false);
@ -747,6 +795,7 @@ const MeetingDetail: React.FC = () => {
const [expandKeywords, setExpandKeywords] = useState(false);
const [expandSummary, setExpandSummary] = useState(false);
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('catalog');
const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
@ -759,7 +808,8 @@ const MeetingDetail: React.FC = () => {
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
const [sharePasswordDraft, setSharePasswordDraft] = useState('');
const [highlightKeyword, setHighlightKeyword] = useState('');
const [highlightTimestamp, setHighlightTimestamp] = useState<number | null>(null);
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
@ -772,6 +822,7 @@ const MeetingDetail: React.FC = () => {
const summaryPdfRef = useRef<HTMLDivElement>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const pendingTranscriptScrollIdRef = useRef<number | null>(null);
const leftColumnRef = useRef<HTMLDivElement>(null);
const transcriptSectionRef = useRef<HTMLDivElement>(null);
const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false);
@ -779,9 +830,14 @@ const MeetingDetail: React.FC = () => {
const fetchData = useCallback(async (meetingId: number) => {
try {
const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
const [detailRes, transcriptRes, chapterRes] = await Promise.all([
getMeetingDetail(meetingId),
getTranscripts(meetingId),
getMeetingChapters(meetingId),
]);
setMeeting(detailRes.data.data);
setTranscripts(transcriptRes.data.data || []);
setMeetingChapters(chapterRes.data.data || []);
} catch (error) {
console.error(error);
} finally {
@ -827,6 +883,68 @@ const MeetingDetail: React.FC = () => {
() => resolveAudioExtension(playbackAudioUrl).toUpperCase(),
[playbackAudioUrl],
);
const keywordItems = useMemo(
() => (analysis.keywords.length ? visibleKeywords : meetingTags),
[analysis.keywords.length, meetingTags, visibleKeywords],
);
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
const sourceChapters = meetingChapters.length
? meetingChapters
: analysis.chapters.map((item) => ({
title: item.title,
time: item.time,
}));
return sourceChapters.map((chapter, index) => {
let matchedTranscripts: MeetingTranscriptVO[] = [];
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
? chapter.sourceTranscriptIds
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item))
: [];
if (sourceTranscriptIds.length) {
matchedTranscripts = sourceTranscriptIds
.map((item) => transcripts[transcriptIdToIndex.get(item)!])
.filter(Boolean);
} else if (chapter.startTranscriptId && chapter.endTranscriptId) {
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
const endIndex = transcriptIdToIndex.get(Number(chapter.endTranscriptId));
if (startIndex !== undefined && endIndex !== undefined) {
matchedTranscripts = transcripts.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex) + 1);
}
} else {
const startMs = typeof chapter.startTime === 'number' ? chapter.startTime : parseChapterTimeToMs(chapter.time);
const nextChapterStartMs = sourceChapters
.slice(index + 1)
.map((item) => (typeof item.startTime === 'number' ? item.startTime : parseChapterTimeToMs(item.time)))
.find((item): item is number => item !== null && startMs !== null && item > startMs);
if (startMs !== null) {
const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs);
if (firstTranscriptIndex >= 0) {
const lastTranscriptIndex = nextChapterStartMs === undefined
? transcripts.length
: transcripts.findIndex((item) => item.startTime >= nextChapterStartMs);
matchedTranscripts = transcripts.slice(
firstTranscriptIndex,
lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length,
);
}
}
}
return {
key: `${chapter.chapterNo ?? index}-${chapter.title || 'chapter'}`,
title: chapter.title || `章节 ${index + 1}`,
timeLabel: chapter.time || '--:--',
transcriptIds: matchedTranscripts.map((item) => item.id),
firstTranscriptId: matchedTranscripts[0]?.id ?? null,
firstTranscriptStartTime: matchedTranscripts[0]?.startTime ?? null,
};
});
}, [analysis.chapters, meetingChapters, transcripts]);
const sharePreviewUrl = useMemo(() => {
const meetingId = meeting?.id ?? (id ? Number(id) : NaN);
return buildMeetingPreviewUrl(meetingId);
@ -927,6 +1045,27 @@ const MeetingDetail: React.FC = () => {
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
}, [analysis.keywords]);
useEffect(() => {
if (workspaceTab !== 'transcript') {
return;
}
const pendingId = pendingTranscriptScrollIdRef.current;
if (!pendingId) {
return;
}
const frameId = window.requestAnimationFrame(() => {
const target = transcriptItemRefs.current[pendingId];
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
pendingTranscriptScrollIdRef.current = null;
}
});
return () => window.cancelAnimationFrame(frameId);
}, [workspaceTab, transcripts, linkedTranscriptIds]);
useEffect(() => {
if (meeting?.audioSaveStatus === 'FAILED') {
message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。');
@ -1070,8 +1209,10 @@ const MeetingDetail: React.FC = () => {
);
if (firstMatch) {
setWorkspaceTab('transcript');
setLinkedTranscriptIds([]);
setLinkedChapterKey(null);
setHighlightKeyword(keyword);
setHighlightTimestamp(firstMatch.startTime);
seekTo(firstMatch.startTime);
message.info(`已跳转至关键词 "${keyword}" 所在位置`);
} else {
@ -1079,6 +1220,27 @@ const MeetingDetail: React.FC = () => {
}
}, [transcripts, seekTo, message]);
const handleTranscriptRowPlay = useCallback((timeMs: number) => {
setLinkedTranscriptIds([]);
setLinkedChapterKey(null);
seekTo(timeMs);
}, [seekTo]);
const handleLocateChapterTranscript = useCallback((chapterIndex: number) => {
const targetLink = catalogChapterLinks[chapterIndex];
if (!targetLink || !targetLink.transcriptIds.length || targetLink.firstTranscriptId === null || targetLink.firstTranscriptStartTime === null) {
message.warning('当前章节暂未匹配到可关联的转录原文');
return;
}
setWorkspaceTab('transcript');
setHighlightKeyword('');
setLinkedTranscriptIds(targetLink.transcriptIds);
setLinkedChapterKey(targetLink.key);
pendingTranscriptScrollIdRef.current = targetLink.firstTranscriptId;
seekTo(targetLink.firstTranscriptStartTime);
}, [catalogChapterLinks, message, seekTo]);
const handleRetryTranscription = async () => {
setActionLoading(true);
try {
@ -1220,6 +1382,10 @@ const MeetingDetail: React.FC = () => {
void fetchData(meeting.id);
}, [fetchData, meeting]);
const registerTranscriptRow = useCallback((transcriptId: number, node: HTMLDivElement | null) => {
transcriptItemRefs.current[transcriptId] = node;
}, []);
const handleAudioPlaybackError = useCallback(() => {
const currentAudioUrl = playbackAudioUrl || '';
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
@ -1694,6 +1860,57 @@ const MeetingDetail: React.FC = () => {
<Row gutter={24} style={{ height: '100%' }}>
<Col xs={24} xl={13} style={{ height: '100%' }}>
<div className="detail-side-column detail-left-column">
<Card className="left-flow-card keyword-panel" variant="borderless">
<div className="keyword-panel-head">
<div className="keyword-panel-title"></div>
<div className="transcript-keyword-actions">
{analysis.keywords.length > 9 ? (
<button type="button" className="summary-link" onClick={() => setExpandKeywords((value) => !value)}>
{expandKeywords ? '收起' : '展开全部'}
</button>
) : null}
{isOwner && analysis.keywords.length > 0 ? (
<Button
size="small"
type="primary"
ghost
disabled={!selectedKeywords.length}
loading={addingHotwords}
onClick={handleAddSelectedHotwords}
>
{selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
</Button>
) : null}
</div>
</div>
<div className="record-tags">
{keywordItems.length ? (
keywordItems.map((tag) => {
const isSelected = selectedKeywords.includes(tag);
const isHighlighted = highlightKeyword === tag;
return (
<div
key={tag}
className={`tag selectable-tag ${isSelected ? 'selected' : ''} ${isHighlighted ? 'highlighted-tag' : ''}`}
onClick={() => {
if (isOwner && analysis.keywords.length) {
handleKeywordToggle(tag, !isSelected);
}
handleKeywordClick(tag);
}}
style={isHighlighted ? { borderColor: '#5f51ff', backgroundColor: 'rgba(95, 81, 255, 0.1)' } : {}}
>
<span>#{tag}</span>
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
</div>
);
})
) : (
<Text type="secondary"></Text>
)}
</div>
</Card>
<Card className="left-flow-card summary-panel" variant="borderless">
<div className="summary-head">
<div className="summary-title">
@ -1861,12 +2078,14 @@ const MeetingDetail: React.FC = () => {
isEditing={editingTranscriptId === item.id}
isSaving={savingTranscriptId === item.id}
speakerLabelMap={speakerLabelMap}
onSeek={seekTo}
onPlay={handleTranscriptRowPlay}
onStartEdit={handleStartEditTranscript}
onDraftBlur={handleTranscriptDraftBlur}
onDraftKeyDown={handleTranscriptDraftKeyDown}
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
registerRow={registerTranscriptRow}
isActive={isActive}
isLinkedHighlight={linkedTranscriptIds.includes(item.id)}
audioPlaying={audioPlaying}
/>
);
@ -1894,106 +2113,98 @@ const MeetingDetail: React.FC = () => {
</audio>
)}
<div className="transcript-keyword-bar">
<div className="transcript-keyword-label"></div>
<div className="transcript-keyword-body">
<div className="record-tags">
{(analysis.keywords.length ? visibleKeywords : meetingTags).length ? (
(analysis.keywords.length ? visibleKeywords : meetingTags).map((tag) => {
const isSelected = selectedKeywords.includes(tag);
const isHighlighted = highlightKeyword === tag;
return (
<div
key={tag}
className={`tag selectable-tag ${isSelected ? 'selected' : ''} ${isHighlighted ? 'highlighted-tag' : ''}`}
onClick={() => {
if (isOwner && analysis.keywords.length) {
handleKeywordToggle(tag, !isSelected);
}
handleKeywordClick(tag);
}}
style={isHighlighted ? { borderColor: '#5f51ff', backgroundColor: 'rgba(95, 81, 255, 0.1)' } : {}}
>
<span>#{tag}</span>
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
</div>
);
})
) : (
<Text type="secondary"></Text>
)}
</div>
<div className="transcript-keyword-actions">
{analysis.keywords.length > 9 ? (
<button type="button" className="summary-link" onClick={() => setExpandKeywords((value) => !value)}>
{expandKeywords ? '收起' : '展开全部'}
</button>
) : null}
{isOwner && analysis.keywords.length > 0 ? (
<Button
size="small"
type="primary"
ghost
disabled={!selectedKeywords.length}
loading={addingHotwords}
onClick={handleAddSelectedHotwords}
>
{selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
</Button>
) : null}
</div>
</div>
</div>
<div className="transcript-stage-tabs">
<button type="button" className="active"></button>
<button
type="button"
className={workspaceTab === 'catalog' ? 'active' : ''}
onClick={() => setWorkspaceTab('catalog')}
>
AI
</button>
<button
type="button"
className={workspaceTab === 'transcript' ? 'active' : ''}
onClick={() => setWorkspaceTab('transcript')}
>
</button>
</div>
<div className="transcript-scroll-shell">
{emptyTranscriptFailureNotice && (
<div className="empty-transcript-inline-note">
<div className="empty-transcript-inline-note__title"></div>
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
{workspaceTab === 'catalog' ? (
<div className="catalog-list">
{catalogChapterLinks.length ? (
catalogChapterLinks.map((chapter, index) => (
<div
key={chapter.key}
className={`catalog-item ${linkedChapterKey === chapter.key ? 'active' : ''}`}
>
<div className="catalog-item-time">{chapter.timeLabel}</div>
<div className="catalog-item-main">
<div className="catalog-item-title">{chapter.title}</div>
<button
type="button"
className="catalog-item-link"
onClick={() => handleLocateChapterTranscript(index)}
>
</button>
</div>
</div>
))
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 AI 目录" />
)}
</div>
)}
{meeting.audioSaveStatus === 'FAILED' && (
<Alert
type="warning"
showIcon
style={{ margin: '0 18px 16px' }}
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
/>
)}
<List
className="transcript-list"
dataSource={transcripts}
style={{ paddingBottom: playbackAudioUrl ? 156 : 0 }}
renderItem={(item, index) => {
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
return (
<ActiveTranscriptRow
item={item}
meetingId={meeting.id}
isOwner={isOwner}
isEditing={editingTranscriptId === item.id}
isSaving={savingTranscriptId === item.id}
speakerLabelMap={speakerLabelMap}
onSeek={seekTo}
onStartEdit={handleStartEditTranscript}
onDraftBlur={handleTranscriptDraftBlur}
onDraftKeyDown={handleTranscriptDraftKeyDown}
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
isActive={isActive}
audioPlaying={audioPlaying}
highlightKeyword={highlightKeyword}
) : (
<>
{emptyTranscriptFailureNotice && (
<div className="empty-transcript-inline-note">
<div className="empty-transcript-inline-note__title"></div>
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
</div>
)}
{meeting.audioSaveStatus === 'FAILED' && (
<Alert
type="warning"
showIcon
style={{ margin: '0 18px 16px' }}
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
/>
);
}}
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
/>
)}
<List
className="transcript-list"
dataSource={transcripts}
style={{ paddingBottom: playbackAudioUrl ? 156 : 0 }}
renderItem={(item, index) => {
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
return (
<ActiveTranscriptRow
item={item}
meetingId={meeting.id}
isOwner={isOwner}
isEditing={editingTranscriptId === item.id}
isSaving={savingTranscriptId === item.id}
speakerLabelMap={speakerLabelMap}
onPlay={handleTranscriptRowPlay}
onStartEdit={handleStartEditTranscript}
onDraftBlur={handleTranscriptDraftBlur}
onDraftKeyDown={handleTranscriptDraftKeyDown}
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
registerRow={registerTranscriptRow}
isActive={isActive}
isLinkedHighlight={linkedTranscriptIds.includes(item.id)}
audioPlaying={audioPlaying}
highlightKeyword={highlightKeyword}
/>
);
}}
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
/>
</>
)}
</div>
</Card>
</div>
@ -2165,6 +2376,7 @@ const MeetingDetail: React.FC = () => {
padding-right: 6px;
display: flex;
flex-direction: column;
gap: 18px;
}
.detail-left-column > .section-divider-note,
.detail-left-column > .transcript-player-anchor {
@ -2221,6 +2433,24 @@ const MeetingDetail: React.FC = () => {
.summary-markdown-panel {
min-height: 0;
}
.keyword-panel .ant-card-body {
display: grid;
gap: 16px;
padding: 20px 24px 24px;
}
.keyword-panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.keyword-panel-title {
color: #2d3553;
font-size: 16px;
font-weight: 800;
letter-spacing: 0.04em;
}
.brief-section {
display: grid;
gap: 14px;
@ -2613,6 +2843,64 @@ const MeetingDetail: React.FC = () => {
overflow-x: hidden;
padding: 18px 18px 0;
}
.catalog-list {
display: grid;
gap: 14px;
padding-bottom: 0;
}
.catalog-item {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 14px;
align-items: start;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid rgba(228, 232, 245, 0.96);
background: rgba(248, 250, 255, 0.96);
transition: all 0.24s ease;
}
.catalog-item:hover {
border-color: rgba(95, 81, 255, 0.18);
box-shadow: 0 12px 28px rgba(95, 81, 255, 0.08);
transform: translateY(-1px);
}
.catalog-item.active {
border-color: rgba(95, 81, 255, 0.24);
background: linear-gradient(135deg, rgba(95, 81, 255, 0.08), rgba(108, 140, 255, 0.06));
box-shadow: 0 14px 30px rgba(95, 81, 255, 0.1);
}
.catalog-item-time {
color: #5c66a2;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
padding-top: 4px;
}
.catalog-item-main {
min-width: 0;
display: grid;
gap: 10px;
}
.catalog-item-title {
color: #273153;
font-size: 15px;
font-weight: 700;
line-height: 1.7;
}
.catalog-item-link {
width: fit-content;
padding: 0;
border: 0;
background: transparent;
color: #5f51ff;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.catalog-item-link:hover {
color: #4536f0;
text-decoration: underline;
}
.segmented-tabs {
display: flex;
gap: 28px;
@ -2808,6 +3096,12 @@ const MeetingDetail: React.FC = () => {
border-bottom: 0 !important;
cursor: pointer;
}
.ant-list-item.transcript-row.linked .transcript-bubble,
.ant-list-item.transcript-row.linked .transcript-bubble-editing {
background: linear-gradient(135deg, rgba(95, 81, 255, 0.08), rgba(108, 140, 255, 0.06));
border-color: rgba(95, 81, 255, 0.2);
box-shadow: 0 10px 24px rgba(95, 81, 255, 0.06);
}
.ant-list-item.transcript-row.active .transcript-bubble,
.ant-list-item.transcript-row.active .transcript-bubble-editing {
border-color: rgba(95, 81, 255, 0.16);
@ -3102,6 +3396,9 @@ const MeetingDetail: React.FC = () => {
.detail-left-column {
padding-right: 0;
}
.catalog-item {
grid-template-columns: 68px minmax(0, 1fr);
}
.speaker-summary-card {
grid-template-columns: 1fr;
}
@ -3123,6 +3420,10 @@ const MeetingDetail: React.FC = () => {
.detail-left-column {
overflow: visible;
}
.catalog-item {
grid-template-columns: 1fr;
gap: 8px;
}
.transcript-player--floating {
bottom: 72px;
max-width: calc(100vw - 24px);