diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 99ec9fa..5edd2f6 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -235,6 +235,16 @@ public class MeetingController { return ApiResponse.ok(meetingQueryService.getTranscripts(id)); } + @Operation(summary = "查询会议章节") + @GetMapping("/{id}/chapters") + @PreAuthorize("isAuthenticated()") + public ApiResponse>> 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); } diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java new file mode 100644 index 0000000..fe55eb2 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java @@ -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 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 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 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 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); + } +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java b/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java index 0b8f8ee..35c7496 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java +++ b/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java @@ -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; diff --git a/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java b/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java index 6b04861..efe7f17 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java +++ b/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java @@ -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; diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java index 1554182..57e0283 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java @@ -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") diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryFinalizeDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryFinalizeDTO.java new file mode 100644 index 0000000..d391c26 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryFinalizeDTO.java @@ -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 analysis; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryPromptContextRequestDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryPromptContextRequestDTO.java new file mode 100644 index 0000000..1ee8edf --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryPromptContextRequestDTO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryPromptContextVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryPromptContextVO.java new file mode 100644 index 0000000..30910ba --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryPromptContextVO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingSummarySource.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummarySource.java index ac8ed96..8957dca 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingSummarySource.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummarySource.java @@ -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> chapters; public Map toSnapshot() { Map 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; } } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptChapterImportDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptChapterImportDTO.java new file mode 100644 index 0000000..1aeca1e --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptChapterImportDTO.java @@ -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 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 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; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptChapterImportResultVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptChapterImportResultVO.java new file mode 100644 index 0000000..783741b --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptChapterImportResultVO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptSourceVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptSourceVO.java new file mode 100644 index 0000000..d4ca6dd --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptSourceVO.java @@ -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 segments; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptChapter.java b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptChapter.java new file mode 100644 index 0000000..4d4e7c0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptChapter.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptChapterVersion.java b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptChapterVersion.java new file mode 100644 index 0000000..3f88840 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptChapterVersion.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptChapterMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptChapterMapper.java new file mode 100644 index 0000000..706b80a --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptChapterMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptChapterVersionMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptChapterVersionMapper.java new file mode 100644 index 0000000..fb315e1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptChapterVersionMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java index c8a74e8..530d226 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java @@ -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 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() .eq(AiTask::getMeetingId, meetingId) diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java index 3179bd9..ec80036 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -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); } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java index ddfffc3..2488f01 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java @@ -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 getTranscripts(Long meetingId); + List> getChapters(Long meetingId); + + MeetingTranscriptSourceVO getTranscriptSource(Long meetingId); + + MeetingSummaryPromptContextVO buildSummaryPromptContext(Long meetingId, MeetingSummaryPromptContextRequestDTO request); + Map getDashboardStats(Long tenantId, Long userId, boolean isAdmin); List getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit); diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java index 0c5ac4a..60d36dc 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingSummaryFileService.java @@ -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 normalizeSummaryAnalysis(Map analysis); + String stripFrontMatter(String markdown); } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptChapterService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptChapterService.java new file mode 100644 index 0000000..896bf45 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptChapterService.java @@ -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> listCurrentChapterAnalysis(Long meetingId); + + void invalidateCurrentVersion(Long meetingId); + + MeetingTranscriptChapterVersion importExternalChapters(Meeting meeting, AiTask sourceTask, MeetingTranscriptChapterImportDTO command); + + MeetingTranscriptSourceVO buildTranscriptSource(Long meetingId); + + MeetingTranscriptChapterVersion getCurrentVersion(Long meetingId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 771231e..014d560 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -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 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 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 impleme return; } - AiTask sumTask = this.getOne(new LambdaQueryWrapper() - .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 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 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 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 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 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 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 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()) diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index f6f72a3..de79b71 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -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() + .eq(AiTask::getMeetingId, meeting.getId()) + .eq(AiTask::getTaskType, "CHAPTER") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + AiTask latestSummaryTask = aiTaskService.getOne(new LambdaQueryWrapper() + .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 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 normalizedAnalysis = meetingSummaryFileService.normalizeSummaryAnalysis(command.getAnalysis()); + String relativePath = meetingSummaryFileService.saveSummaryContent(meeting, summaryTask, command.getSummaryContent()); + + Map responseData = summaryTask.getResponseData() == null + ? new HashMap<>() + : new HashMap<>(summaryTask.getResponseData()); + Map 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 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() + .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); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 985be8d..c4ec8a6 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -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) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java index 047dcb3..6c7c9e1 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java @@ -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> pageMeetings(Integer current, Integer size, String title, Long tenantId, @@ -94,6 +103,50 @@ public class MeetingQueryServiceImpl implements MeetingQueryService { }).collect(Collectors.toList()); } + @Override + public List> 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() + .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 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 getDashboardStats(Long tenantId, Long userId, boolean isAdmin) { Map 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); + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java index d03f0eb..51131ec 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java @@ -145,7 +145,6 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService Map 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 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 normalizeSummaryAnalysis(Map analysis) { + return analysis == null ? new LinkedHashMap<>() : parseSummaryAnalysisFromMap(analysis); + } + @Override public String stripFrontMatter(String markdown) { if (markdown == null || markdown.isBlank()) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java index 54e9d9a..b3efd2f 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java @@ -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 buildTaskConfig(Long summaryModelId, Long promptId, String userPrompt) { + return buildTaskConfig(summaryModelId, summaryModelId, promptId, userPrompt); + } + + public Map buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { Map 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, diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java new file mode 100644 index 0000000..9810f11 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java @@ -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 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> listCurrentChapterAnalysis(Long meetingId) { + MeetingTranscriptChapterVersion current = findCurrentVersion(meetingId); + if (current == null) { + return List.of(); + } + List transcripts = loadRawTranscripts(meetingId); + Map 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() + .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 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 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 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 transcripts, + String fingerprint) { + List chapterItems = generateInternalChapterItems(summaryTask, transcripts); + if (chapterItems == null || chapterItems.isEmpty()) { + throw new RuntimeException("章节模型未返回有效章节结果,无法继续总结"); + } + List 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 generateInternalChapterItems(AiTask summaryTask, List 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 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 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 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 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 transcripts) throws Exception { + List> segments = new ArrayList<>(); + for (MeetingTranscript transcript : transcripts) { + Map 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 transcripts, + List 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 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 transcripts, + String fingerprint) { + Map transcriptById = transcripts.stream() + .collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new)); + List chapterEntities = loadVersionChapters(version.getId()); + List> 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 transcripts) { + Map transcriptById = transcripts.stream() + .collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new)); + List chapters = loadVersionChapters(version.getId()); + return writeCurrentChapterMarkdown(meeting, version, chapters, transcriptById); + } + + private String writeCurrentChapterMarkdown(Meeting meeting, + MeetingTranscriptChapterVersion version, + List chapters, + Map 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 chapters, + Map 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 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 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 chapters, Map 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 fidelityPoints = extractFidelityPoints(resolveTranscriptsInRange(chapter, transcriptById)); + if (!fidelityPoints.isEmpty()) { + builder.append("章节导航保真锚点:").append(String.join("、", fidelityPoints)).append("\n"); + } + } + return builder.toString().trim(); + } + + private Map toChapterAnalysis(MeetingTranscriptChapter chapter, Map transcriptById) { + List range = resolveTranscriptsInRange(chapter, transcriptById); + Map 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 loadRawTranscripts(Long meetingId) { + return transcriptMapper.selectList(new LambdaQueryWrapper() + .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() + .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() + .eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId) + .eq(MeetingTranscriptChapterVersion::getIsCurrent, 1) + .eq(MeetingTranscriptChapterVersion::getStatus, 2) + .orderByDesc(MeetingTranscriptChapterVersion::getVersionNo) + .last("limit 1")); + } + + private List loadVersionChapters(Long versionId) { + return chapterMapper.selectList(new LambdaQueryWrapper() + .eq(MeetingTranscriptChapter::getVersionId, versionId) + .orderByAsc(MeetingTranscriptChapter::getChapterNo) + .orderByAsc(MeetingTranscriptChapter::getId)); + } + + private int resolveNextVersionNo(Long meetingId) { + MeetingTranscriptChapterVersion latest = versionMapper.selectOne(new LambdaQueryWrapper() + .eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId) + .orderByDesc(MeetingTranscriptChapterVersion::getVersionNo) + .last("limit 1")); + return latest == null || latest.getVersionNo() == null ? 1 : latest.getVersionNo() + 1; + } + + private void validateCandidatesAgainstTranscripts(List candidates, List transcripts) { + if (candidates == null || candidates.isEmpty()) { + throw new RuntimeException("章节结果不能为空"); + } + Map 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 resolveTranscriptsInRange(MeetingTranscriptChapter chapter, Map transcriptById) { + List 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 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 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 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 extractFidelityPoints(List transcripts) { + Set 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 keywords) { + try { + return objectMapper.writeValueAsString(normalizeKeywords(keywords)); + } catch (Exception ex) { + return "[]"; + } + } + + private List readKeywords(String keywordsJson) { + if (keywordsJson == null || keywordsJson.isBlank()) { + return List.of(); + } + try { + return normalizeKeywords(objectMapper.readValue(keywordsJson, new TypeReference>() {})); + } catch (Exception ex) { + return List.of(); + } + } + + private List normalizeKeywords(List 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 keywords, + Long startTranscriptId, + Long endTranscriptId, + BigDecimal confidence) { + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c746815..320bec1 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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 diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java index 667fa7d..394c404 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java @@ -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 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 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 ); diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java index e47bc67..a064454 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java @@ -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 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 diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index fe3b41e..831fd07 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -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` diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index d87c774..6f49c15 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -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; - 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) => 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(({ isEditing, isSaving, speakerLabelMap, - onSeek, + onPlay, onStartEdit, onDraftBlur, onDraftKeyDown, onSpeakerUpdated, + registerRow, isActive, + isLinkedHighlight, audioPlaying, highlightKeyword = '', }) => { @@ -648,8 +684,17 @@ const ActiveTranscriptRow = React.memo(({ const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : ''; return ( - onSeek(item.startTime)}> -
+ onPlay(item.startTime)} + > +
{ + rowRef.current = node; + registerRow(item.id, node); + }} + > } className="transcript-avatar" />
@@ -716,12 +761,14 @@ const ActiveTranscriptRow = React.memo(({ && 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(null); const [transcripts, setTranscripts] = useState([]); + const [meetingChapters, setMeetingChapters] = useState([]); 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([]); + const [workspaceTab, setWorkspaceTab] = useState('catalog'); const [addingHotwords, setAddingHotwords] = useState(false); const [editingTranscriptId, setEditingTranscriptId] = useState(null); const [savingTranscriptId, setSavingTranscriptId] = useState(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(null); + const [linkedTranscriptIds, setLinkedTranscriptIds] = useState([]); + const [linkedChapterKey, setLinkedChapterKey] = useState(null); const audioRef = useRef(null); const [audioCurrentTime, setAudioCurrentTime] = useState(0); @@ -772,6 +822,7 @@ const MeetingDetail: React.FC = () => { const summaryPdfRef = useRef(null); const transcriptItemRefs = useRef>({}); + const pendingTranscriptScrollIdRef = useRef(null); const leftColumnRef = useRef(null); const transcriptSectionRef = useRef(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(() => { + 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 = () => {
+ +
+
关键词
+
+ {analysis.keywords.length > 9 ? ( + + ) : null} + {isOwner && analysis.keywords.length > 0 ? ( + + ) : null} +
+
+
+ {keywordItems.length ? ( + keywordItems.map((tag) => { + const isSelected = selectedKeywords.includes(tag); + const isHighlighted = highlightKeyword === tag; + return ( +
{ + if (isOwner && analysis.keywords.length) { + handleKeywordToggle(tag, !isSelected); + } + handleKeywordClick(tag); + }} + style={isHighlighted ? { borderColor: '#5f51ff', backgroundColor: 'rgba(95, 81, 255, 0.1)' } : {}} + > + #{tag} + {isOwner && isSelected && } +
+ ); + }) + ) : ( + 暂无关键词 + )} +
+
+
@@ -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 = () => { )} -
-
关键词
-
-
- {(analysis.keywords.length ? visibleKeywords : meetingTags).length ? ( - (analysis.keywords.length ? visibleKeywords : meetingTags).map((tag) => { - const isSelected = selectedKeywords.includes(tag); - const isHighlighted = highlightKeyword === tag; - return ( -
{ - if (isOwner && analysis.keywords.length) { - handleKeywordToggle(tag, !isSelected); - } - handleKeywordClick(tag); - }} - style={isHighlighted ? { borderColor: '#5f51ff', backgroundColor: 'rgba(95, 81, 255, 0.1)' } : {}} - > - #{tag} - {isOwner && isSelected && } -
- ); - }) - ) : ( - 暂无关键词 - )} -
-
- {analysis.keywords.length > 9 ? ( - - ) : null} - {isOwner && analysis.keywords.length > 0 ? ( - - ) : null} -
-
-
-
- + +
- {emptyTranscriptFailureNotice && ( -
-
当前没有可展示的转录内容
-
{emptyTranscriptFailureNotice.description}
+ {workspaceTab === 'catalog' ? ( +
+ {catalogChapterLinks.length ? ( + catalogChapterLinks.map((chapter, index) => ( +
+
{chapter.timeLabel}
+
+
{chapter.title}
+ +
+
+ )) + ) : ( + + )}
- )} - {meeting.audioSaveStatus === 'FAILED' && ( - - )} - { - const nextStartTime = transcripts[index + 1]?.startTime || Infinity; - const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime; - - return ( - + {emptyTranscriptFailureNotice && ( +
+
当前没有可展示的转录内容
+
{emptyTranscriptFailureNotice.description}
+
+ )} + {meeting.audioSaveStatus === 'FAILED' && ( + - ); - }} - locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} - /> + )} + { + const nextStartTime = transcripts[index + 1]?.startTime || Infinity; + const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime; + return ( + + ); + }} + locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} + /> + + )}
@@ -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);