feat: 添加会议章节导入和总结功能
- 在 `MeetingCommandService` 中添加 `importTranscriptChapters` 和 `finalizeSummary` 方法 - 更新 `MeetingSummaryPromptAssembler` 以支持章节模型和摘要源 - 在 `MeetingQueryService` 中添加获取章节和转录源的方法 - 新增 `MeetingSummaryFinalizeDTO` 和 `MeetingSummaryPromptContextVO` 数据传输对象 - 在 `MeetingCommandServiceImpl` 中实现章节导入和总结任务创建逻辑 - 更新前端 `meeting.ts` 以支持获取章节信息dev_na
parent
9b63a1ec4e
commit
a34885111c
|
|
@ -235,6 +235,16 @@ public class MeetingController {
|
||||||
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
|
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "查询会议章节")
|
||||||
|
@GetMapping("/{id}/chapters")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> getChapters(@PathVariable Long id) {
|
||||||
|
LoginUser loginUser = currentLoginUser();
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
|
||||||
|
return ApiResponse.ok(meetingQueryService.getChapters(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "下载会议转录 Markdown")
|
@Operation(summary = "下载会议转录 Markdown")
|
||||||
@GetMapping("/{id}/transcripts/export")
|
@GetMapping("/{id}/transcripts/export")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
|
@ -403,7 +413,7 @@ public class MeetingController {
|
||||||
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||||
dto.setMeetingId(id);
|
dto.setMeetingId(id);
|
||||||
assertPromptAvailable(dto.getPromptId(), loginUser);
|
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);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
package com.imeeting.controller.biz;
|
||||||
|
|
||||||
|
import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO;
|
||||||
|
import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO;
|
||||||
|
import com.imeeting.dto.biz.MeetingSummaryPromptContextVO;
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
|
||||||
|
import com.imeeting.service.biz.MeetingCommandService;
|
||||||
|
import com.imeeting.service.biz.MeetingQueryService;
|
||||||
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import com.unisbase.config.properties.UnisBaseProperties;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Tag(name = "会议内部编排接口")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/sys/internal/meetings")
|
||||||
|
public class MeetingInternalWorkflowController {
|
||||||
|
|
||||||
|
private final MeetingCommandService meetingCommandService;
|
||||||
|
private final MeetingQueryService meetingQueryService;
|
||||||
|
private final UnisBaseProperties unisBaseProperties;
|
||||||
|
|
||||||
|
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
|
||||||
|
private String summaryOrchestrationMode;
|
||||||
|
|
||||||
|
public MeetingInternalWorkflowController(MeetingCommandService meetingCommandService,
|
||||||
|
MeetingQueryService meetingQueryService,
|
||||||
|
UnisBaseProperties unisBaseProperties) {
|
||||||
|
this.meetingCommandService = meetingCommandService;
|
||||||
|
this.meetingQueryService = meetingQueryService;
|
||||||
|
this.unisBaseProperties = unisBaseProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "导入会议章节")
|
||||||
|
@PostMapping("/{meetingId}/chapters/import")
|
||||||
|
public ApiResponse<MeetingTranscriptChapterImportResultVO> importChapters(HttpServletRequest request,
|
||||||
|
@PathVariable Long meetingId,
|
||||||
|
@Valid @RequestBody MeetingTranscriptChapterImportDTO command) {
|
||||||
|
if (!isExternalModeEnabled()) {
|
||||||
|
return ApiResponse.error("External n8n summary orchestration is disabled");
|
||||||
|
}
|
||||||
|
if (!isInternalSecretValid(request)) {
|
||||||
|
return ApiResponse.error("Invalid internal secret");
|
||||||
|
}
|
||||||
|
command.setMeetingId(meetingId);
|
||||||
|
return ApiResponse.ok(meetingCommandService.importTranscriptChapters(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取会议原始转录源")
|
||||||
|
@GetMapping("/{meetingId}/transcript-source")
|
||||||
|
public ApiResponse<MeetingTranscriptSourceVO> getTranscriptSource(HttpServletRequest request, @PathVariable Long meetingId) {
|
||||||
|
if (!isExternalModeEnabled()) {
|
||||||
|
return ApiResponse.error("External n8n summary orchestration is disabled");
|
||||||
|
}
|
||||||
|
if (!isInternalSecretValid(request)) {
|
||||||
|
return ApiResponse.error("Invalid internal secret");
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(meetingQueryService.getTranscriptSource(meetingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取会议总结提示词上下文")
|
||||||
|
@PostMapping("/{meetingId}/summary-prompt-context")
|
||||||
|
public ApiResponse<MeetingSummaryPromptContextVO> getSummaryPromptContext(HttpServletRequest request,
|
||||||
|
@PathVariable Long meetingId,
|
||||||
|
@RequestBody(required = false) MeetingSummaryPromptContextRequestDTO requestDTO) {
|
||||||
|
if (!isExternalModeEnabled()) {
|
||||||
|
return ApiResponse.error("External n8n summary orchestration is disabled");
|
||||||
|
}
|
||||||
|
if (!isInternalSecretValid(request)) {
|
||||||
|
return ApiResponse.error("Invalid internal secret");
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(meetingQueryService.buildSummaryPromptContext(
|
||||||
|
meetingId,
|
||||||
|
requestDTO == null ? new MeetingSummaryPromptContextRequestDTO() : requestDTO
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "回填会议总结")
|
||||||
|
@PostMapping("/{meetingId}/summary/finalize")
|
||||||
|
public ApiResponse<Boolean> finalizeSummary(HttpServletRequest request,
|
||||||
|
@PathVariable Long meetingId,
|
||||||
|
@Valid @RequestBody MeetingSummaryFinalizeDTO command) {
|
||||||
|
if (!isExternalModeEnabled()) {
|
||||||
|
return ApiResponse.error("External n8n summary orchestration is disabled");
|
||||||
|
}
|
||||||
|
if (!isInternalSecretValid(request)) {
|
||||||
|
return ApiResponse.error("Invalid internal secret");
|
||||||
|
}
|
||||||
|
command.setMeetingId(meetingId);
|
||||||
|
meetingCommandService.finalizeSummary(command);
|
||||||
|
return ApiResponse.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isExternalModeEnabled() {
|
||||||
|
return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isInternalSecretValid(HttpServletRequest request) {
|
||||||
|
if (request == null || unisBaseProperties == null || unisBaseProperties.getInternalAuth() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!unisBaseProperties.getInternalAuth().isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String headerName = unisBaseProperties.getInternalAuth().getHeaderName();
|
||||||
|
String expectedSecret = unisBaseProperties.getInternalAuth().getSecret();
|
||||||
|
if (headerName == null || headerName.isBlank() || expectedSecret == null || expectedSecret.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String actual = request.getHeader(headerName);
|
||||||
|
return expectedSecret.equals(actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,8 @@ public class CreateMeetingCommand {
|
||||||
@NotNull(message = "summaryModelId must not be null")
|
@NotNull(message = "summaryModelId must not be null")
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
|
|
||||||
|
private Long chapterModelId;
|
||||||
|
|
||||||
@NotNull(message = "promptId must not be null")
|
@NotNull(message = "promptId must not be null")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ public class CreateRealtimeMeetingCommand {
|
||||||
@NotNull(message = "summaryModelId must not be null")
|
@NotNull(message = "summaryModelId must not be null")
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
|
|
||||||
|
private Long chapterModelId;
|
||||||
|
|
||||||
@NotNull(message = "promptId must not be null")
|
@NotNull(message = "promptId must not be null")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import jakarta.validation.constraints.Size;
|
||||||
public class MeetingResummaryDTO {
|
public class MeetingResummaryDTO {
|
||||||
private Long meetingId;
|
private Long meetingId;
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
|
private Long chapterModelId;
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "会议总结回填请求")
|
||||||
|
public class MeetingSummaryFinalizeDTO {
|
||||||
|
@Schema(description = "会议ID")
|
||||||
|
private Long meetingId;
|
||||||
|
|
||||||
|
@NotNull(message = "summaryTaskId must not be null")
|
||||||
|
@Schema(description = "总结任务ID")
|
||||||
|
private Long summaryTaskId;
|
||||||
|
|
||||||
|
@NotBlank(message = "sourceFingerprint must not be blank")
|
||||||
|
@Schema(description = "转录指纹")
|
||||||
|
private String sourceFingerprint;
|
||||||
|
|
||||||
|
@NotNull(message = "chapterVersionId must not be null")
|
||||||
|
@Schema(description = "章节版本ID")
|
||||||
|
private Long chapterVersionId;
|
||||||
|
|
||||||
|
@NotBlank(message = "summaryContent must not be blank")
|
||||||
|
@Schema(description = "最终总结正文")
|
||||||
|
private String summaryContent;
|
||||||
|
|
||||||
|
@Schema(description = "结构化分析结果")
|
||||||
|
private Map<String, Object> analysis;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|
@ -17,6 +18,14 @@ public class MeetingSummarySource {
|
||||||
private String triggerTaskType;
|
private String triggerTaskType;
|
||||||
private String semanticCorrector;
|
private String semanticCorrector;
|
||||||
private String ruleProfileVersion;
|
private String ruleProfileVersion;
|
||||||
|
private Long chapterVersionId;
|
||||||
|
private Integer chapterCount;
|
||||||
|
private String algorithmVersion;
|
||||||
|
private String generationMode;
|
||||||
|
private String rawTranscriptText;
|
||||||
|
private String chapterOutlineText;
|
||||||
|
private String chapterFilePath;
|
||||||
|
private List<Map<String, Object>> chapters;
|
||||||
|
|
||||||
public Map<String, Object> toSnapshot() {
|
public Map<String, Object> toSnapshot() {
|
||||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||||
|
|
@ -27,6 +36,13 @@ public class MeetingSummarySource {
|
||||||
snapshot.put("triggerTaskType", triggerTaskType);
|
snapshot.put("triggerTaskType", triggerTaskType);
|
||||||
snapshot.put("semanticCorrector", semanticCorrector);
|
snapshot.put("semanticCorrector", semanticCorrector);
|
||||||
snapshot.put("ruleProfileVersion", ruleProfileVersion);
|
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;
|
return snapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "会议转录章节导入请求")
|
||||||
|
public class MeetingTranscriptChapterImportDTO {
|
||||||
|
@Schema(description = "会议ID")
|
||||||
|
private Long meetingId;
|
||||||
|
|
||||||
|
@Schema(description = "章节生成来源标识")
|
||||||
|
private String chapterGeneratorLabel;
|
||||||
|
|
||||||
|
@Schema(description = "章节算法版本")
|
||||||
|
private String algorithmVersion;
|
||||||
|
|
||||||
|
@Schema(description = "导入成功后是否触发总结")
|
||||||
|
private Boolean triggerSummary;
|
||||||
|
|
||||||
|
@Schema(description = "本次总结模型ID")
|
||||||
|
private Long summaryModelId;
|
||||||
|
|
||||||
|
@Schema(description = "本次章节模型ID")
|
||||||
|
private Long chapterModelId;
|
||||||
|
|
||||||
|
@Schema(description = "本次提示词模板ID")
|
||||||
|
private Long promptId;
|
||||||
|
|
||||||
|
@Schema(description = "本次附加用户提示词")
|
||||||
|
private String userPrompt;
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotEmpty(message = "chapters must not be empty")
|
||||||
|
@Schema(description = "章节列表")
|
||||||
|
private List<ChapterItem> chapters;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "章节项")
|
||||||
|
public static class ChapterItem {
|
||||||
|
@NotNull(message = "chapterNo must not be null")
|
||||||
|
@Schema(description = "章节序号")
|
||||||
|
private Integer chapterNo;
|
||||||
|
|
||||||
|
@Schema(description = "章节标题")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Schema(description = "章节摘要")
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
@Schema(description = "章节关键词")
|
||||||
|
private List<String> keywords;
|
||||||
|
|
||||||
|
@NotNull(message = "startTranscriptId must not be null")
|
||||||
|
@Schema(description = "起始转录ID")
|
||||||
|
private Long startTranscriptId;
|
||||||
|
|
||||||
|
@NotNull(message = "endTranscriptId must not be null")
|
||||||
|
@Schema(description = "结束转录ID")
|
||||||
|
private Long endTranscriptId;
|
||||||
|
|
||||||
|
@DecimalMin(value = "0.0", inclusive = true, message = "confidence must be >= 0")
|
||||||
|
@DecimalMax(value = "1.0", inclusive = true, message = "confidence must be <= 1")
|
||||||
|
@Schema(description = "置信度")
|
||||||
|
private BigDecimal confidence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "会议原始转录源")
|
||||||
|
public class MeetingTranscriptSourceVO {
|
||||||
|
@Schema(description = "会议ID")
|
||||||
|
private Long meetingId;
|
||||||
|
|
||||||
|
@Schema(description = "转录指纹")
|
||||||
|
private String sourceFingerprint;
|
||||||
|
|
||||||
|
@Schema(description = "原始转录全文")
|
||||||
|
private String transcriptText;
|
||||||
|
|
||||||
|
@Schema(description = "原始转录分段")
|
||||||
|
private List<MeetingTranscriptVO> segments;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.imeeting.mapper.biz;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.imeeting.entity.biz.MeetingTranscriptChapter;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface MeetingTranscriptChapterMapper extends BaseMapper<MeetingTranscriptChapter> {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.imeeting.mapper.biz;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface MeetingTranscriptChapterVersionMapper extends BaseMapper<MeetingTranscriptChapterVersion> {
|
||||||
|
}
|
||||||
|
|
@ -150,6 +150,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
meetingService.updateById(meeting);
|
meetingService.updateById(meeting);
|
||||||
|
|
||||||
resetOrCreateAsrTask(meetingId, profile);
|
resetOrCreateAsrTask(meetingId, profile);
|
||||||
|
resetOrCreateChapterTask(meetingId, profile);
|
||||||
resetOrCreateSummaryTask(meetingId, profile);
|
resetOrCreateSummaryTask(meetingId, profile);
|
||||||
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||||
|
|
||||||
|
|
@ -256,6 +257,17 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig);
|
resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void resetOrCreateChapterTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) {
|
||||||
|
AiTask task = findLatestTask(meetingId, "CHAPTER");
|
||||||
|
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
|
||||||
|
profile.getResolvedSummaryModelId(),
|
||||||
|
profile.getResolvedSummaryModelId(),
|
||||||
|
profile.getResolvedPromptId(),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
resetOrCreateTask(task, meetingId, "CHAPTER", taskConfig);
|
||||||
|
}
|
||||||
|
|
||||||
private AiTask findLatestTask(Long meetingId, String taskType) {
|
private AiTask findLatestTask(Long meetingId, String taskType) {
|
||||||
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
.eq(AiTask::getMeetingId, meetingId)
|
.eq(AiTask::getMeetingId, meetingId)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ package com.imeeting.service.biz;
|
||||||
|
|
||||||
import com.imeeting.dto.biz.CreateMeetingCommand;
|
import com.imeeting.dto.biz.CreateMeetingCommand;
|
||||||
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
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.MeetingVO;
|
||||||
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
||||||
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
||||||
|
|
@ -32,7 +35,11 @@ public interface MeetingCommandService {
|
||||||
|
|
||||||
void updateSummaryContent(Long meetingId, String summaryContent);
|
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);
|
void retryTranscription(Long meetingId);
|
||||||
|
|
||||||
|
MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command);
|
||||||
|
|
||||||
|
void finalizeSummary(MeetingSummaryFinalizeDTO command);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package com.imeeting.service.biz;
|
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.MeetingTranscriptVO;
|
||||||
import com.imeeting.dto.biz.MeetingVO;
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
import com.unisbase.dto.PageResult;
|
import com.unisbase.dto.PageResult;
|
||||||
|
|
@ -17,6 +20,12 @@ public interface MeetingQueryService {
|
||||||
|
|
||||||
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
|
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
|
||||||
|
|
||||||
|
List<Map<String, Object>> getChapters(Long meetingId);
|
||||||
|
|
||||||
|
MeetingTranscriptSourceVO getTranscriptSource(Long meetingId);
|
||||||
|
|
||||||
|
MeetingSummaryPromptContextVO buildSummaryPromptContext(Long meetingId, MeetingSummaryPromptContextRequestDTO request);
|
||||||
|
|
||||||
Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
|
Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
|
||||||
|
|
||||||
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);
|
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.imeeting.service.biz;
|
package com.imeeting.service.biz;
|
||||||
|
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
|
import com.imeeting.entity.biz.AiTask;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -20,5 +21,9 @@ public interface MeetingSummaryFileService {
|
||||||
|
|
||||||
void updateSummaryContent(Meeting meeting, String summaryContent);
|
void updateSummaryContent(Meeting meeting, String summaryContent);
|
||||||
|
|
||||||
|
String saveSummaryContent(Meeting meeting, AiTask summaryTask, String summaryContent);
|
||||||
|
|
||||||
|
Map<String, Object> normalizeSummaryAnalysis(Map<String, Object> analysis);
|
||||||
|
|
||||||
String stripFrontMatter(String markdown);
|
String stripFrontMatter(String markdown);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.imeeting.service.biz;
|
||||||
|
|
||||||
|
import com.imeeting.dto.biz.MeetingSummarySource;
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
|
||||||
|
import com.imeeting.entity.biz.AiTask;
|
||||||
|
import com.imeeting.entity.biz.Meeting;
|
||||||
|
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface MeetingTranscriptChapterService {
|
||||||
|
MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask);
|
||||||
|
|
||||||
|
List<Map<String, Object>> listCurrentChapterAnalysis(Long meetingId);
|
||||||
|
|
||||||
|
void invalidateCurrentVersion(Long meetingId);
|
||||||
|
|
||||||
|
MeetingTranscriptChapterVersion importExternalChapters(Meeting meeting, AiTask sourceTask, MeetingTranscriptChapterImportDTO command);
|
||||||
|
|
||||||
|
MeetingTranscriptSourceVO buildTranscriptSource(Long meetingId);
|
||||||
|
|
||||||
|
MeetingTranscriptChapterVersion getCurrentVersion(Long meetingId);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.AiTaskService;
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||||
|
|
||||||
|
|
@ -63,6 +64,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||||
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
||||||
|
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
|
||||||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||||
|
|
||||||
|
|
@ -72,6 +74,9 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
@Value("${unisbase.app.upload-path}")
|
@Value("${unisbase.app.upload-path}")
|
||||||
private String uploadPath;
|
private String uploadPath;
|
||||||
|
|
||||||
|
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
|
||||||
|
private String summaryOrchestrationMode;
|
||||||
|
|
||||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(300))
|
.connectTimeout(Duration.ofSeconds(300))
|
||||||
.version(HttpClient.Version.HTTP_1_1)
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
|
@ -121,19 +126,25 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AiTask sumTask = this.getOne(new LambdaQueryWrapper<AiTask>()
|
AiTask chapterTask = findLatestTask(meetingId, "CHAPTER");
|
||||||
.eq(AiTask::getMeetingId, meetingId)
|
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
|
||||||
.eq(AiTask::getTaskType, "SUMMARY")
|
|
||||||
.orderByDesc(AiTask::getId)
|
|
||||||
.last("limit 1"));
|
|
||||||
if (asrText == null || asrText.isBlank()) {
|
if (asrText == null || asrText.isBlank()) {
|
||||||
failPendingSummaryTask(sumTask, "没有可用于总结的转录内容");
|
failPendingSummaryTask(sumTask, "没有可用于总结的转录内容");
|
||||||
updateMeetingStatus(meetingId, 4);
|
updateMeetingStatus(meetingId, 4);
|
||||||
updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0);
|
updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0);
|
||||||
return;
|
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)) {
|
if (sumTask != null && canExecuteTask(sumTask)) {
|
||||||
executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask));
|
executeSummaryFlow(meeting, sumTask, chapterTask);
|
||||||
} else if (meeting.getStatus() != 3) {
|
} else if (meeting.getStatus() != 3) {
|
||||||
updateMeetingStatus(meetingId, 3);
|
updateMeetingStatus(meetingId, 3);
|
||||||
}
|
}
|
||||||
|
|
@ -158,11 +169,24 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
if (meeting == null) {
|
if (meeting == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isExternalSummaryModeEnabled()) {
|
||||||
|
updateProgress(meetingId, 95, "等待外部章节与总结编排...", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AiTask chapterTask = findLatestTask(meetingId, "CHAPTER");
|
||||||
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
|
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
|
||||||
AiTask asrTask = findLatestTask(meetingId, "ASR");
|
|
||||||
try {
|
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)) {
|
if (sumTask != null && canExecuteTask(sumTask)) {
|
||||||
executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask));
|
executeSummaryFlow(meeting, sumTask, chapterTask);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Re-summary failed for meeting {}", meetingId, e);
|
log.error("Re-summary failed for meeting {}", meetingId, e);
|
||||||
|
|
@ -467,7 +491,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processSummaryTask(Meeting meeting, MeetingSummarySource summarySource, AiTask taskRecord) throws Exception {
|
private void processSummaryTask(Meeting meeting, MeetingSummarySource summarySource, AiTask taskRecord) throws Exception {
|
||||||
String asrText = summarySource.getText();
|
|
||||||
updateMeetingStatus(meeting.getId(), 2);
|
updateMeetingStatus(meeting.getId(), 2);
|
||||||
updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0);
|
updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0);
|
||||||
|
|
||||||
|
|
@ -497,7 +520,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
req.put("temperature", llmModel.getTemperature());
|
req.put("temperature", llmModel.getTemperature());
|
||||||
req.put("messages", List.of(
|
req.put("messages", List.of(
|
||||||
Map.of("role", "system", "content", meetingSummaryPromptAssembler.buildSystemMessage(taskRecord.getTaskConfig())),
|
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);
|
taskRecord.setRequestData(req);
|
||||||
|
|
@ -575,7 +598,54 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiModelVO asrModel) throws Exception {
|
private void executeChapterFlow(Meeting meeting, AiTask chapterTask) {
|
||||||
|
if (chapterTask == null || !canExecuteTask(chapterTask)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isExternalSummaryModeEnabled()) {
|
||||||
|
updateMeetingStatus(meeting.getId(), 2);
|
||||||
|
updateProgress(meeting.getId(), 85, "等待外部章节编排...", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
chapterTask.setStatus(1);
|
||||||
|
chapterTask.setStartedAt(LocalDateTime.now());
|
||||||
|
this.updateById(chapterTask);
|
||||||
|
|
||||||
|
MeetingSummarySource summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask);
|
||||||
|
if (summarySource.getRawTranscriptText() == null || summarySource.getRawTranscriptText().isBlank()) {
|
||||||
|
updateAiTaskFail(chapterTask, "没有可用转录,无法生成章节");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> responseData = chapterTask.getResponseData() == null
|
||||||
|
? new HashMap<>()
|
||||||
|
: new HashMap<>(chapterTask.getResponseData());
|
||||||
|
responseData.put("summarySource", summarySource.toSnapshot());
|
||||||
|
responseData.put("chapterOutlineText", summarySource.getChapterOutlineText());
|
||||||
|
responseData.put("sourceFingerprint", summarySource.getSourceFingerprint());
|
||||||
|
responseData.put("chapterVersionId", summarySource.getChapterVersionId());
|
||||||
|
responseData.put("chapterCount", summarySource.getChapterCount());
|
||||||
|
responseData.put("chapterFilePath", summarySource.getChapterFilePath());
|
||||||
|
chapterTask.setResultFilePath(summarySource.getChapterFilePath());
|
||||||
|
chapterTask.setResponseData(responseData);
|
||||||
|
chapterTask.setStatus(2);
|
||||||
|
chapterTask.setErrorMsg(null);
|
||||||
|
chapterTask.setCompletedAt(LocalDateTime.now());
|
||||||
|
this.updateById(chapterTask);
|
||||||
|
updateProgress(meeting.getId(), 88, "章节生成完成,准备生成总结...", 0);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Chapter flow failed for meeting {}", meeting.getId(), ex);
|
||||||
|
updateAiTaskFail(chapterTask, "章节生成失败: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception {
|
||||||
|
if (isExternalSummaryModeEnabled()) {
|
||||||
|
updateMeetingStatus(meeting.getId(), 2);
|
||||||
|
updateProgress(meeting.getId(), 95, "等待外部章节与总结编排...", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId());
|
String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId());
|
||||||
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(summaryLockKey, "locked", 30, TimeUnit.MINUTES);
|
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(summaryLockKey, "locked", 30, TimeUnit.MINUTES);
|
||||||
if (Boolean.FALSE.equals(acquired)) {
|
if (Boolean.FALSE.equals(acquired)) {
|
||||||
|
|
@ -583,7 +653,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
MeetingSummarySource summarySource = meetingTranscriptRevisionService.resolveSummarySource(meeting, sumTask, asrModel);
|
MeetingSummarySource summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask != null ? chapterTask : sumTask);
|
||||||
if (summarySource.getText() == null || summarySource.getText().isBlank()) {
|
if (summarySource.getText() == null || summarySource.getText().isBlank()) {
|
||||||
failPendingSummaryTask(sumTask, "没有转录内容");
|
failPendingSummaryTask(sumTask, "没有转录内容");
|
||||||
updateMeetingStatus(meeting.getId(), 4);
|
updateMeetingStatus(meeting.getId(), 4);
|
||||||
|
|
@ -604,6 +674,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isExternalSummaryModeEnabled() {
|
||||||
|
return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean canExecuteTask(AiTask task) {
|
private boolean canExecuteTask(AiTask task) {
|
||||||
return task != null
|
return task != null
|
||||||
&& !Integer.valueOf(2).equals(task.getStatus())
|
&& !Integer.valueOf(2).equals(task.getStatus())
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import com.imeeting.common.MeetingConstants;
|
||||||
import com.imeeting.common.RedisKeys;
|
import com.imeeting.common.RedisKeys;
|
||||||
import com.imeeting.dto.biz.CreateMeetingCommand;
|
import com.imeeting.dto.biz.CreateMeetingCommand;
|
||||||
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
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.MeetingVO;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
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.HotWord;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.MeetingTranscript;
|
import com.imeeting.entity.biz.MeetingTranscript;
|
||||||
|
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
|
||||||
import com.imeeting.service.biz.AiTaskService;
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import com.imeeting.service.biz.MeetingCommandService;
|
import com.imeeting.service.biz.MeetingCommandService;
|
||||||
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
import com.imeeting.service.biz.MeetingService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
@ -57,6 +63,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||||
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
|
||||||
|
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
|
||||||
private final MeetingDomainSupport meetingDomainSupport;
|
private final MeetingDomainSupport meetingDomainSupport;
|
||||||
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
|
||||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||||
|
|
@ -64,6 +71,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
|
||||||
|
private String summaryOrchestrationMode;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
|
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);
|
asrTask.setTaskConfig(asrConfig);
|
||||||
aiTaskService.save(asrTask);
|
aiTaskService.save(asrTask);
|
||||||
|
|
||||||
meetingDomainSupport.createSummaryTask(
|
Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId();
|
||||||
|
meetingDomainSupport.createChapterTask(
|
||||||
meeting.getId(),
|
meeting.getId(),
|
||||||
runtimeProfile.getResolvedSummaryModelId(),
|
runtimeProfile.getResolvedSummaryModelId(),
|
||||||
|
chapterModelId,
|
||||||
runtimeProfile.getResolvedPromptId(),
|
runtimeProfile.getResolvedPromptId(),
|
||||||
command.getUserPrompt()
|
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()));
|
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
|
||||||
meetingService.updateById(meeting);
|
meetingService.updateById(meeting);
|
||||||
meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl());
|
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(),
|
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
|
||||||
null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
|
null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
|
||||||
meetingService.save(meeting);
|
meetingService.save(meeting);
|
||||||
meetingDomainSupport.createSummaryTask(
|
Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId();
|
||||||
|
meetingDomainSupport.createChapterTask(
|
||||||
meeting.getId(),
|
meeting.getId(),
|
||||||
runtimeProfile.getResolvedSummaryModelId(),
|
runtimeProfile.getResolvedSummaryModelId(),
|
||||||
|
chapterModelId,
|
||||||
runtimeProfile.getResolvedPromptId(),
|
runtimeProfile.getResolvedPromptId(),
|
||||||
command.getUserPrompt()
|
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.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
|
||||||
realtimeMeetingSessionStateService.rememberResumeConfig(meeting.getId(), buildRealtimeResumeConfig(command, tenantId, runtimeProfile));
|
realtimeMeetingSessionStateService.rememberResumeConfig(meeting.getId(), buildRealtimeResumeConfig(command, tenantId, runtimeProfile));
|
||||||
|
|
||||||
|
|
@ -457,6 +503,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
throw new RuntimeException("转录记录不存在");
|
throw new RuntimeException("转录记录不存在");
|
||||||
}
|
}
|
||||||
meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId());
|
meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId());
|
||||||
|
meetingTranscriptChapterService.invalidateCurrentVersion(command.getMeetingId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -502,13 +549,190 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void reSummary(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
|
public MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command) {
|
||||||
|
ensureExternalSummaryModeEnabled();
|
||||||
|
if (command == null || command.getMeetingId() == null) {
|
||||||
|
throw new RuntimeException("缺少会议ID,无法导入章节");
|
||||||
|
}
|
||||||
|
Meeting meeting = meetingService.getById(command.getMeetingId());
|
||||||
|
if (meeting == null) {
|
||||||
|
throw new RuntimeException("会议不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
AiTask latestChapterTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meeting.getId())
|
||||||
|
.eq(AiTask::getTaskType, "CHAPTER")
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
AiTask latestSummaryTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meeting.getId())
|
||||||
|
.eq(AiTask::getTaskType, "SUMMARY")
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
|
||||||
|
if (latestChapterTask == null) {
|
||||||
|
Long summaryModelId = resolveSummaryModelId(command, latestSummaryTask);
|
||||||
|
Long chapterModelId = resolveChapterModelId(command, latestSummaryTask, summaryModelId);
|
||||||
|
Long promptId = resolvePromptId(command, latestSummaryTask);
|
||||||
|
latestChapterTask = meetingDomainSupport.createChapterTask(
|
||||||
|
meeting.getId(),
|
||||||
|
summaryModelId,
|
||||||
|
chapterModelId,
|
||||||
|
promptId,
|
||||||
|
command.getUserPrompt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MeetingTranscriptChapterVersion version = meetingTranscriptChapterService.importExternalChapters(meeting, latestChapterTask, command);
|
||||||
|
latestChapterTask.setStatus(2);
|
||||||
|
latestChapterTask.setErrorMsg(null);
|
||||||
|
latestChapterTask.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
Map<String, Object> chapterResponse = latestChapterTask.getResponseData() == null
|
||||||
|
? new HashMap<>()
|
||||||
|
: new HashMap<>(latestChapterTask.getResponseData());
|
||||||
|
chapterResponse.put("chapterVersionId", version.getId());
|
||||||
|
chapterResponse.put("chapterCount", version.getChapterCount());
|
||||||
|
chapterResponse.put("sourceFingerprint", version.getSourceFingerprint());
|
||||||
|
chapterResponse.put("generationMode", version.getGenerationMode());
|
||||||
|
chapterResponse.put("algorithmVersion", version.getAlgorithmVersion());
|
||||||
|
chapterResponse.put("chapterFilePath", "meetings/" + meeting.getId() + "/chapters/current.md");
|
||||||
|
latestChapterTask.setResultFilePath("meetings/" + meeting.getId() + "/chapters/current.md");
|
||||||
|
latestChapterTask.setResponseData(chapterResponse);
|
||||||
|
aiTaskService.updateById(latestChapterTask);
|
||||||
|
|
||||||
|
MeetingTranscriptChapterImportResultVO result = new MeetingTranscriptChapterImportResultVO();
|
||||||
|
result.setChapterVersionId(version.getId());
|
||||||
|
result.setChapterCount(version.getChapterCount());
|
||||||
|
result.setChapterGenerationMode(version.getGenerationMode());
|
||||||
|
result.setChapterGeneratorLabel(version.getGeneratorLabel());
|
||||||
|
result.setAlgorithmVersion(version.getAlgorithmVersion());
|
||||||
|
result.setSourceFingerprint(version.getSourceFingerprint());
|
||||||
|
result.setSummaryTriggered(false);
|
||||||
|
|
||||||
|
if (Boolean.TRUE.equals(command.getTriggerSummary())) {
|
||||||
|
Long summaryModelId = resolveSummaryModelId(command, latestSummaryTask);
|
||||||
|
Long chapterModelId = resolveChapterModelId(command, latestSummaryTask, summaryModelId);
|
||||||
|
Long promptId = resolvePromptId(command, latestSummaryTask);
|
||||||
|
String userPrompt = command.getUserPrompt() != null
|
||||||
|
? command.getUserPrompt()
|
||||||
|
: latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null
|
||||||
|
? null
|
||||||
|
: stringValue(latestSummaryTask.getTaskConfig().get("userPrompt"));
|
||||||
|
|
||||||
|
AiTask createdSummaryTask = Objects.equals(chapterModelId, summaryModelId)
|
||||||
|
? meetingDomainSupport.createSummaryTask(
|
||||||
|
meeting.getId(),
|
||||||
|
summaryModelId,
|
||||||
|
promptId,
|
||||||
|
userPrompt
|
||||||
|
)
|
||||||
|
: meetingDomainSupport.createSummaryTask(
|
||||||
|
meeting.getId(),
|
||||||
|
summaryModelId,
|
||||||
|
chapterModelId,
|
||||||
|
promptId,
|
||||||
|
userPrompt
|
||||||
|
);
|
||||||
|
meeting.setLatestSummaryTaskId(createdSummaryTask.getId());
|
||||||
|
meeting.setStatus(2);
|
||||||
|
meetingService.updateById(meeting);
|
||||||
|
aiTaskService.dispatchSummaryTask(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId());
|
||||||
|
|
||||||
|
result.setSummaryTriggered(true);
|
||||||
|
result.setSummaryTaskId(createdSummaryTask.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void finalizeSummary(MeetingSummaryFinalizeDTO command) {
|
||||||
|
ensureExternalSummaryModeEnabled();
|
||||||
|
if (command == null || command.getMeetingId() == null) {
|
||||||
|
throw new RuntimeException("缺少会议ID,无法回填总结");
|
||||||
|
}
|
||||||
|
Meeting meeting = meetingService.getById(command.getMeetingId());
|
||||||
|
if (meeting == null) {
|
||||||
|
throw new RuntimeException("会议不存在");
|
||||||
|
}
|
||||||
|
AiTask summaryTask = aiTaskService.getById(command.getSummaryTaskId());
|
||||||
|
if (summaryTask == null || !Objects.equals(summaryTask.getMeetingId(), meeting.getId()) || !"SUMMARY".equals(summaryTask.getTaskType())) {
|
||||||
|
throw new RuntimeException("总结任务不存在或不属于当前会议");
|
||||||
|
}
|
||||||
|
|
||||||
|
MeetingTranscriptChapterVersion currentVersion = meetingTranscriptChapterService.getCurrentVersion(meeting.getId());
|
||||||
|
if (currentVersion == null) {
|
||||||
|
throw new RuntimeException("当前会议不存在有效章节版本");
|
||||||
|
}
|
||||||
|
if (!Objects.equals(currentVersion.getId(), command.getChapterVersionId())) {
|
||||||
|
throw new RuntimeException("章节版本不是当前生效版本,拒绝回填总结");
|
||||||
|
}
|
||||||
|
if (!Objects.equals(currentVersion.getSourceFingerprint(), command.getSourceFingerprint())) {
|
||||||
|
throw new RuntimeException("转录指纹已变化,拒绝回填过期总结结果");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> normalizedAnalysis = meetingSummaryFileService.normalizeSummaryAnalysis(command.getAnalysis());
|
||||||
|
String relativePath = meetingSummaryFileService.saveSummaryContent(meeting, summaryTask, command.getSummaryContent());
|
||||||
|
|
||||||
|
Map<String, Object> responseData = summaryTask.getResponseData() == null
|
||||||
|
? new HashMap<>()
|
||||||
|
: new HashMap<>(summaryTask.getResponseData());
|
||||||
|
Map<String, Object> summarySource = new HashMap<>();
|
||||||
|
summarySource.put("sourceType", "CHAPTER_VERSION");
|
||||||
|
summarySource.put("chapterVersionId", currentVersion.getId());
|
||||||
|
summarySource.put("chapterCount", currentVersion.getChapterCount());
|
||||||
|
summarySource.put("sourceFingerprint", currentVersion.getSourceFingerprint());
|
||||||
|
summarySource.put("algorithmVersion", currentVersion.getAlgorithmVersion());
|
||||||
|
summarySource.put("generationMode", currentVersion.getGenerationMode());
|
||||||
|
responseData.put("summarySource", summarySource);
|
||||||
|
|
||||||
|
Map<String, Object> summaryBundle = new HashMap<>();
|
||||||
|
summaryBundle.put("summaryContent", command.getSummaryContent());
|
||||||
|
summaryBundle.put("analysis", normalizedAnalysis);
|
||||||
|
responseData.put("summaryBundle", summaryBundle);
|
||||||
|
responseData.put("normalizedAnalysis", normalizedAnalysis);
|
||||||
|
|
||||||
|
summaryTask.setResultFilePath(relativePath);
|
||||||
|
summaryTask.setResponseData(responseData);
|
||||||
|
summaryTask.setStatus(2);
|
||||||
|
summaryTask.setErrorMsg(null);
|
||||||
|
summaryTask.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
aiTaskService.updateById(summaryTask);
|
||||||
|
|
||||||
|
meeting.setLatestSummaryTaskId(summaryTask.getId());
|
||||||
|
meeting.setStatus(3);
|
||||||
|
meetingService.updateById(meeting);
|
||||||
|
updateMeetingProgress(meeting.getId(), 100, "外部总结回填完成", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
|
||||||
Meeting meeting = meetingService.getById(meetingId);
|
Meeting meeting = meetingService.getById(meetingId);
|
||||||
if (meeting == null) {
|
if (meeting == null) {
|
||||||
throw new RuntimeException("会议不存在");
|
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);
|
meeting.setStatus(2);
|
||||||
meetingService.updateById(meeting);
|
meetingService.updateById(meeting);
|
||||||
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||||
|
|
@ -551,6 +775,27 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
|
|
||||||
resetAiTask(asrTask, new HashMap<>(asrTask.getTaskConfig()));
|
resetAiTask(asrTask, new HashMap<>(asrTask.getTaskConfig()));
|
||||||
aiTaskService.updateById(asrTask);
|
aiTaskService.updateById(asrTask);
|
||||||
|
AiTask chapterTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meetingId)
|
||||||
|
.eq(AiTask::getTaskType, "CHAPTER")
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (chapterTask == null) {
|
||||||
|
Long summaryModelId = longValue(summaryTask.getTaskConfig().get("summaryModelId"));
|
||||||
|
Long chapterModelId = longValue(summaryTask.getTaskConfig().get("chapterModelId"));
|
||||||
|
Long promptId = longValue(summaryTask.getTaskConfig().get("promptId"));
|
||||||
|
String userPrompt = stringValue(summaryTask.getTaskConfig().get("userPrompt"));
|
||||||
|
chapterTask = meetingDomainSupport.createChapterTask(
|
||||||
|
meetingId,
|
||||||
|
summaryModelId,
|
||||||
|
chapterModelId != null ? chapterModelId : summaryModelId,
|
||||||
|
promptId,
|
||||||
|
userPrompt
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resetAiTask(chapterTask, new HashMap<>(chapterTask.getTaskConfig()));
|
||||||
|
aiTaskService.updateById(chapterTask);
|
||||||
|
}
|
||||||
resetAiTask(summaryTask, new HashMap<>(summaryTask.getTaskConfig()));
|
resetAiTask(summaryTask, new HashMap<>(summaryTask.getTaskConfig()));
|
||||||
aiTaskService.updateById(summaryTask);
|
aiTaskService.updateById(summaryTask);
|
||||||
|
|
||||||
|
|
@ -560,6 +805,63 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
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) {
|
private void dispatchSummaryTaskAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
||||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId);
|
aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId);
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,28 @@ public class MeetingDomainSupport {
|
||||||
return meeting;
|
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();
|
AiTask sumTask = new AiTask();
|
||||||
sumTask.setMeetingId(meetingId);
|
sumTask.setMeetingId(meetingId);
|
||||||
sumTask.setTaskType("SUMMARY");
|
sumTask.setTaskType("SUMMARY");
|
||||||
sumTask.setStatus(0);
|
sumTask.setStatus(0);
|
||||||
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, promptId, userPrompt));
|
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt));
|
||||||
aiTaskService.save(sumTask);
|
aiTaskService.save(sumTask);
|
||||||
|
return sumTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void publishMeetingCreated(Long meetingId, Long tenantId, Long userId) {
|
public void publishMeetingCreated(Long meetingId, Long tenantId, Long userId) {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,20 @@ package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
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.MeetingTranscriptVO;
|
||||||
import com.imeeting.dto.biz.MeetingVO;
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
|
import com.imeeting.entity.biz.AiTask;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.MeetingTranscript;
|
import com.imeeting.entity.biz.MeetingTranscript;
|
||||||
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
import com.imeeting.mapper.biz.MeetingMapper;
|
import com.imeeting.mapper.biz.MeetingMapper;
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
import com.imeeting.service.biz.MeetingQueryService;
|
import com.imeeting.service.biz.MeetingQueryService;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
import com.imeeting.service.biz.MeetingService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||||
import com.unisbase.dto.PageResult;
|
import com.unisbase.dto.PageResult;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
@ -28,6 +34,9 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
|
||||||
private final MeetingMapper meetingMapper;
|
private final MeetingMapper meetingMapper;
|
||||||
private final MeetingTranscriptMapper transcriptMapper;
|
private final MeetingTranscriptMapper transcriptMapper;
|
||||||
private final MeetingDomainSupport meetingDomainSupport;
|
private final MeetingDomainSupport meetingDomainSupport;
|
||||||
|
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
|
||||||
|
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||||
|
private final AiTaskService aiTaskService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
|
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
|
||||||
|
|
@ -94,6 +103,50 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
|
||||||
}).collect(Collectors.toList());
|
}).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> getChapters(Long meetingId) {
|
||||||
|
return meetingTranscriptChapterService.listCurrentChapterAnalysis(meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MeetingTranscriptSourceVO getTranscriptSource(Long meetingId) {
|
||||||
|
return meetingTranscriptChapterService.buildTranscriptSource(meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MeetingSummaryPromptContextVO buildSummaryPromptContext(Long meetingId, MeetingSummaryPromptContextRequestDTO request) {
|
||||||
|
Meeting meeting = meetingService.getById(meetingId);
|
||||||
|
if (meeting == null) {
|
||||||
|
throw new RuntimeException("会议不存在");
|
||||||
|
}
|
||||||
|
AiTask latestSummaryTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meetingId)
|
||||||
|
.eq(AiTask::getTaskType, "SUMMARY")
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null) {
|
||||||
|
throw new RuntimeException("缺少可用的总结任务配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
Long summaryModelId = firstLong(request == null ? null : request.getSummaryModelId(), latestSummaryTask.getTaskConfig().get("summaryModelId"));
|
||||||
|
Long chapterModelId = firstLong(request == null ? null : request.getChapterModelId(), latestSummaryTask.getTaskConfig().get("chapterModelId"), summaryModelId);
|
||||||
|
Long promptId = firstLong(request == null ? null : request.getPromptId(), latestSummaryTask.getTaskConfig().get("promptId"));
|
||||||
|
String userPrompt = request != null && request.getUserPrompt() != null
|
||||||
|
? request.getUserPrompt()
|
||||||
|
: stringValue(latestSummaryTask.getTaskConfig().get("userPrompt"));
|
||||||
|
|
||||||
|
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt);
|
||||||
|
MeetingSummaryPromptContextVO context = new MeetingSummaryPromptContextVO();
|
||||||
|
context.setPromptSchemaVersion(String.valueOf(taskConfig.get("promptSchemaVersion")));
|
||||||
|
context.setSystemMessage(meetingSummaryPromptAssembler.buildSystemMessage(taskConfig));
|
||||||
|
context.setUserMessageTemplate(meetingSummaryPromptAssembler.buildUserMessageTemplate(meeting, userPrompt));
|
||||||
|
context.setEffectiveTemplatePrompt(stringValue(taskConfig.get("effectiveTemplatePrompt")));
|
||||||
|
context.setEffectiveUserPrompt(meetingSummaryPromptAssembler.normalizeOptionalText(userPrompt));
|
||||||
|
context.setSummaryModelId(summaryModelId);
|
||||||
|
context.setChapterModelId(chapterModelId);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin) {
|
public Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin) {
|
||||||
Map<String, Object> stats = new HashMap<>();
|
Map<String, Object> stats = new HashMap<>();
|
||||||
|
|
@ -134,4 +187,24 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
|
||||||
meetingDomainSupport.fillMeetingVO(meeting, vo, includeSummary, includeSummary);
|
meetingDomainSupport.fillMeetingVO(meeting, vo, includeSummary, includeSummary);
|
||||||
return vo;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,6 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
|
||||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||||
normalized.put("overview", clipText(asText(parsed.get("overview")), 500));
|
normalized.put("overview", clipText(asText(parsed.get("overview")), 500));
|
||||||
normalized.put("keywords", normalizeStringList(parsed.get("keywords")));
|
normalized.put("keywords", normalizeStringList(parsed.get("keywords")));
|
||||||
normalized.put("chapters", normalizeChapterList(parsed.get("chapters")));
|
|
||||||
normalized.put("speakerSummaries", normalizeSpeakerSummaries(parsed.get("speakerSummaries")));
|
normalized.put("speakerSummaries", normalizeSpeakerSummaries(parsed.get("speakerSummaries")));
|
||||||
normalized.put("keyPoints", normalizeKeyPoints(parsed.get("keyPoints")));
|
normalized.put("keyPoints", normalizeKeyPoints(parsed.get("keyPoints")));
|
||||||
List<String> todos = normalizeStringList(parsed.containsKey("todos") ? parsed.get("todos") : parsed.get("actionItems"));
|
List<String> todos = normalizeStringList(parsed.containsKey("todos") ? parsed.get("todos") : parsed.get("actionItems"));
|
||||||
|
|
@ -240,6 +239,41 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String saveSummaryContent(Meeting meeting, AiTask summaryTask, String summaryContent) {
|
||||||
|
if (meeting == null || summaryTask == null) {
|
||||||
|
throw new RuntimeException("保存总结文件缺少会议或任务上下文");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
||||||
|
Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meeting.getId()), "summaries");
|
||||||
|
Files.createDirectories(targetDir);
|
||||||
|
|
||||||
|
String relativePath = summaryTask.getResultFilePath();
|
||||||
|
if (relativePath == null || relativePath.isBlank()) {
|
||||||
|
String timestamp = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
|
||||||
|
relativePath = "meetings/" + meeting.getId() + "/summaries/summary_" + timestamp + ".md";
|
||||||
|
}
|
||||||
|
|
||||||
|
Path summaryPath = Paths.get(basePath, relativePath.replace("\\", "/"));
|
||||||
|
Path parent = summaryPath.getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
String existingContent = Files.exists(summaryPath) ? Files.readString(summaryPath, StandardCharsets.UTF_8) : "";
|
||||||
|
String frontMatter = extractFrontMatter(existingContent, meeting, summaryTask);
|
||||||
|
Files.writeString(summaryPath, frontMatter + normalizeSummaryMarkdown(summaryContent), StandardCharsets.UTF_8);
|
||||||
|
return relativePath.replace("\\", "/");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new RuntimeException("保存总结文件失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> normalizeSummaryAnalysis(Map<String, Object> analysis) {
|
||||||
|
return analysis == null ? new LinkedHashMap<>() : parseSummaryAnalysisFromMap(analysis);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String stripFrontMatter(String markdown) {
|
public String stripFrontMatter(String markdown) {
|
||||||
if (markdown == null || markdown.isBlank()) {
|
if (markdown == null || markdown.isBlank()) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.imeeting.service.biz.impl;
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
import com.imeeting.common.SysParamKeys;
|
import com.imeeting.common.SysParamKeys;
|
||||||
|
import com.imeeting.dto.biz.MeetingSummarySource;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.PromptTemplate;
|
import com.imeeting.entity.biz.PromptTemplate;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
|
|
@ -17,6 +18,7 @@ import java.util.Map;
|
||||||
public class MeetingSummaryPromptAssembler {
|
public class MeetingSummaryPromptAssembler {
|
||||||
|
|
||||||
public static final String PROMPT_SCHEMA_VERSION = "v2";
|
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 =
|
private static final String SYSTEM_PROMPT_NOT_CONFIGURED_MESSAGE =
|
||||||
"系统提示词未配置,请先维护系统参数 " + SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT;
|
"系统提示词未配置,请先维护系统参数 " + SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT;
|
||||||
|
|
||||||
|
|
@ -24,8 +26,13 @@ public class MeetingSummaryPromptAssembler {
|
||||||
private final SysParamService sysParamService;
|
private final SysParamService sysParamService;
|
||||||
|
|
||||||
public Map<String, Object> buildTaskConfig(Long summaryModelId, Long promptId, String userPrompt) {
|
public Map<String, Object> buildTaskConfig(Long summaryModelId, Long promptId, String userPrompt) {
|
||||||
|
return buildTaskConfig(summaryModelId, summaryModelId, promptId, userPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
|
||||||
Map<String, Object> taskConfig = new HashMap<>();
|
Map<String, Object> taskConfig = new HashMap<>();
|
||||||
taskConfig.put("summaryModelId", summaryModelId);
|
taskConfig.put("summaryModelId", summaryModelId);
|
||||||
|
taskConfig.put("chapterModelId", chapterModelId != null ? chapterModelId : summaryModelId);
|
||||||
taskConfig.put("promptSchemaVersion", PROMPT_SCHEMA_VERSION);
|
taskConfig.put("promptSchemaVersion", PROMPT_SCHEMA_VERSION);
|
||||||
taskConfig.put("effectiveSystemPrompt", resolveSystemPrompt());
|
taskConfig.put("effectiveSystemPrompt", resolveSystemPrompt());
|
||||||
|
|
||||||
|
|
@ -50,31 +57,55 @@ public class MeetingSummaryPromptAssembler {
|
||||||
String templatePrompt = firstNonBlank(
|
String templatePrompt = firstNonBlank(
|
||||||
stringValue(taskConfig, "effectiveTemplatePrompt"),
|
stringValue(taskConfig, "effectiveTemplatePrompt"),
|
||||||
stringValue(taskConfig, "promptContent"),
|
stringValue(taskConfig, "promptContent"),
|
||||||
"请输出结构清晰、信息完整、适合直接阅读和导出的会议纪要。"
|
"请输出结构清晰、信息完整、适合直接阅读和导出的正式会议纪要。"
|
||||||
);
|
);
|
||||||
|
|
||||||
return String.join("\n\n",
|
return String.join("\n\n",
|
||||||
"你是一名擅长中文会议纪要、结构化分析和待办提取的助手。",
|
"你是一名擅长中文会议纪要、结构化分析和待办提取的助手。",
|
||||||
"系统提示词(基础边界,优先级最高):\n" + systemPrompt,
|
"系统提示词(最高优先级):\n" + systemPrompt,
|
||||||
"模板提示词(结构与风格要求):\n" + templatePrompt,
|
"模板提示词(结构和风格要求):\n" + templatePrompt,
|
||||||
"输出要求:",
|
"""
|
||||||
"1. 最终只能输出一个 JSON 对象,不要输出 Markdown 代码块、解释说明或额外前后缀。",
|
输出要求:
|
||||||
"2. JSON 必须包含 `summaryContent` 和 `analysis` 两个顶级字段。",
|
1. 最终只允许输出一个 JSON 对象,不要输出 Markdown 代码块、解释说明或额外前后缀。
|
||||||
"3. `summaryContent` 必须是完整、自然、可直接保存和导出的正式会议纪要正文。",
|
2. JSON 必须包含 `summaryContent` 和 `analysis` 两个顶级字段。
|
||||||
"4. `analysis` 仅作为结构化附加结果,不能替代 `summaryContent`。",
|
3. `summaryContent` 必须是完整、自然、可直接保存和导出的正式会议纪要正文。
|
||||||
"5. 如果系统提示词、模板提示词和用户提示词存在冲突,优先级为:系统提示词 > 模板提示词 > 用户提示词。"
|
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()
|
String participants = meeting.getParticipants() == null || meeting.getParticipants().isBlank()
|
||||||
? "未填写"
|
? "未填写"
|
||||||
: meeting.getParticipants();
|
: meeting.getParticipants();
|
||||||
String meetingTime = meeting.getMeetingTime() == null ? "未知" : meeting.getMeetingTime().toString();
|
String meetingTime = meeting.getMeetingTime() == null ? "未知" : meeting.getMeetingTime().toString();
|
||||||
String normalizedUserPrompt = normalizeOptionalText(userPrompt);
|
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()
|
StringBuilder message = new StringBuilder()
|
||||||
.append("请基于以下会议转写内容生成会议纪要与结构化分析结果。\n")
|
.append("请基于以下会议信息、章节辅助结构和原始会议转录生成会议纪要与结构化分析结果。\n")
|
||||||
.append("会议信息:\n")
|
.append("会议信息:\n")
|
||||||
.append("标题:").append(StringUtils.hasText(meeting.getTitle()) ? meeting.getTitle() : "未命名会议").append("\n")
|
.append("标题:").append(StringUtils.hasText(meeting.getTitle()) ? meeting.getTitle() : "未命名会议").append("\n")
|
||||||
.append("会议时间:").append(meetingTime).append("\n")
|
.append("会议时间:").append(meetingTime).append("\n")
|
||||||
|
|
@ -87,30 +118,35 @@ public class MeetingSummaryPromptAssembler {
|
||||||
.append("\n");
|
.append("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
message.append("\n")
|
message.append("""
|
||||||
.append("返回 JSON,格式固定如下:\n")
|
|
||||||
.append("{\n")
|
返回 JSON,格式固定如下:
|
||||||
.append(" \"summaryContent\": \"完整会议纪要正文,使用 markdown\",\n")
|
{
|
||||||
.append(" \"analysis\": {\n")
|
"summaryContent": "完整会议纪要正文,使用 markdown",
|
||||||
// .append(" \"overview\": \"会议概览\",\n")
|
"analysis": {
|
||||||
.append(" \"keywords\": [\"关键词1\", \"关键词2\"],\n")
|
"keywords": ["关键词1", "关键词2"]
|
||||||
// .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")
|
1. `summaryContent` 必须优先遵循模板提示词中的结构、标题层级、章节顺序和写作风格。
|
||||||
.append("}\n")
|
2. `analysis.keywords` 必须基于完整转录内容生成,不得脱离上下文,并且在原始会议转录中能找到对应依据。
|
||||||
.append("要求:\n")
|
3. 章节信息只是辅助结构,不能替代原始转录真值;数字、日期、时间、金额、百分比等必须优先以原始转录为准。
|
||||||
.append("1. `summaryContent` 必须优先遵循模板提示词中的结构、标题层级、章节顺序和写作风格。\n")
|
4. 最终不要在 `analysis` 中返回章节列表。
|
||||||
.append("2. `analysis.keywords` 必须基于完整转写内容生成,不得脱离上下文。并且在会议转写中能找到对应的原文\n")
|
5. 仅输出 JSON。
|
||||||
// .append("3. 若无待办事项,`todos` 返回空数组。\n")
|
|
||||||
.append("3. 仅输出 JSON。\n")
|
章节辅助结构如下:
|
||||||
.append("\n")
|
""").append(chapterOutlineText)
|
||||||
.append("会议转写如下:\n")
|
.append("\n\n")
|
||||||
.append(asrText == null ? "" : asrText);
|
.append("原始会议转录如下:\n")
|
||||||
|
.append(rawTranscriptText == null ? "" : rawTranscriptText);
|
||||||
return message.toString();
|
return message.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String buildUserMessageTemplate(Meeting meeting, String userPrompt) {
|
||||||
|
return buildUserMessage(meeting, SUMMARY_SOURCE_PLACEHOLDER, userPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
public String resolveSystemPrompt() {
|
public String resolveSystemPrompt() {
|
||||||
String configured = sysParamService.getCachedParamValue(
|
String configured = sysParamService.getCachedParamValue(
|
||||||
SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT,
|
SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,881 @@
|
||||||
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
|
import com.imeeting.dto.biz.MeetingSummarySource;
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
|
||||||
|
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
||||||
|
import com.imeeting.entity.biz.AiTask;
|
||||||
|
import com.imeeting.entity.biz.Meeting;
|
||||||
|
import com.imeeting.entity.biz.MeetingTranscript;
|
||||||
|
import com.imeeting.entity.biz.MeetingTranscriptChapter;
|
||||||
|
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
|
||||||
|
import com.imeeting.mapper.biz.MeetingTranscriptChapterMapper;
|
||||||
|
import com.imeeting.mapper.biz.MeetingTranscriptChapterVersionMapper;
|
||||||
|
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
|
import com.imeeting.service.biz.AiModelService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MeetingTranscriptChapterServiceImpl implements MeetingTranscriptChapterService {
|
||||||
|
|
||||||
|
private static final String SOURCE_TYPE_CHAPTER_VERSION = "CHAPTER_VERSION";
|
||||||
|
private static final String SOURCE_TYPE_RAW_FALLBACK = "RAW_FALLBACK";
|
||||||
|
private static final String GENERATION_MODE_INTERNAL = "INTERNAL_LLM";
|
||||||
|
private static final String GENERATION_MODE_EXTERNAL = "EXTERNAL_IMPORT";
|
||||||
|
private static final String DEFAULT_ALGORITHM_VERSION = "chapter-llm-v1";
|
||||||
|
private static final String DEFAULT_GENERATOR_LABEL = "builtin-chapter-llm";
|
||||||
|
private static final String CHAPTER_RELATIVE_PATH_TEMPLATE = "meetings/%s/chapters/current.md";
|
||||||
|
private static final Pattern FIDELITY_POINT_PATTERN = Pattern.compile(
|
||||||
|
"(\\d{4}年\\d{1,2}月\\d{1,2}日|\\d{1,2}:\\d{2}|\\d+(?:\\.\\d+)?(?:万|亿|元|%|%|人|天|月|年))"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final MeetingTranscriptMapper transcriptMapper;
|
||||||
|
private final MeetingTranscriptChapterVersionMapper versionMapper;
|
||||||
|
private final MeetingTranscriptChapterMapper chapterMapper;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private AiModelService aiModelService;
|
||||||
|
|
||||||
|
@Value("${imeeting.summary-orchestration.chapter-policy:INTERNAL_LLM}")
|
||||||
|
private String chapterGenerationPolicy;
|
||||||
|
|
||||||
|
@Value("${unisbase.app.upload-path}")
|
||||||
|
private String uploadPath;
|
||||||
|
|
||||||
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(60))
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
public void setAiModelService(AiModelService aiModelService) {
|
||||||
|
this.aiModelService = aiModelService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask) {
|
||||||
|
List<MeetingTranscript> transcripts = loadRawTranscripts(meeting.getId());
|
||||||
|
String transcriptText = buildTranscriptText(transcripts);
|
||||||
|
String fingerprint = buildSourceFingerprint(transcripts);
|
||||||
|
if (transcripts.isEmpty() || transcriptText.isBlank()) {
|
||||||
|
return MeetingSummarySource.builder()
|
||||||
|
.text(transcriptText)
|
||||||
|
.sourceType(SOURCE_TYPE_RAW_FALLBACK)
|
||||||
|
.fallbackUsed(true)
|
||||||
|
.sourceFingerprint(fingerprint)
|
||||||
|
.algorithmVersion(DEFAULT_ALGORITHM_VERSION)
|
||||||
|
.generationMode("NONE")
|
||||||
|
.rawTranscriptText(transcriptText)
|
||||||
|
.chapterOutlineText("")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
MeetingTranscriptChapterVersion current = findReusableCurrentVersion(meeting.getId(), fingerprint);
|
||||||
|
if (current != null) {
|
||||||
|
return buildSummarySource(meeting, current, transcripts, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("EXTERNAL_IMPORT_REQUIRED".equalsIgnoreCase(chapterGenerationPolicy)) {
|
||||||
|
throw new RuntimeException("缺少外部章节化保真结果,无法继续总结");
|
||||||
|
}
|
||||||
|
|
||||||
|
MeetingTranscriptChapterVersion generated = generateInternalVersion(meeting, summaryTask, transcripts, fingerprint);
|
||||||
|
return buildSummarySource(meeting, generated, transcripts, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> listCurrentChapterAnalysis(Long meetingId) {
|
||||||
|
MeetingTranscriptChapterVersion current = findCurrentVersion(meetingId);
|
||||||
|
if (current == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<MeetingTranscript> transcripts = loadRawTranscripts(meetingId);
|
||||||
|
Map<Long, MeetingTranscript> transcriptById = transcripts.stream()
|
||||||
|
.collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new));
|
||||||
|
return loadVersionChapters(current.getId()).stream()
|
||||||
|
.map(chapter -> toChapterAnalysis(chapter, transcriptById))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void invalidateCurrentVersion(Long meetingId) {
|
||||||
|
versionMapper.update(null, new LambdaUpdateWrapper<MeetingTranscriptChapterVersion>()
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getIsCurrent, 1)
|
||||||
|
.set(MeetingTranscriptChapterVersion::getIsCurrent, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public MeetingTranscriptChapterVersion importExternalChapters(Meeting meeting, AiTask sourceTask, MeetingTranscriptChapterImportDTO command) {
|
||||||
|
List<MeetingTranscript> transcripts = loadRawTranscripts(meeting.getId());
|
||||||
|
if (transcripts.isEmpty()) {
|
||||||
|
throw new RuntimeException("当前会议没有可用转录,无法导入章节");
|
||||||
|
}
|
||||||
|
if (command.getChapters() == null || command.getChapters().isEmpty()) {
|
||||||
|
throw new RuntimeException("章节导入数据不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
String fingerprint = buildSourceFingerprint(transcripts);
|
||||||
|
List<ChapterCandidate> candidates = command.getChapters().stream()
|
||||||
|
.sorted(Comparator.comparing(MeetingTranscriptChapterImportDTO.ChapterItem::getChapterNo))
|
||||||
|
.map(item -> new ChapterCandidate(
|
||||||
|
item.getChapterNo(),
|
||||||
|
normalizeOptionalText(item.getTitle()),
|
||||||
|
normalizeOptionalText(item.getSummary()),
|
||||||
|
normalizeKeywords(item.getKeywords()),
|
||||||
|
item.getStartTranscriptId(),
|
||||||
|
item.getEndTranscriptId(),
|
||||||
|
item.getConfidence() == null ? BigDecimal.ONE : item.getConfidence()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
validateCandidatesAgainstTranscripts(candidates, transcripts);
|
||||||
|
MeetingTranscriptChapterVersion version = persistVersion(
|
||||||
|
meeting,
|
||||||
|
sourceTask,
|
||||||
|
fingerprintsafe(fingerprint),
|
||||||
|
nonBlank(command.getAlgorithmVersion(), "external-import-v1"),
|
||||||
|
GENERATION_MODE_EXTERNAL,
|
||||||
|
nonBlank(command.getChapterGeneratorLabel(), "external-import"),
|
||||||
|
transcripts,
|
||||||
|
candidates
|
||||||
|
);
|
||||||
|
writeCurrentChapterMarkdown(meeting, version, transcripts);
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MeetingTranscriptSourceVO buildTranscriptSource(Long meetingId) {
|
||||||
|
List<MeetingTranscript> transcripts = loadRawTranscripts(meetingId);
|
||||||
|
MeetingTranscriptSourceVO source = new MeetingTranscriptSourceVO();
|
||||||
|
source.setMeetingId(meetingId);
|
||||||
|
source.setSourceFingerprint(buildSourceFingerprint(transcripts));
|
||||||
|
source.setTranscriptText(buildTranscriptText(transcripts));
|
||||||
|
source.setSegments(transcripts.stream().map(this::toTranscriptVO).toList());
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeetingTranscriptChapterVersion getCurrentVersion(Long meetingId) {
|
||||||
|
return findCurrentVersion(meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingTranscriptChapterVersion generateInternalVersion(Meeting meeting,
|
||||||
|
AiTask summaryTask,
|
||||||
|
List<MeetingTranscript> transcripts,
|
||||||
|
String fingerprint) {
|
||||||
|
List<MeetingTranscriptChapterImportDTO.ChapterItem> chapterItems = generateInternalChapterItems(summaryTask, transcripts);
|
||||||
|
if (chapterItems == null || chapterItems.isEmpty()) {
|
||||||
|
throw new RuntimeException("章节模型未返回有效章节结果,无法继续总结");
|
||||||
|
}
|
||||||
|
List<ChapterCandidate> candidates = chapterItems.stream()
|
||||||
|
.sorted(Comparator.comparing(MeetingTranscriptChapterImportDTO.ChapterItem::getChapterNo))
|
||||||
|
.map(item -> new ChapterCandidate(
|
||||||
|
item.getChapterNo(),
|
||||||
|
normalizeOptionalText(item.getTitle()),
|
||||||
|
normalizeOptionalText(item.getSummary()),
|
||||||
|
normalizeKeywords(item.getKeywords()),
|
||||||
|
item.getStartTranscriptId(),
|
||||||
|
item.getEndTranscriptId(),
|
||||||
|
item.getConfidence() == null ? BigDecimal.valueOf(0.88D) : item.getConfidence()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
Object chapterModelId = summaryTask == null || summaryTask.getTaskConfig() == null
|
||||||
|
? null
|
||||||
|
: summaryTask.getTaskConfig().get("chapterModelId");
|
||||||
|
String generatorLabel = chapterModelId == null ? DEFAULT_GENERATOR_LABEL : DEFAULT_GENERATOR_LABEL + "-" + chapterModelId;
|
||||||
|
validateCandidatesAgainstTranscripts(candidates, transcripts);
|
||||||
|
return persistVersion(
|
||||||
|
meeting,
|
||||||
|
summaryTask,
|
||||||
|
fingerprintsafe(fingerprint),
|
||||||
|
DEFAULT_ALGORITHM_VERSION,
|
||||||
|
GENERATION_MODE_INTERNAL,
|
||||||
|
generatorLabel,
|
||||||
|
transcripts,
|
||||||
|
candidates
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<MeetingTranscriptChapterImportDTO.ChapterItem> generateInternalChapterItems(AiTask summaryTask, List<MeetingTranscript> transcripts) {
|
||||||
|
if (aiModelService == null || summaryTask == null || summaryTask.getTaskConfig() == null) {
|
||||||
|
throw new RuntimeException("章节模型未配置,无法生成章节");
|
||||||
|
}
|
||||||
|
Long chapterModelId = longValue(summaryTask.getTaskConfig().get("chapterModelId"));
|
||||||
|
if (chapterModelId == null) {
|
||||||
|
chapterModelId = longValue(summaryTask.getTaskConfig().get("summaryModelId"));
|
||||||
|
}
|
||||||
|
if (chapterModelId == null) {
|
||||||
|
throw new RuntimeException("缺少 chapterModelId,无法生成章节");
|
||||||
|
}
|
||||||
|
|
||||||
|
AiModelVO llmModel;
|
||||||
|
try {
|
||||||
|
llmModel = aiModelService.getModelById(chapterModelId, "LLM");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new RuntimeException("解析章节模型失败: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
if (llmModel == null || !Integer.valueOf(1).equals(llmModel.getStatus())) {
|
||||||
|
throw new RuntimeException("章节模型不存在或未启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Map<String, Object> requestBody = new LinkedHashMap<>();
|
||||||
|
requestBody.put("model", llmModel.getModelCode());
|
||||||
|
requestBody.put("temperature", llmModel.getTemperature());
|
||||||
|
requestBody.put("messages", List.of(
|
||||||
|
Map.of("role", "system", "content", buildChapterSystemPrompt()),
|
||||||
|
Map.of("role", "user", "content", buildChapterUserPrompt(transcripts))
|
||||||
|
));
|
||||||
|
String payload = objectMapper.writeValueAsString(requestBody);
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(appendPath(llmModel.getBaseUrl(),
|
||||||
|
nonBlank(llmModel.getApiPath(), "v1/chat/completions"))))
|
||||||
|
.header("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Authorization", "Bearer " + llmModel.getApiKey())
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new RuntimeException("章节模型调用失败,HTTP " + response.statusCode());
|
||||||
|
}
|
||||||
|
JsonNode root = objectMapper.readTree(response.body());
|
||||||
|
String content = sanitizeResponseContent(root.path("choices").path(0).path("message").path("content").asText(""));
|
||||||
|
if (content.isBlank()) {
|
||||||
|
throw new RuntimeException("章节模型未返回内容");
|
||||||
|
}
|
||||||
|
JsonNode parsed = objectMapper.readTree(content);
|
||||||
|
JsonNode chaptersNode = parsed.path("chapters");
|
||||||
|
if (!chaptersNode.isArray()) {
|
||||||
|
throw new RuntimeException("章节模型返回格式不正确,缺少 chapters 数组");
|
||||||
|
}
|
||||||
|
List<MeetingTranscriptChapterImportDTO.ChapterItem> result = new ArrayList<>();
|
||||||
|
for (JsonNode item : chaptersNode) {
|
||||||
|
Long startTranscriptId = longValue(item.path("startTranscriptId").asText(null));
|
||||||
|
Long endTranscriptId = longValue(item.path("endTranscriptId").asText(null));
|
||||||
|
Integer chapterNo = item.path("chapterNo").isInt() ? item.path("chapterNo").asInt() : null;
|
||||||
|
if (chapterNo == null || startTranscriptId == null || endTranscriptId == null) {
|
||||||
|
throw new RuntimeException("章节模型返回了不完整的章节边界");
|
||||||
|
}
|
||||||
|
List<String> keywords = new ArrayList<>();
|
||||||
|
if (item.path("keywords").isArray()) {
|
||||||
|
for (JsonNode keyword : item.path("keywords")) {
|
||||||
|
String text = normalizeOptionalText(keyword.asText(""));
|
||||||
|
if (text != null && !keywords.contains(text)) {
|
||||||
|
keywords.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MeetingTranscriptChapterImportDTO.ChapterItem chapterItem = new MeetingTranscriptChapterImportDTO.ChapterItem();
|
||||||
|
chapterItem.setChapterNo(chapterNo);
|
||||||
|
chapterItem.setTitle(normalizeOptionalText(item.path("title").asText("")));
|
||||||
|
chapterItem.setSummary(normalizeOptionalText(item.path("summary").asText("")));
|
||||||
|
chapterItem.setKeywords(keywords);
|
||||||
|
chapterItem.setStartTranscriptId(startTranscriptId);
|
||||||
|
chapterItem.setEndTranscriptId(endTranscriptId);
|
||||||
|
chapterItem.setConfidence(item.path("confidence").isNumber() ? item.path("confidence").decimalValue() : BigDecimal.valueOf(0.88D));
|
||||||
|
result.add(chapterItem);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new RuntimeException("章节模型生成失败: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildChapterSystemPrompt() {
|
||||||
|
return """
|
||||||
|
你负责对会议转录分段做章节边界识别。
|
||||||
|
只允许返回 JSON。
|
||||||
|
只能返回 chapters 数组。
|
||||||
|
每个章节只允许包含 chapterNo,title,summary,keywords,startTranscriptId,endTranscriptId,confidence。
|
||||||
|
不得改写原文,不得输出章节正文,不得归一化数字、日期、金额、时间点。
|
||||||
|
所有章节必须完整覆盖全部 transcript,不能重叠,不能断档。
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildChapterUserPrompt(List<MeetingTranscript> transcripts) throws Exception {
|
||||||
|
List<Map<String, Object>> segments = new ArrayList<>();
|
||||||
|
for (MeetingTranscript transcript : transcripts) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("transcriptId", transcript.getId());
|
||||||
|
item.put("sortOrder", transcript.getSortOrder());
|
||||||
|
item.put("speakerName", transcript.getSpeakerName());
|
||||||
|
item.put("startTime", transcript.getStartTime());
|
||||||
|
item.put("endTime", transcript.getEndTime());
|
||||||
|
item.put("content", transcript.getContent());
|
||||||
|
segments.add(item);
|
||||||
|
}
|
||||||
|
return "请根据以下 transcript 分段识别章节边界并返回 JSON:\n" + objectMapper.writeValueAsString(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingTranscriptChapterVersion persistVersion(Meeting meeting,
|
||||||
|
AiTask sourceTask,
|
||||||
|
String sourceFingerprint,
|
||||||
|
String algorithmVersion,
|
||||||
|
String generationMode,
|
||||||
|
String generatorLabel,
|
||||||
|
List<MeetingTranscript> transcripts,
|
||||||
|
List<ChapterCandidate> candidates) {
|
||||||
|
invalidateCurrentVersion(meeting.getId());
|
||||||
|
MeetingTranscriptChapterVersion version = new MeetingTranscriptChapterVersion();
|
||||||
|
version.setTenantId(meeting.getTenantId());
|
||||||
|
version.setMeetingId(meeting.getId());
|
||||||
|
version.setSourceTaskId(sourceTask == null ? null : sourceTask.getId());
|
||||||
|
version.setVersionNo(resolveNextVersionNo(meeting.getId()));
|
||||||
|
version.setStatus(2);
|
||||||
|
version.setSourceFingerprint(sourceFingerprint);
|
||||||
|
version.setAlgorithmVersion(algorithmVersion);
|
||||||
|
version.setGenerationMode(generationMode);
|
||||||
|
version.setGeneratorLabel(generatorLabel);
|
||||||
|
version.setChapterCount(candidates.size());
|
||||||
|
version.setIsCurrent(1);
|
||||||
|
versionMapper.insert(version);
|
||||||
|
|
||||||
|
Map<Long, MeetingTranscript> transcriptById = transcripts.stream()
|
||||||
|
.collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new));
|
||||||
|
for (ChapterCandidate candidate : candidates) {
|
||||||
|
MeetingTranscript start = transcriptById.get(candidate.startTranscriptId());
|
||||||
|
MeetingTranscript end = transcriptById.get(candidate.endTranscriptId());
|
||||||
|
MeetingTranscriptChapter chapter = new MeetingTranscriptChapter();
|
||||||
|
chapter.setTenantId(meeting.getTenantId());
|
||||||
|
chapter.setVersionId(version.getId());
|
||||||
|
chapter.setChapterNo(candidate.chapterNo());
|
||||||
|
chapter.setTitle(candidate.title());
|
||||||
|
chapter.setSummary(candidate.summary());
|
||||||
|
chapter.setKeywordsJson(writeKeywords(candidate.keywords()));
|
||||||
|
chapter.setStartTranscriptId(candidate.startTranscriptId());
|
||||||
|
chapter.setEndTranscriptId(candidate.endTranscriptId());
|
||||||
|
chapter.setStartSortOrder(start == null ? null : start.getSortOrder());
|
||||||
|
chapter.setEndSortOrder(end == null ? null : end.getSortOrder());
|
||||||
|
chapter.setStartTime(resolveStartTime(start));
|
||||||
|
chapter.setEndTime(resolveEndTime(end));
|
||||||
|
chapter.setSegmentCount(countSegmentsInRange(transcripts, candidate.startTranscriptId(), candidate.endTranscriptId()));
|
||||||
|
chapter.setConfidence(candidate.confidence());
|
||||||
|
chapterMapper.insert(chapter);
|
||||||
|
}
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingSummarySource buildSummarySource(Meeting meeting,
|
||||||
|
MeetingTranscriptChapterVersion version,
|
||||||
|
List<MeetingTranscript> transcripts,
|
||||||
|
String fingerprint) {
|
||||||
|
Map<Long, MeetingTranscript> transcriptById = transcripts.stream()
|
||||||
|
.collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new));
|
||||||
|
List<MeetingTranscriptChapter> chapterEntities = loadVersionChapters(version.getId());
|
||||||
|
List<Map<String, Object>> chapters = chapterEntities.stream()
|
||||||
|
.map(chapter -> toChapterAnalysis(chapter, transcriptById))
|
||||||
|
.toList();
|
||||||
|
String chapterOutlineText = buildChapterOutlineText(chapterEntities, transcriptById);
|
||||||
|
String rawTranscriptText = buildTranscriptText(transcripts);
|
||||||
|
String text = buildCombinedSummaryInputText(chapterOutlineText, rawTranscriptText);
|
||||||
|
String chapterFilePath = writeCurrentChapterMarkdown(meeting, version, chapterEntities, transcriptById);
|
||||||
|
return MeetingSummarySource.builder()
|
||||||
|
.text(text)
|
||||||
|
.sourceType(SOURCE_TYPE_CHAPTER_VERSION)
|
||||||
|
.fallbackUsed(false)
|
||||||
|
.sourceFingerprint(fingerprint)
|
||||||
|
.chapterVersionId(version.getId())
|
||||||
|
.chapterCount(version.getChapterCount())
|
||||||
|
.algorithmVersion(version.getAlgorithmVersion())
|
||||||
|
.generationMode(version.getGenerationMode())
|
||||||
|
.rawTranscriptText(rawTranscriptText)
|
||||||
|
.chapterOutlineText(chapterOutlineText)
|
||||||
|
.chapterFilePath(chapterFilePath)
|
||||||
|
.chapters(chapters)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeCurrentChapterMarkdown(Meeting meeting,
|
||||||
|
MeetingTranscriptChapterVersion version,
|
||||||
|
List<MeetingTranscript> transcripts) {
|
||||||
|
Map<Long, MeetingTranscript> transcriptById = transcripts.stream()
|
||||||
|
.collect(Collectors.toMap(MeetingTranscript::getId, item -> item, (left, right) -> left, LinkedHashMap::new));
|
||||||
|
List<MeetingTranscriptChapter> chapters = loadVersionChapters(version.getId());
|
||||||
|
return writeCurrentChapterMarkdown(meeting, version, chapters, transcriptById);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeCurrentChapterMarkdown(Meeting meeting,
|
||||||
|
MeetingTranscriptChapterVersion version,
|
||||||
|
List<MeetingTranscriptChapter> chapters,
|
||||||
|
Map<Long, MeetingTranscript> transcriptById) {
|
||||||
|
try {
|
||||||
|
String relativePath = CHAPTER_RELATIVE_PATH_TEMPLATE.formatted(meeting.getId());
|
||||||
|
String basePath = uploadPath.endsWith("/") || uploadPath.endsWith("\\") ? uploadPath : uploadPath + "/";
|
||||||
|
Path targetPath = Paths.get(basePath, relativePath.replace("\\", "/"));
|
||||||
|
Path parent = targetPath.getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
Files.writeString(targetPath, buildChapterMarkdown(meeting, version, chapters, transcriptById), StandardCharsets.UTF_8);
|
||||||
|
return relativePath;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new RuntimeException("保存会议章节文件失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildChapterMarkdown(Meeting meeting,
|
||||||
|
MeetingTranscriptChapterVersion version,
|
||||||
|
List<MeetingTranscriptChapter> chapters,
|
||||||
|
Map<Long, MeetingTranscript> transcriptById) {
|
||||||
|
String nowText = LocalDateTime.now().toString();
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
builder.append("---\n");
|
||||||
|
builder.append("updatedAt: ").append(nowText).append("\n");
|
||||||
|
builder.append("meetingId: ").append(meeting.getId()).append("\n");
|
||||||
|
builder.append("chapterVersionId: ").append(version.getId()).append("\n");
|
||||||
|
if (version.getVersionNo() != null) {
|
||||||
|
builder.append("versionNo: ").append(version.getVersionNo()).append("\n");
|
||||||
|
}
|
||||||
|
builder.append("sourceFingerprint: ").append(nonBlank(version.getSourceFingerprint(), "")).append("\n");
|
||||||
|
builder.append("generationMode: ").append(nonBlank(version.getGenerationMode(), "")).append("\n");
|
||||||
|
builder.append("algorithmVersion: ").append(nonBlank(version.getAlgorithmVersion(), "")).append("\n");
|
||||||
|
builder.append("---\n\n");
|
||||||
|
|
||||||
|
builder.append("# ").append(nonBlank(meeting.getTitle(), "会议")).append(" 章节目录\n\n");
|
||||||
|
if (version.getVersionNo() != null) {
|
||||||
|
builder.append("- 章节版本:V").append(version.getVersionNo()).append("\n");
|
||||||
|
}
|
||||||
|
if (nonBlank(version.getGenerationMode()) != null) {
|
||||||
|
builder.append("- 生成方式:").append(version.getGenerationMode()).append("\n");
|
||||||
|
}
|
||||||
|
if (nonBlank(version.getAlgorithmVersion()) != null) {
|
||||||
|
builder.append("- 算法版本:").append(version.getAlgorithmVersion()).append("\n");
|
||||||
|
}
|
||||||
|
builder.append("- 更新时间:").append(nowText).append("\n\n");
|
||||||
|
builder.append("## 章节内容\n\n");
|
||||||
|
|
||||||
|
if (chapters == null || chapters.isEmpty()) {
|
||||||
|
builder.append("_当前暂无章节内容_\n");
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (MeetingTranscriptChapter chapter : chapters) {
|
||||||
|
String title = nonBlank(chapter.getTitle(), "第" + chapter.getChapterNo() + "章");
|
||||||
|
builder.append("### 第").append(chapter.getChapterNo()).append("章 ").append(title).append("\n");
|
||||||
|
builder.append("- 时间范围:").append(formatTimeRange(chapter.getStartTime(), chapter.getEndTime())).append("\n");
|
||||||
|
|
||||||
|
List<String> keywords = readKeywords(chapter.getKeywordsJson());
|
||||||
|
if (!keywords.isEmpty()) {
|
||||||
|
builder.append("- 关键词:").append(String.join("、", keywords)).append("\n");
|
||||||
|
}
|
||||||
|
if (chapter.getSummary() != null && !chapter.getSummary().isBlank()) {
|
||||||
|
builder.append("- 章节摘要:").append(chapter.getSummary().trim()).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> fidelityPoints = extractFidelityPoints(resolveTranscriptsInRange(chapter, transcriptById));
|
||||||
|
if (!fidelityPoints.isEmpty()) {
|
||||||
|
builder.append("- 保真锚点:").append(String.join("、", fidelityPoints)).append("\n");
|
||||||
|
}
|
||||||
|
builder.append("\n");
|
||||||
|
}
|
||||||
|
return builder.toString().trim() + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildCombinedSummaryInputText(String chapterOutlineText, String rawTranscriptText) {
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
if (chapterOutlineText != null && !chapterOutlineText.isBlank()) {
|
||||||
|
builder.append("【章节辅助结构】\n").append(chapterOutlineText.trim());
|
||||||
|
}
|
||||||
|
if (rawTranscriptText != null && !rawTranscriptText.isBlank()) {
|
||||||
|
if (builder.length() > 0) {
|
||||||
|
builder.append("\n\n");
|
||||||
|
}
|
||||||
|
builder.append("【原始转录】\n").append(rawTranscriptText.trim());
|
||||||
|
}
|
||||||
|
return builder.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildChapterOutlineText(List<MeetingTranscriptChapter> chapters, Map<Long, MeetingTranscript> transcriptById) {
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
for (MeetingTranscriptChapter chapter : chapters) {
|
||||||
|
if (builder.length() > 0) {
|
||||||
|
builder.append("\n\n");
|
||||||
|
}
|
||||||
|
String title = nonBlank(chapter.getTitle(), "第" + chapter.getChapterNo() + "章");
|
||||||
|
builder.append("### 第").append(chapter.getChapterNo()).append("章 ").append(title).append("\n");
|
||||||
|
builder.append("时间范围:").append(formatTimeRange(chapter.getStartTime(), chapter.getEndTime())).append("\n");
|
||||||
|
if (chapter.getSummary() != null && !chapter.getSummary().isBlank()) {
|
||||||
|
builder.append("章节摘要:").append(chapter.getSummary().trim()).append("\n");
|
||||||
|
}
|
||||||
|
List<String> fidelityPoints = extractFidelityPoints(resolveTranscriptsInRange(chapter, transcriptById));
|
||||||
|
if (!fidelityPoints.isEmpty()) {
|
||||||
|
builder.append("章节导航保真锚点:").append(String.join("、", fidelityPoints)).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toChapterAnalysis(MeetingTranscriptChapter chapter, Map<Long, MeetingTranscript> transcriptById) {
|
||||||
|
List<MeetingTranscript> range = resolveTranscriptsInRange(chapter, transcriptById);
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("chapterNo", chapter.getChapterNo());
|
||||||
|
item.put("title", nonBlank(chapter.getTitle(), "第" + chapter.getChapterNo() + "章"));
|
||||||
|
item.put("summary", nonBlank(chapter.getSummary(), ""));
|
||||||
|
item.put("time", formatTimeRange(chapter.getStartTime(), chapter.getEndTime()));
|
||||||
|
item.put("startTime", chapter.getStartTime());
|
||||||
|
item.put("endTime", chapter.getEndTime());
|
||||||
|
item.put("startTranscriptId", chapter.getStartTranscriptId());
|
||||||
|
item.put("endTranscriptId", chapter.getEndTranscriptId());
|
||||||
|
item.put("startSortOrder", chapter.getStartSortOrder());
|
||||||
|
item.put("endSortOrder", chapter.getEndSortOrder());
|
||||||
|
item.put("confidence", chapter.getConfidence());
|
||||||
|
item.put("keywords", readKeywords(chapter.getKeywordsJson()));
|
||||||
|
item.put("fidelityPoints", extractFidelityPoints(range));
|
||||||
|
item.put("sourceTranscriptIds", range.stream().map(MeetingTranscript::getId).toList());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MeetingTranscript> loadRawTranscripts(Long meetingId) {
|
||||||
|
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
||||||
|
.eq(MeetingTranscript::getMeetingId, meetingId)
|
||||||
|
.orderByAsc(MeetingTranscript::getSortOrder)
|
||||||
|
.orderByAsc(MeetingTranscript::getStartTime)
|
||||||
|
.orderByAsc(MeetingTranscript::getId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingTranscriptChapterVersion findReusableCurrentVersion(Long meetingId, String fingerprint) {
|
||||||
|
return versionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>()
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getIsCurrent, 1)
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getStatus, 2)
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getSourceFingerprint, fingerprint)
|
||||||
|
.orderByDesc(MeetingTranscriptChapterVersion::getVersionNo)
|
||||||
|
.last("limit 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingTranscriptChapterVersion findCurrentVersion(Long meetingId) {
|
||||||
|
return versionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>()
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getIsCurrent, 1)
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getStatus, 2)
|
||||||
|
.orderByDesc(MeetingTranscriptChapterVersion::getVersionNo)
|
||||||
|
.last("limit 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MeetingTranscriptChapter> loadVersionChapters(Long versionId) {
|
||||||
|
return chapterMapper.selectList(new LambdaQueryWrapper<MeetingTranscriptChapter>()
|
||||||
|
.eq(MeetingTranscriptChapter::getVersionId, versionId)
|
||||||
|
.orderByAsc(MeetingTranscriptChapter::getChapterNo)
|
||||||
|
.orderByAsc(MeetingTranscriptChapter::getId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int resolveNextVersionNo(Long meetingId) {
|
||||||
|
MeetingTranscriptChapterVersion latest = versionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>()
|
||||||
|
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
|
||||||
|
.orderByDesc(MeetingTranscriptChapterVersion::getVersionNo)
|
||||||
|
.last("limit 1"));
|
||||||
|
return latest == null || latest.getVersionNo() == null ? 1 : latest.getVersionNo() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCandidatesAgainstTranscripts(List<ChapterCandidate> candidates, List<MeetingTranscript> transcripts) {
|
||||||
|
if (candidates == null || candidates.isEmpty()) {
|
||||||
|
throw new RuntimeException("章节结果不能为空");
|
||||||
|
}
|
||||||
|
Map<Long, Integer> indexByTranscriptId = new LinkedHashMap<>();
|
||||||
|
for (int index = 0; index < transcripts.size(); index++) {
|
||||||
|
indexByTranscriptId.put(transcripts.get(index).getId(), index);
|
||||||
|
}
|
||||||
|
int expectedStartIndex = 0;
|
||||||
|
for (ChapterCandidate candidate : candidates.stream().sorted(Comparator.comparing(ChapterCandidate::chapterNo)).toList()) {
|
||||||
|
Integer startIndex = indexByTranscriptId.get(candidate.startTranscriptId());
|
||||||
|
Integer endIndex = indexByTranscriptId.get(candidate.endTranscriptId());
|
||||||
|
if (startIndex == null || endIndex == null) {
|
||||||
|
throw new RuntimeException("章节边界引用了不存在的 transcript");
|
||||||
|
}
|
||||||
|
if (startIndex > endIndex) {
|
||||||
|
throw new RuntimeException("章节边界顺序非法");
|
||||||
|
}
|
||||||
|
if (startIndex != expectedStartIndex) {
|
||||||
|
throw new RuntimeException("章节未完整覆盖全部转录,存在断档或重叠");
|
||||||
|
}
|
||||||
|
expectedStartIndex = endIndex + 1;
|
||||||
|
}
|
||||||
|
if (expectedStartIndex != transcripts.size()) {
|
||||||
|
throw new RuntimeException("章节未完整覆盖全部转录");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MeetingTranscript> resolveTranscriptsInRange(MeetingTranscriptChapter chapter, Map<Long, MeetingTranscript> transcriptById) {
|
||||||
|
List<MeetingTranscript> ordered = new ArrayList<>(transcriptById.values());
|
||||||
|
int startIndex = -1;
|
||||||
|
int endIndex = -1;
|
||||||
|
for (int index = 0; index < ordered.size(); index++) {
|
||||||
|
Long transcriptId = ordered.get(index).getId();
|
||||||
|
if (Objects.equals(transcriptId, chapter.getStartTranscriptId())) {
|
||||||
|
startIndex = index;
|
||||||
|
}
|
||||||
|
if (Objects.equals(transcriptId, chapter.getEndTranscriptId())) {
|
||||||
|
endIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (startIndex < 0 || endIndex < startIndex) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return ordered.subList(startIndex, endIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int countSegmentsInRange(List<MeetingTranscript> transcripts, Long startTranscriptId, Long endTranscriptId) {
|
||||||
|
boolean started = false;
|
||||||
|
int count = 0;
|
||||||
|
for (MeetingTranscript transcript : transcripts) {
|
||||||
|
if (Objects.equals(transcript.getId(), startTranscriptId)) {
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
if (started) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (Objects.equals(transcript.getId(), endTranscriptId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSourceFingerprint(List<MeetingTranscript> transcripts) {
|
||||||
|
String raw = transcripts.stream()
|
||||||
|
.map(transcript -> String.join("|",
|
||||||
|
String.valueOf(transcript.getId()),
|
||||||
|
String.valueOf(transcript.getSortOrder()),
|
||||||
|
nonBlank(transcript.getSpeakerId(), ""),
|
||||||
|
nonBlank(transcript.getSpeakerName(), ""),
|
||||||
|
nonBlank(transcript.getContent(), ""),
|
||||||
|
String.valueOf(transcript.getStartTime()),
|
||||||
|
String.valueOf(transcript.getEndTime())))
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
for (byte item : bytes) {
|
||||||
|
builder.append(String.format("%02x", item));
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new RuntimeException("计算转录指纹失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildTranscriptText(List<MeetingTranscript> transcripts) {
|
||||||
|
return transcripts.stream()
|
||||||
|
.map(this::formatTranscriptLine)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.filter(text -> !text.isBlank())
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatTranscriptLine(MeetingTranscript transcript) {
|
||||||
|
if (transcript == null || transcript.getContent() == null || transcript.getContent().isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String speaker = nonBlank(transcript.getSpeakerName(), transcript.getSpeakerId());
|
||||||
|
if (speaker == null) {
|
||||||
|
return transcript.getContent().trim();
|
||||||
|
}
|
||||||
|
return speaker + ": " + transcript.getContent().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingTranscriptVO toTranscriptVO(MeetingTranscript transcript) {
|
||||||
|
MeetingTranscriptVO vo = new MeetingTranscriptVO();
|
||||||
|
vo.setId(transcript.getId());
|
||||||
|
vo.setSpeakerId(transcript.getSpeakerId());
|
||||||
|
vo.setSpeakerName(transcript.getSpeakerName());
|
||||||
|
vo.setSpeakerLabel(transcript.getSpeakerLabel());
|
||||||
|
vo.setContent(transcript.getContent());
|
||||||
|
vo.setStartTime(transcript.getStartTime());
|
||||||
|
vo.setEndTime(transcript.getEndTime());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> extractFidelityPoints(List<MeetingTranscript> transcripts) {
|
||||||
|
Set<String> result = new LinkedHashSet<>();
|
||||||
|
for (MeetingTranscript transcript : transcripts) {
|
||||||
|
String content = transcript.getContent();
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Matcher matcher = FIDELITY_POINT_PATTERN.matcher(content);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String point = normalizeOptionalText(matcher.group(1));
|
||||||
|
if (point != null) {
|
||||||
|
result.add(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeKeywords(List<String> keywords) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(normalizeKeywords(keywords));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> readKeywords(String keywordsJson) {
|
||||||
|
if (keywordsJson == null || keywordsJson.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalizeKeywords(objectMapper.readValue(keywordsJson, new TypeReference<List<String>>() {}));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> normalizeKeywords(List<String> keywords) {
|
||||||
|
return keywords == null ? List.of() : keywords.stream()
|
||||||
|
.map(this::normalizeOptionalText)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitizeResponseContent(String content) {
|
||||||
|
if (content == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String normalized = content.trim();
|
||||||
|
if (!normalized.startsWith("```")) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
int firstBreak = normalized.indexOf('\n');
|
||||||
|
int lastFence = normalized.lastIndexOf("\n```");
|
||||||
|
if (firstBreak < 0 || lastFence <= firstBreak) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalized.substring(firstBreak + 1, lastFence).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String appendPath(String baseUrl, String path) {
|
||||||
|
String normalizedBaseUrl = baseUrl == null ? "" : baseUrl.trim();
|
||||||
|
String normalizedPath = path == null ? "" : path.trim();
|
||||||
|
while (normalizedPath.startsWith("/")) {
|
||||||
|
normalizedPath = normalizedPath.substring(1);
|
||||||
|
}
|
||||||
|
if (normalizedPath.startsWith("http://") || normalizedPath.startsWith("https://")) {
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
if (normalizedBaseUrl.endsWith("/")) {
|
||||||
|
return normalizedBaseUrl + normalizedPath;
|
||||||
|
}
|
||||||
|
return normalizedBaseUrl + "/" + normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatTimeRange(Integer startTime, Integer endTime) {
|
||||||
|
return formatTime(startTime) + "-" + formatTime(endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatTime(Integer millis) {
|
||||||
|
long safeMillis = millis == null || millis < 0 ? 0L : millis;
|
||||||
|
long totalSeconds = safeMillis / 1000L;
|
||||||
|
long hours = totalSeconds / 3600L;
|
||||||
|
long minutes = (totalSeconds % 3600L) / 60L;
|
||||||
|
long seconds = totalSeconds % 60L;
|
||||||
|
if (hours > 0) {
|
||||||
|
return String.format(Locale.ROOT, "%02d:%02d:%02d", hours, minutes, seconds);
|
||||||
|
}
|
||||||
|
return String.format(Locale.ROOT, "%02d:%02d", minutes, seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer resolveStartTime(MeetingTranscript transcript) {
|
||||||
|
if (transcript == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return transcript.getStartTime() != null ? transcript.getStartTime() : transcript.getEndTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer resolveEndTime(MeetingTranscript transcript) {
|
||||||
|
if (transcript == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return transcript.getEndTime() != null ? transcript.getEndTime() : transcript.getStartTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nonBlank(String... values) {
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String value : values) {
|
||||||
|
String normalized = normalizeOptionalText(value);
|
||||||
|
if (normalized != null) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeOptionalText(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = value.trim();
|
||||||
|
return normalized.isEmpty() ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long longValue(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(String.valueOf(value).trim());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fingerprintsafe(String fingerprint) {
|
||||||
|
return fingerprint == null ? "" : fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ChapterCandidate(Integer chapterNo,
|
||||||
|
String title,
|
||||||
|
String summary,
|
||||||
|
List<String> keywords,
|
||||||
|
Long startTranscriptId,
|
||||||
|
Long endTranscriptId,
|
||||||
|
BigDecimal confidence) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,8 @@ unisbase:
|
||||||
- biz_llm_models
|
- biz_llm_models
|
||||||
- biz_asr_models
|
- biz_asr_models
|
||||||
- biz_prompt_templates
|
- biz_prompt_templates
|
||||||
|
- biz_meeting_transcript_chapter_versions
|
||||||
|
- biz_meeting_transcript_chapters
|
||||||
- biz_client_downloads
|
- biz_client_downloads
|
||||||
- biz_external_apps
|
- biz_external_apps
|
||||||
security:
|
security:
|
||||||
|
|
@ -83,6 +85,8 @@ unisbase:
|
||||||
refresh-default-days: 7
|
refresh-default-days: 7
|
||||||
|
|
||||||
imeeting:
|
imeeting:
|
||||||
|
summary-orchestration:
|
||||||
|
mode: INTERNAL_BUILTIN
|
||||||
realtime:
|
realtime:
|
||||||
resume-window-minutes: 30
|
resume-window-minutes: 30
|
||||||
empty-session-retention-minutes: 720
|
empty-session-retention-minutes: 720
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.HotWordService;
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
|
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
|
||||||
import com.imeeting.support.TaskSecurityContextRunner;
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
|
|
@ -97,7 +98,8 @@ class AiTaskServiceImplTest {
|
||||||
redisTemplate,
|
redisTemplate,
|
||||||
new TaskSecurityContextRunner(),
|
new TaskSecurityContextRunner(),
|
||||||
mock(MeetingTranscriptFileService.class),
|
mock(MeetingTranscriptFileService.class),
|
||||||
mock(MeetingTranscriptRevisionService.class)
|
mock(MeetingTranscriptRevisionService.class),
|
||||||
|
mock(MeetingTranscriptChapterService.class)
|
||||||
));
|
));
|
||||||
doReturn(true).when(service).updateById(any());
|
doReturn(true).when(service).updateById(any());
|
||||||
|
|
||||||
|
|
@ -116,7 +118,7 @@ class AiTaskServiceImplTest {
|
||||||
summaryTask.setMeetingId(66L);
|
summaryTask.setMeetingId(66L);
|
||||||
summaryTask.setTaskType("SUMMARY");
|
summaryTask.setTaskType("SUMMARY");
|
||||||
summaryTask.setStatus(0);
|
summaryTask.setStatus(0);
|
||||||
doReturn(null, summaryTask).when(service).getOne(any());
|
doReturn(null, null, summaryTask).when(service).getOne(any());
|
||||||
|
|
||||||
service.dispatchTasks(66L, 1L, 2L);
|
service.dispatchTasks(66L, 1L, 2L);
|
||||||
|
|
||||||
|
|
@ -131,18 +133,16 @@ class AiTaskServiceImplTest {
|
||||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||||
AiModelService aiModelService = mock(AiModelService.class);
|
AiModelService aiModelService = mock(AiModelService.class);
|
||||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||||
MeetingTranscriptRevisionService revisionService = mock(MeetingTranscriptRevisionService.class);
|
MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.class);
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||||
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
|
when(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(" ")
|
.text(" ")
|
||||||
.sourceType("RAW_FALLBACK")
|
.sourceType("RAW_FALLBACK")
|
||||||
.fallbackUsed(true)
|
.fallbackUsed(true)
|
||||||
.triggerTaskType("SUMMARY")
|
.algorithmVersion("cohesion-v1")
|
||||||
.semanticCorrector("NONE_V1")
|
|
||||||
.ruleProfileVersion("v1")
|
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
AiTaskServiceImpl service = spy(createService(
|
AiTaskServiceImpl service = spy(createService(
|
||||||
|
|
@ -152,7 +152,8 @@ class AiTaskServiceImplTest {
|
||||||
redisTemplate,
|
redisTemplate,
|
||||||
new TaskSecurityContextRunner(),
|
new TaskSecurityContextRunner(),
|
||||||
mock(MeetingTranscriptFileService.class),
|
mock(MeetingTranscriptFileService.class),
|
||||||
revisionService
|
mock(MeetingTranscriptRevisionService.class),
|
||||||
|
chapterService
|
||||||
));
|
));
|
||||||
doReturn(true).when(service).updateById(any());
|
doReturn(true).when(service).updateById(any());
|
||||||
|
|
||||||
|
|
@ -160,12 +161,17 @@ class AiTaskServiceImplTest {
|
||||||
meeting.setId(77L);
|
meeting.setId(77L);
|
||||||
when(meetingMapper.selectById(77L)).thenReturn(meeting);
|
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();
|
AiTask summaryTask = new AiTask();
|
||||||
summaryTask.setId(100L);
|
summaryTask.setId(100L);
|
||||||
summaryTask.setMeetingId(77L);
|
summaryTask.setMeetingId(77L);
|
||||||
summaryTask.setTaskType("SUMMARY");
|
summaryTask.setTaskType("SUMMARY");
|
||||||
summaryTask.setStatus(0);
|
summaryTask.setStatus(0);
|
||||||
doReturn(summaryTask, null).when(service).getOne(any());
|
doReturn(chapterTask, summaryTask).when(service).getOne(any());
|
||||||
|
|
||||||
service.dispatchSummaryTask(77L, 1L, 2L);
|
service.dispatchSummaryTask(77L, 1L, 2L);
|
||||||
|
|
||||||
|
|
@ -174,6 +180,47 @@ class AiTaskServiceImplTest {
|
||||||
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dispatchSummaryTaskShouldWaitForExternalOrchestrationWhenExternalModeEnabled() {
|
||||||
|
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||||
|
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||||
|
AiModelService aiModelService = mock(AiModelService.class);
|
||||||
|
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||||
|
MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||||
|
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
|
||||||
|
|
||||||
|
AiTaskServiceImpl service = spy(createService(
|
||||||
|
meetingMapper,
|
||||||
|
transcriptMapper,
|
||||||
|
aiModelService,
|
||||||
|
redisTemplate,
|
||||||
|
new TaskSecurityContextRunner(),
|
||||||
|
mock(MeetingTranscriptFileService.class),
|
||||||
|
mock(MeetingTranscriptRevisionService.class),
|
||||||
|
chapterService
|
||||||
|
));
|
||||||
|
ReflectionTestUtils.setField(service, "summaryOrchestrationMode", "EXTERNAL_N8N");
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(78L);
|
||||||
|
when(meetingMapper.selectById(78L)).thenReturn(meeting);
|
||||||
|
|
||||||
|
AiTask summaryTask = new AiTask();
|
||||||
|
summaryTask.setId(101L);
|
||||||
|
summaryTask.setMeetingId(78L);
|
||||||
|
summaryTask.setTaskType("SUMMARY");
|
||||||
|
summaryTask.setStatus(0);
|
||||||
|
doReturn(summaryTask, null).when(service).getOne(any());
|
||||||
|
|
||||||
|
service.dispatchSummaryTask(78L, 1L, 2L);
|
||||||
|
|
||||||
|
verify(chapterService, never()).resolveSummarySource(any(), any());
|
||||||
|
verify(aiModelService, never()).getModelById(anyLong(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void saveTranscriptsShouldInitializeTranscriptFileAfterFirstPersist() throws Exception {
|
void saveTranscriptsShouldInitializeTranscriptFileAfterFirstPersist() throws Exception {
|
||||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||||
|
|
@ -186,7 +233,8 @@ class AiTaskServiceImplTest {
|
||||||
mock(StringRedisTemplate.class),
|
mock(StringRedisTemplate.class),
|
||||||
mock(TaskSecurityContextRunner.class),
|
mock(TaskSecurityContextRunner.class),
|
||||||
transcriptFileService,
|
transcriptFileService,
|
||||||
mock(MeetingTranscriptRevisionService.class)
|
mock(MeetingTranscriptRevisionService.class),
|
||||||
|
mock(MeetingTranscriptChapterService.class)
|
||||||
);
|
);
|
||||||
|
|
||||||
Meeting meeting = new Meeting();
|
Meeting meeting = new Meeting();
|
||||||
|
|
@ -222,7 +270,8 @@ class AiTaskServiceImplTest {
|
||||||
mock(StringRedisTemplate.class),
|
mock(StringRedisTemplate.class),
|
||||||
mock(TaskSecurityContextRunner.class),
|
mock(TaskSecurityContextRunner.class),
|
||||||
mock(MeetingTranscriptFileService.class),
|
mock(MeetingTranscriptFileService.class),
|
||||||
mock(MeetingTranscriptRevisionService.class)
|
mock(MeetingTranscriptRevisionService.class),
|
||||||
|
mock(MeetingTranscriptChapterService.class)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,7 +281,8 @@ class AiTaskServiceImplTest {
|
||||||
StringRedisTemplate redisTemplate,
|
StringRedisTemplate redisTemplate,
|
||||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||||
MeetingTranscriptRevisionService revisionService) {
|
MeetingTranscriptRevisionService revisionService,
|
||||||
|
MeetingTranscriptChapterService chapterService) {
|
||||||
return new AiTaskServiceImpl(
|
return new AiTaskServiceImpl(
|
||||||
meetingMapper,
|
meetingMapper,
|
||||||
transcriptMapper,
|
transcriptMapper,
|
||||||
|
|
@ -244,6 +294,7 @@ class AiTaskServiceImplTest {
|
||||||
mock(MeetingSummaryFileService.class),
|
mock(MeetingSummaryFileService.class),
|
||||||
meetingTranscriptFileService,
|
meetingTranscriptFileService,
|
||||||
revisionService,
|
revisionService,
|
||||||
|
chapterService,
|
||||||
mock(MeetingSummaryPromptAssembler.class),
|
mock(MeetingSummaryPromptAssembler.class),
|
||||||
taskSecurityContextRunner
|
taskSecurityContextRunner
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.imeeting.service.biz.impl;
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
import com.imeeting.common.SysParamKeys;
|
import com.imeeting.common.SysParamKeys;
|
||||||
|
import com.imeeting.dto.biz.MeetingSummarySource;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.PromptTemplate;
|
import com.imeeting.entity.biz.PromptTemplate;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
|
|
@ -35,6 +36,7 @@ class MeetingSummaryPromptAssemblerTest {
|
||||||
Map<String, Object> taskConfig = assembler.buildTaskConfig(2L, 3L, " 关注风险项 ");
|
Map<String, Object> taskConfig = assembler.buildTaskConfig(2L, 3L, " 关注风险项 ");
|
||||||
|
|
||||||
assertEquals(2L, taskConfig.get("summaryModelId"));
|
assertEquals(2L, taskConfig.get("summaryModelId"));
|
||||||
|
assertEquals(2L, taskConfig.get("chapterModelId"));
|
||||||
assertEquals(3L, taskConfig.get("promptId"));
|
assertEquals(3L, taskConfig.get("promptId"));
|
||||||
assertEquals("v2", taskConfig.get("promptSchemaVersion"));
|
assertEquals("v2", taskConfig.get("promptSchemaVersion"));
|
||||||
assertEquals("系统提示词", taskConfig.get("effectiveSystemPrompt"));
|
assertEquals("系统提示词", taskConfig.get("effectiveSystemPrompt"));
|
||||||
|
|
@ -69,10 +71,35 @@ class MeetingSummaryPromptAssemblerTest {
|
||||||
meeting.setMeetingTime(LocalDateTime.of(2026, 4, 16, 10, 0));
|
meeting.setMeetingTime(LocalDateTime.of(2026, 4, 16, 10, 0));
|
||||||
meeting.setParticipants("张三,李四");
|
meeting.setParticipants("张三,李四");
|
||||||
|
|
||||||
String userMessage = assembler.buildUserMessage(meeting, "这里是转写文本", " ");
|
String userMessage = assembler.buildUserMessage(meeting, "这里是转录文本", " ");
|
||||||
|
|
||||||
assertFalse(userMessage.contains("用户提示词"));
|
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
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,18 @@ export interface MeetingPreviewAccessVO {
|
||||||
passwordRequired: boolean;
|
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 {
|
export interface PublicMeetingPreviewVO {
|
||||||
meeting: MeetingVO;
|
meeting: MeetingVO;
|
||||||
transcripts: MeetingTranscriptVO[];
|
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) => {
|
export const getMeetingPreviewAccess = (id: number) => {
|
||||||
return http.get<{ code: string; data: MeetingPreviewAccessVO; msg: string }>(
|
return http.get<{ code: string; data: MeetingPreviewAccessVO; msg: string }>(
|
||||||
`/api/public/meetings/${id}/preview/access`
|
`/api/public/meetings/${id}/preview/access`
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,10 @@ import {
|
||||||
downloadMeetingTranscript,
|
downloadMeetingTranscript,
|
||||||
downloadMeetingSummary,
|
downloadMeetingSummary,
|
||||||
getMeetingDetail,
|
getMeetingDetail,
|
||||||
|
getMeetingChapters,
|
||||||
getMeetingProgress,
|
getMeetingProgress,
|
||||||
getTranscripts,
|
getTranscripts,
|
||||||
|
MeetingChapterVO,
|
||||||
MeetingProgress,
|
MeetingProgress,
|
||||||
MeetingTranscriptVO,
|
MeetingTranscriptVO,
|
||||||
MeetingVO,
|
MeetingVO,
|
||||||
|
|
@ -82,6 +84,17 @@ type MeetingAnalysis = {
|
||||||
todos: string[];
|
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 = {
|
const ANALYSIS_EMPTY: MeetingAnalysis = {
|
||||||
overview: '',
|
overview: '',
|
||||||
keywords: [],
|
keywords: [],
|
||||||
|
|
@ -318,6 +331,25 @@ function formatPlayerTime(seconds: number) {
|
||||||
return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
|
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 文本中的关键词添加虚拟超链接
|
* 给 Markdown 文本中的关键词添加虚拟超链接
|
||||||
*/
|
*/
|
||||||
|
|
@ -602,12 +634,14 @@ type ActiveTranscriptRowProps = {
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
speakerLabelMap: Map<string, string>;
|
speakerLabelMap: Map<string, string>;
|
||||||
onSeek: (timeMs: number) => void;
|
onPlay: (timeMs: number) => void;
|
||||||
onStartEdit: (item: MeetingTranscriptVO, event: React.MouseEvent) => void;
|
onStartEdit: (item: MeetingTranscriptVO, event: React.MouseEvent) => void;
|
||||||
onDraftBlur: (item: MeetingTranscriptVO, value: string) => void;
|
onDraftBlur: (item: MeetingTranscriptVO, value: string) => void;
|
||||||
onDraftKeyDown: (item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
onDraftKeyDown: (item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onSpeakerUpdated: () => void;
|
onSpeakerUpdated: () => void;
|
||||||
|
registerRow: (id: number, node: HTMLDivElement | null) => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isLinkedHighlight: boolean;
|
||||||
audioPlaying: boolean;
|
audioPlaying: boolean;
|
||||||
highlightKeyword?: string;
|
highlightKeyword?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -619,12 +653,14 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||||
isEditing,
|
isEditing,
|
||||||
isSaving,
|
isSaving,
|
||||||
speakerLabelMap,
|
speakerLabelMap,
|
||||||
onSeek,
|
onPlay,
|
||||||
onStartEdit,
|
onStartEdit,
|
||||||
onDraftBlur,
|
onDraftBlur,
|
||||||
onDraftKeyDown,
|
onDraftKeyDown,
|
||||||
onSpeakerUpdated,
|
onSpeakerUpdated,
|
||||||
|
registerRow,
|
||||||
isActive,
|
isActive,
|
||||||
|
isLinkedHighlight,
|
||||||
audioPlaying,
|
audioPlaying,
|
||||||
highlightKeyword = '',
|
highlightKeyword = '',
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -648,8 +684,17 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||||
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
|
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item className={`transcript-row ${isActive ? 'active' : ''}`} onClick={() => onSeek(item.startTime)}>
|
<List.Item
|
||||||
<div className="transcript-entry" ref={rowRef}>
|
className={`transcript-row ${isActive ? 'active' : ''} ${isLinkedHighlight ? 'linked' : ''}`}
|
||||||
|
onClick={() => onPlay(item.startTime)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="transcript-entry"
|
||||||
|
ref={(node) => {
|
||||||
|
rowRef.current = node;
|
||||||
|
registerRow(item.id, node);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
||||||
<div className="transcript-content-wrap">
|
<div className="transcript-content-wrap">
|
||||||
<div className="transcript-meta">
|
<div className="transcript-meta">
|
||||||
|
|
@ -716,12 +761,14 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||||
&& prevProps.isEditing === nextProps.isEditing
|
&& prevProps.isEditing === nextProps.isEditing
|
||||||
&& prevProps.isSaving === nextProps.isSaving
|
&& prevProps.isSaving === nextProps.isSaving
|
||||||
&& prevProps.speakerLabelMap === nextProps.speakerLabelMap
|
&& prevProps.speakerLabelMap === nextProps.speakerLabelMap
|
||||||
&& prevProps.onSeek === nextProps.onSeek
|
&& prevProps.onPlay === nextProps.onPlay
|
||||||
&& prevProps.onStartEdit === nextProps.onStartEdit
|
&& prevProps.onStartEdit === nextProps.onStartEdit
|
||||||
&& prevProps.onDraftBlur === nextProps.onDraftBlur
|
&& prevProps.onDraftBlur === nextProps.onDraftBlur
|
||||||
&& prevProps.onDraftKeyDown === nextProps.onDraftKeyDown
|
&& prevProps.onDraftKeyDown === nextProps.onDraftKeyDown
|
||||||
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
|
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
|
||||||
|
&& prevProps.registerRow === nextProps.registerRow
|
||||||
&& prevProps.isActive === nextProps.isActive
|
&& prevProps.isActive === nextProps.isActive
|
||||||
|
&& prevProps.isLinkedHighlight === nextProps.isLinkedHighlight
|
||||||
&& prevProps.audioPlaying === nextProps.audioPlaying
|
&& prevProps.audioPlaying === nextProps.audioPlaying
|
||||||
&& prevProps.highlightKeyword === nextProps.highlightKeyword
|
&& prevProps.highlightKeyword === nextProps.highlightKeyword
|
||||||
));
|
));
|
||||||
|
|
@ -736,6 +783,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
|
|
||||||
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
||||||
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
||||||
|
const [meetingChapters, setMeetingChapters] = useState<MeetingChapterVO[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editVisible, setEditVisible] = useState(false);
|
const [editVisible, setEditVisible] = useState(false);
|
||||||
const [summaryVisible, setSummaryVisible] = useState(false);
|
const [summaryVisible, setSummaryVisible] = useState(false);
|
||||||
|
|
@ -747,6 +795,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
const [expandKeywords, setExpandKeywords] = useState(false);
|
const [expandKeywords, setExpandKeywords] = useState(false);
|
||||||
const [expandSummary, setExpandSummary] = useState(false);
|
const [expandSummary, setExpandSummary] = useState(false);
|
||||||
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
|
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
|
||||||
|
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('catalog');
|
||||||
const [addingHotwords, setAddingHotwords] = useState(false);
|
const [addingHotwords, setAddingHotwords] = useState(false);
|
||||||
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
|
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
|
||||||
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
|
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
|
||||||
|
|
@ -759,7 +808,8 @@ const MeetingDetail: React.FC = () => {
|
||||||
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
|
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
|
||||||
const [sharePasswordDraft, setSharePasswordDraft] = useState('');
|
const [sharePasswordDraft, setSharePasswordDraft] = useState('');
|
||||||
const [highlightKeyword, setHighlightKeyword] = useState('');
|
const [highlightKeyword, setHighlightKeyword] = useState('');
|
||||||
const [highlightTimestamp, setHighlightTimestamp] = useState<number | null>(null);
|
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
|
||||||
|
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
||||||
|
|
@ -772,6 +822,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
|
|
||||||
const summaryPdfRef = useRef<HTMLDivElement>(null);
|
const summaryPdfRef = useRef<HTMLDivElement>(null);
|
||||||
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
|
const pendingTranscriptScrollIdRef = useRef<number | null>(null);
|
||||||
const leftColumnRef = useRef<HTMLDivElement>(null);
|
const leftColumnRef = useRef<HTMLDivElement>(null);
|
||||||
const transcriptSectionRef = useRef<HTMLDivElement>(null);
|
const transcriptSectionRef = useRef<HTMLDivElement>(null);
|
||||||
const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false);
|
const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false);
|
||||||
|
|
@ -779,9 +830,14 @@ const MeetingDetail: React.FC = () => {
|
||||||
|
|
||||||
const fetchData = useCallback(async (meetingId: number) => {
|
const fetchData = useCallback(async (meetingId: number) => {
|
||||||
try {
|
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);
|
setMeeting(detailRes.data.data);
|
||||||
setTranscripts(transcriptRes.data.data || []);
|
setTranscripts(transcriptRes.data.data || []);
|
||||||
|
setMeetingChapters(chapterRes.data.data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -827,6 +883,68 @@ const MeetingDetail: React.FC = () => {
|
||||||
() => resolveAudioExtension(playbackAudioUrl).toUpperCase(),
|
() => resolveAudioExtension(playbackAudioUrl).toUpperCase(),
|
||||||
[playbackAudioUrl],
|
[playbackAudioUrl],
|
||||||
);
|
);
|
||||||
|
const keywordItems = useMemo(
|
||||||
|
() => (analysis.keywords.length ? visibleKeywords : meetingTags),
|
||||||
|
[analysis.keywords.length, meetingTags, visibleKeywords],
|
||||||
|
);
|
||||||
|
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
|
||||||
|
const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
|
||||||
|
const sourceChapters = meetingChapters.length
|
||||||
|
? meetingChapters
|
||||||
|
: analysis.chapters.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
time: item.time,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return sourceChapters.map((chapter, index) => {
|
||||||
|
let matchedTranscripts: MeetingTranscriptVO[] = [];
|
||||||
|
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
|
||||||
|
? chapter.sourceTranscriptIds
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (sourceTranscriptIds.length) {
|
||||||
|
matchedTranscripts = sourceTranscriptIds
|
||||||
|
.map((item) => transcripts[transcriptIdToIndex.get(item)!])
|
||||||
|
.filter(Boolean);
|
||||||
|
} else if (chapter.startTranscriptId && chapter.endTranscriptId) {
|
||||||
|
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
|
||||||
|
const endIndex = transcriptIdToIndex.get(Number(chapter.endTranscriptId));
|
||||||
|
if (startIndex !== undefined && endIndex !== undefined) {
|
||||||
|
matchedTranscripts = transcripts.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex) + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const startMs = typeof chapter.startTime === 'number' ? chapter.startTime : parseChapterTimeToMs(chapter.time);
|
||||||
|
const nextChapterStartMs = sourceChapters
|
||||||
|
.slice(index + 1)
|
||||||
|
.map((item) => (typeof item.startTime === 'number' ? item.startTime : parseChapterTimeToMs(item.time)))
|
||||||
|
.find((item): item is number => item !== null && startMs !== null && item > startMs);
|
||||||
|
|
||||||
|
if (startMs !== null) {
|
||||||
|
const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs);
|
||||||
|
if (firstTranscriptIndex >= 0) {
|
||||||
|
const lastTranscriptIndex = nextChapterStartMs === undefined
|
||||||
|
? transcripts.length
|
||||||
|
: transcripts.findIndex((item) => item.startTime >= nextChapterStartMs);
|
||||||
|
matchedTranscripts = transcripts.slice(
|
||||||
|
firstTranscriptIndex,
|
||||||
|
lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `${chapter.chapterNo ?? index}-${chapter.title || 'chapter'}`,
|
||||||
|
title: chapter.title || `章节 ${index + 1}`,
|
||||||
|
timeLabel: chapter.time || '--:--',
|
||||||
|
transcriptIds: matchedTranscripts.map((item) => item.id),
|
||||||
|
firstTranscriptId: matchedTranscripts[0]?.id ?? null,
|
||||||
|
firstTranscriptStartTime: matchedTranscripts[0]?.startTime ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [analysis.chapters, meetingChapters, transcripts]);
|
||||||
const sharePreviewUrl = useMemo(() => {
|
const sharePreviewUrl = useMemo(() => {
|
||||||
const meetingId = meeting?.id ?? (id ? Number(id) : NaN);
|
const meetingId = meeting?.id ?? (id ? Number(id) : NaN);
|
||||||
return buildMeetingPreviewUrl(meetingId);
|
return buildMeetingPreviewUrl(meetingId);
|
||||||
|
|
@ -927,6 +1045,27 @@ const MeetingDetail: React.FC = () => {
|
||||||
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
|
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
|
||||||
}, [analysis.keywords]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (meeting?.audioSaveStatus === 'FAILED') {
|
if (meeting?.audioSaveStatus === 'FAILED') {
|
||||||
message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。');
|
message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。');
|
||||||
|
|
@ -1070,8 +1209,10 @@ const MeetingDetail: React.FC = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (firstMatch) {
|
if (firstMatch) {
|
||||||
|
setWorkspaceTab('transcript');
|
||||||
|
setLinkedTranscriptIds([]);
|
||||||
|
setLinkedChapterKey(null);
|
||||||
setHighlightKeyword(keyword);
|
setHighlightKeyword(keyword);
|
||||||
setHighlightTimestamp(firstMatch.startTime);
|
|
||||||
seekTo(firstMatch.startTime);
|
seekTo(firstMatch.startTime);
|
||||||
message.info(`已跳转至关键词 "${keyword}" 所在位置`);
|
message.info(`已跳转至关键词 "${keyword}" 所在位置`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1079,6 +1220,27 @@ const MeetingDetail: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [transcripts, seekTo, message]);
|
}, [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 () => {
|
const handleRetryTranscription = async () => {
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -1220,6 +1382,10 @@ const MeetingDetail: React.FC = () => {
|
||||||
void fetchData(meeting.id);
|
void fetchData(meeting.id);
|
||||||
}, [fetchData, meeting]);
|
}, [fetchData, meeting]);
|
||||||
|
|
||||||
|
const registerTranscriptRow = useCallback((transcriptId: number, node: HTMLDivElement | null) => {
|
||||||
|
transcriptItemRefs.current[transcriptId] = node;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleAudioPlaybackError = useCallback(() => {
|
const handleAudioPlaybackError = useCallback(() => {
|
||||||
const currentAudioUrl = playbackAudioUrl || '';
|
const currentAudioUrl = playbackAudioUrl || '';
|
||||||
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
|
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
|
||||||
|
|
@ -1694,6 +1860,57 @@ const MeetingDetail: React.FC = () => {
|
||||||
<Row gutter={24} style={{ height: '100%' }}>
|
<Row gutter={24} style={{ height: '100%' }}>
|
||||||
<Col xs={24} xl={13} style={{ height: '100%' }}>
|
<Col xs={24} xl={13} style={{ height: '100%' }}>
|
||||||
<div className="detail-side-column detail-left-column">
|
<div className="detail-side-column detail-left-column">
|
||||||
|
<Card className="left-flow-card keyword-panel" variant="borderless">
|
||||||
|
<div className="keyword-panel-head">
|
||||||
|
<div className="keyword-panel-title">关键词</div>
|
||||||
|
<div className="transcript-keyword-actions">
|
||||||
|
{analysis.keywords.length > 9 ? (
|
||||||
|
<button type="button" className="summary-link" onClick={() => setExpandKeywords((value) => !value)}>
|
||||||
|
{expandKeywords ? '收起' : '展开全部'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{isOwner && analysis.keywords.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
disabled={!selectedKeywords.length}
|
||||||
|
loading={addingHotwords}
|
||||||
|
onClick={handleAddSelectedHotwords}
|
||||||
|
>
|
||||||
|
加入热词 {selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="record-tags">
|
||||||
|
{keywordItems.length ? (
|
||||||
|
keywordItems.map((tag) => {
|
||||||
|
const isSelected = selectedKeywords.includes(tag);
|
||||||
|
const isHighlighted = highlightKeyword === tag;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
className={`tag selectable-tag ${isSelected ? 'selected' : ''} ${isHighlighted ? 'highlighted-tag' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isOwner && analysis.keywords.length) {
|
||||||
|
handleKeywordToggle(tag, !isSelected);
|
||||||
|
}
|
||||||
|
handleKeywordClick(tag);
|
||||||
|
}}
|
||||||
|
style={isHighlighted ? { borderColor: '#5f51ff', backgroundColor: 'rgba(95, 81, 255, 0.1)' } : {}}
|
||||||
|
>
|
||||||
|
<span>#{tag}</span>
|
||||||
|
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">暂无关键词</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="left-flow-card summary-panel" variant="borderless">
|
<Card className="left-flow-card summary-panel" variant="borderless">
|
||||||
<div className="summary-head">
|
<div className="summary-head">
|
||||||
<div className="summary-title">
|
<div className="summary-title">
|
||||||
|
|
@ -1861,12 +2078,14 @@ const MeetingDetail: React.FC = () => {
|
||||||
isEditing={editingTranscriptId === item.id}
|
isEditing={editingTranscriptId === item.id}
|
||||||
isSaving={savingTranscriptId === item.id}
|
isSaving={savingTranscriptId === item.id}
|
||||||
speakerLabelMap={speakerLabelMap}
|
speakerLabelMap={speakerLabelMap}
|
||||||
onSeek={seekTo}
|
onPlay={handleTranscriptRowPlay}
|
||||||
onStartEdit={handleStartEditTranscript}
|
onStartEdit={handleStartEditTranscript}
|
||||||
onDraftBlur={handleTranscriptDraftBlur}
|
onDraftBlur={handleTranscriptDraftBlur}
|
||||||
onDraftKeyDown={handleTranscriptDraftKeyDown}
|
onDraftKeyDown={handleTranscriptDraftKeyDown}
|
||||||
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
||||||
|
registerRow={registerTranscriptRow}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
|
isLinkedHighlight={linkedTranscriptIds.includes(item.id)}
|
||||||
audioPlaying={audioPlaying}
|
audioPlaying={audioPlaying}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -1894,106 +2113,98 @@ const MeetingDetail: React.FC = () => {
|
||||||
</audio>
|
</audio>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="transcript-keyword-bar">
|
|
||||||
<div className="transcript-keyword-label">关键词</div>
|
|
||||||
<div className="transcript-keyword-body">
|
|
||||||
<div className="record-tags">
|
|
||||||
{(analysis.keywords.length ? visibleKeywords : meetingTags).length ? (
|
|
||||||
(analysis.keywords.length ? visibleKeywords : meetingTags).map((tag) => {
|
|
||||||
const isSelected = selectedKeywords.includes(tag);
|
|
||||||
const isHighlighted = highlightKeyword === tag;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tag}
|
|
||||||
className={`tag selectable-tag ${isSelected ? 'selected' : ''} ${isHighlighted ? 'highlighted-tag' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (isOwner && analysis.keywords.length) {
|
|
||||||
handleKeywordToggle(tag, !isSelected);
|
|
||||||
}
|
|
||||||
handleKeywordClick(tag);
|
|
||||||
}}
|
|
||||||
style={isHighlighted ? { borderColor: '#5f51ff', backgroundColor: 'rgba(95, 81, 255, 0.1)' } : {}}
|
|
||||||
>
|
|
||||||
<span>#{tag}</span>
|
|
||||||
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<Text type="secondary">暂无关键词</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="transcript-keyword-actions">
|
|
||||||
{analysis.keywords.length > 9 ? (
|
|
||||||
<button type="button" className="summary-link" onClick={() => setExpandKeywords((value) => !value)}>
|
|
||||||
{expandKeywords ? '收起' : '展开全部'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{isOwner && analysis.keywords.length > 0 ? (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
ghost
|
|
||||||
disabled={!selectedKeywords.length}
|
|
||||||
loading={addingHotwords}
|
|
||||||
onClick={handleAddSelectedHotwords}
|
|
||||||
>
|
|
||||||
加入热词 {selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="transcript-stage-tabs">
|
<div className="transcript-stage-tabs">
|
||||||
<button type="button" className="active">转录原文</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={workspaceTab === 'catalog' ? 'active' : ''}
|
||||||
|
onClick={() => setWorkspaceTab('catalog')}
|
||||||
|
>
|
||||||
|
AI目录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={workspaceTab === 'transcript' ? 'active' : ''}
|
||||||
|
onClick={() => setWorkspaceTab('transcript')}
|
||||||
|
>
|
||||||
|
转录原文
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="transcript-scroll-shell">
|
<div className="transcript-scroll-shell">
|
||||||
{emptyTranscriptFailureNotice && (
|
{workspaceTab === 'catalog' ? (
|
||||||
<div className="empty-transcript-inline-note">
|
<div className="catalog-list">
|
||||||
<div className="empty-transcript-inline-note__title">当前没有可展示的转录内容</div>
|
{catalogChapterLinks.length ? (
|
||||||
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
|
catalogChapterLinks.map((chapter, index) => (
|
||||||
|
<div
|
||||||
|
key={chapter.key}
|
||||||
|
className={`catalog-item ${linkedChapterKey === chapter.key ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="catalog-item-time">{chapter.timeLabel}</div>
|
||||||
|
<div className="catalog-item-main">
|
||||||
|
<div className="catalog-item-title">{chapter.title}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="catalog-item-link"
|
||||||
|
onClick={() => handleLocateChapterTranscript(index)}
|
||||||
|
>
|
||||||
|
关联原文
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 AI 目录" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{meeting.audioSaveStatus === 'FAILED' && (
|
<>
|
||||||
<Alert
|
{emptyTranscriptFailureNotice && (
|
||||||
type="warning"
|
<div className="empty-transcript-inline-note">
|
||||||
showIcon
|
<div className="empty-transcript-inline-note__title">当前没有可展示的转录内容</div>
|
||||||
style={{ margin: '0 18px 16px' }}
|
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
|
||||||
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
|
</div>
|
||||||
/>
|
)}
|
||||||
)}
|
{meeting.audioSaveStatus === 'FAILED' && (
|
||||||
<List
|
<Alert
|
||||||
className="transcript-list"
|
type="warning"
|
||||||
dataSource={transcripts}
|
showIcon
|
||||||
style={{ paddingBottom: playbackAudioUrl ? 156 : 0 }}
|
style={{ margin: '0 18px 16px' }}
|
||||||
renderItem={(item, index) => {
|
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
|
||||||
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
|
|
||||||
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActiveTranscriptRow
|
|
||||||
item={item}
|
|
||||||
meetingId={meeting.id}
|
|
||||||
isOwner={isOwner}
|
|
||||||
isEditing={editingTranscriptId === item.id}
|
|
||||||
isSaving={savingTranscriptId === item.id}
|
|
||||||
speakerLabelMap={speakerLabelMap}
|
|
||||||
onSeek={seekTo}
|
|
||||||
onStartEdit={handleStartEditTranscript}
|
|
||||||
onDraftBlur={handleTranscriptDraftBlur}
|
|
||||||
onDraftKeyDown={handleTranscriptDraftKeyDown}
|
|
||||||
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
|
||||||
isActive={isActive}
|
|
||||||
audioPlaying={audioPlaying}
|
|
||||||
highlightKeyword={highlightKeyword}
|
|
||||||
/>
|
/>
|
||||||
);
|
)}
|
||||||
}}
|
<List
|
||||||
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
className="transcript-list"
|
||||||
/>
|
dataSource={transcripts}
|
||||||
|
style={{ paddingBottom: playbackAudioUrl ? 156 : 0 }}
|
||||||
|
renderItem={(item, index) => {
|
||||||
|
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
|
||||||
|
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActiveTranscriptRow
|
||||||
|
item={item}
|
||||||
|
meetingId={meeting.id}
|
||||||
|
isOwner={isOwner}
|
||||||
|
isEditing={editingTranscriptId === item.id}
|
||||||
|
isSaving={savingTranscriptId === item.id}
|
||||||
|
speakerLabelMap={speakerLabelMap}
|
||||||
|
onPlay={handleTranscriptRowPlay}
|
||||||
|
onStartEdit={handleStartEditTranscript}
|
||||||
|
onDraftBlur={handleTranscriptDraftBlur}
|
||||||
|
onDraftKeyDown={handleTranscriptDraftKeyDown}
|
||||||
|
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
||||||
|
registerRow={registerTranscriptRow}
|
||||||
|
isActive={isActive}
|
||||||
|
isLinkedHighlight={linkedTranscriptIds.includes(item.id)}
|
||||||
|
audioPlaying={audioPlaying}
|
||||||
|
highlightKeyword={highlightKeyword}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2165,6 +2376,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.detail-left-column > .section-divider-note,
|
.detail-left-column > .section-divider-note,
|
||||||
.detail-left-column > .transcript-player-anchor {
|
.detail-left-column > .transcript-player-anchor {
|
||||||
|
|
@ -2221,6 +2433,24 @@ const MeetingDetail: React.FC = () => {
|
||||||
.summary-markdown-panel {
|
.summary-markdown-panel {
|
||||||
min-height: 0;
|
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 {
|
.brief-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|
@ -2613,6 +2843,64 @@ const MeetingDetail: React.FC = () => {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 18px 18px 0;
|
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 {
|
.segmented-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
|
|
@ -2808,6 +3096,12 @@ const MeetingDetail: React.FC = () => {
|
||||||
border-bottom: 0 !important;
|
border-bottom: 0 !important;
|
||||||
cursor: pointer;
|
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,
|
||||||
.ant-list-item.transcript-row.active .transcript-bubble-editing {
|
.ant-list-item.transcript-row.active .transcript-bubble-editing {
|
||||||
border-color: rgba(95, 81, 255, 0.16);
|
border-color: rgba(95, 81, 255, 0.16);
|
||||||
|
|
@ -3102,6 +3396,9 @@ const MeetingDetail: React.FC = () => {
|
||||||
.detail-left-column {
|
.detail-left-column {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
.catalog-item {
|
||||||
|
grid-template-columns: 68px minmax(0, 1fr);
|
||||||
|
}
|
||||||
.speaker-summary-card {
|
.speaker-summary-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
@ -3123,6 +3420,10 @@ const MeetingDetail: React.FC = () => {
|
||||||
.detail-left-column {
|
.detail-left-column {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
.catalog-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.transcript-player--floating {
|
.transcript-player--floating {
|
||||||
bottom: 72px;
|
bottom: 72px;
|
||||||
max-width: calc(100vw - 24px);
|
max-width: calc(100vw - 24px);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue