diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java index 1bd212b..e0ed123 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java @@ -100,6 +100,10 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService if (rawContent == null || rawContent.isBlank()) { return null; } + Map xmlBundle = tryParseXmlBundle(rawContent.trim()); + if (xmlBundle != null) { + return xmlBundle; + } Map parsed = tryParseJson(rawContent.trim()); if (parsed == null) { return null; @@ -129,6 +133,10 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService if (rawContent == null || rawContent.isBlank()) { return null; } + Map xmlAnalysis = tryParseXmlAnalysis(rawContent.trim()); + if (xmlAnalysis != null) { + return xmlAnalysis; + } Map parsed = tryParseJson(rawContent.trim()); if (parsed == null) { return null; @@ -327,6 +335,142 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService return null; } + private Map tryParseXmlBundle(String rawContent) { + String text = normalizeXmlCandidate(rawContent); + String summarySection = extractXmlSection(text, "summary"); + if (summarySection != null) { + text = summarySection; + } + + String summaryContent = extractXmlSection(text, "summaryContent"); + String analysisSection = extractXmlSection(text, "analysis"); + if (!hasText(summaryContent) && !hasText(analysisSection)) { + return null; + } + + Map bundle = new LinkedHashMap<>(); + bundle.put("summaryContent", normalizeSummaryMarkdown(summaryContent)); + bundle.put("analysis", hasText(analysisSection) ? parseXmlAnalysisSection(analysisSection) : null); + return bundle; + } + + private Map tryParseXmlAnalysis(String rawContent) { + String text = normalizeXmlCandidate(rawContent); + String summarySection = extractXmlSection(text, "summary"); + if (summarySection != null) { + text = summarySection; + } + + String analysisSection = extractXmlSection(text, "analysis"); + if (analysisSection != null) { + return parseXmlAnalysisSection(analysisSection); + } + if (containsAnyXmlTag(text, "overview", "keywords", "speakerSummaries", "keyPoints", "todos")) { + return parseXmlAnalysisSection(text); + } + return null; + } + + private Map parseXmlAnalysisSection(String analysisSection) { + Map analysis = new LinkedHashMap<>(); + analysis.put("overview", extractXmlSection(analysisSection, "overview")); + analysis.put("keywords", extractXmlTextList(firstNonBlank(extractXmlSection(analysisSection, "keywords"), analysisSection), "keyword")); + analysis.put("speakerSummaries", extractXmlItemMaps(analysisSection, "speakerSummaries", "speaker", "summary")); + analysis.put("keyPoints", extractXmlItemMaps(analysisSection, "keyPoints", "title", "summary", "speaker", "time")); + analysis.put("todos", extractXmlTextList(firstNonBlank(extractXmlSection(analysisSection, "todos"), analysisSection), "todo")); + return parseSummaryAnalysisFromMap(analysis); + } + + private List> extractXmlItemMaps(String text, String parentTag, String... childTags) { + List> result = new ArrayList<>(); + String parent = extractXmlSection(text, parentTag); + if (!hasText(parent)) { + return result; + } + for (String item : extractXmlSections(parent, "item")) { + Map normalized = new LinkedHashMap<>(); + for (String childTag : childTags) { + normalized.put(childTag, extractXmlSection(item, childTag)); + } + result.add(normalized); + } + return result; + } + + private List extractXmlTextList(String text, String tag) { + List result = new ArrayList<>(); + for (String item : extractXmlSections(text, tag)) { + if (hasText(item) && !result.contains(item)) { + result.add(item); + } + } + return result; + } + + private List extractXmlSections(String text, String tag) { + List result = new ArrayList<>(); + if (!hasText(text)) { + return result; + } + String pattern = "(?is)<\\s*" + tag + "(?:\\s+[^>]*)?>(.*?)<\\s*/\\s*" + tag + "\\s*>"; + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile(pattern).matcher(text); + while (matcher.find()) { + result.add(normalizeXmlText(matcher.group(1))); + } + return result; + } + + private String extractXmlSection(String text, String tag) { + List sections = extractXmlSections(text, tag); + return sections.isEmpty() ? null : sections.get(0); + } + + private String normalizeXmlCandidate(String rawContent) { + String fenced = unwrapCodeFence(rawContent); + return fenced == null ? rawContent.trim() : fenced.trim(); + } + + private String normalizeXmlText(String text) { + String normalized = text == null ? "" : text.trim(); + if (normalized.startsWith("")) { + normalized = normalized.substring(9, normalized.length() - 3).trim(); + } + return normalized + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'"); + } + + private boolean containsAnyXmlTag(String text, String... tags) { + if (!hasText(text) || tags == null) { + return false; + } + for (String tag : tags) { + if (text.matches("(?is).*<\\s*" + tag + "(?:\\s+[^>]*)?>.*")) { + return true; + } + } + return false; + } + + private boolean hasText(String text) { + return text != null && !text.isBlank(); + } + + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (hasText(value)) { + return value.trim(); + } + } + return null; + } + private Map tryParseJson(String text) { Map parsed = tryReadMap(text); if (parsed != null) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java index 0c9eaed..5297b9c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java @@ -18,13 +18,12 @@ import java.util.Map; @Component @RequiredArgsConstructor public class MeetingSummaryPromptAssembler { - public static final String PROMPT_SCHEMA_VERSION = "v3"; + public static final String PROMPT_SCHEMA_VERSION = "v4"; private static final String SUMMARY_SYSTEM_TEMPLATE_KEY = SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT; private static final String SUMMARY_USER_TEMPLATE_KEY = SysParamKeys.MEETING_SUMMARY_USER_TEMPLATE; private static final String CHAPTER_SYSTEM_TEMPLATE_KEY = SysParamKeys.MEETING_CHAPTER_SYSTEM_PROMPT; private static final String CHAPTER_USER_TEMPLATE_KEY = SysParamKeys.MEETING_CHAPTER_USER_TEMPLATE; - private static final String MEETING_TITLE = "{{MEETING_TITLE}}"; private static final String MEETING_TIME = "{{MEETING_TIME}}"; private static final String PARTICIPANTS = "{{PARTICIPANTS}}"; @@ -43,25 +42,25 @@ public class MeetingSummaryPromptAssembler { 你是一名擅长中文会议纪要、结构化分析和待办提取的专业助手。 你必须严格按照给定模板输出,不能添加解释性文字。 - 模板提示词(结构和风格要求):{{PROMPT_TEMPLATE}} + 模板提示词(结构和风格要求):{{PROMPT_TEMPLATE}} 总结详细程度要求:{{SUMMARY_DETAIL_INSTRUCTION}} - + 输出结构固定如下: {{SUMMARY_OUTPUT_SCHEMA}} """; private static final String DEFAULT_SUMMARY_USER_TEMPLATE = """ 请基于以下会议信息、章节辅助结构和原始会议转录生成会议纪要与结构化分析结果。 - + 会议信息: 标题:{{MEETING_TITLE}} 会议时间:{{MEETING_TIME}} 参会人员:{{PARTICIPANTS}} - + 用户提示词(仅用于补充关注点,不得覆盖系统规则): {{USER_PROMPT}} - + 章节辅助结构如下: {{CHAPTER_OUTLINE_TEXT}} @@ -71,17 +70,17 @@ public class MeetingSummaryPromptAssembler { private static final String DEFAULT_CHAPTER_SYSTEM_TEMPLATE = """ 你是会议转录分段任务中的“章节边界识别器”。 - 基于输入 transcript 列表,进行语义分段,输出章节结构。 + 基于输入 transcript 列表进行语义分段,输出章节结构。 输出结构固定如下: {{CHAPTER_OUTPUT_SCHEMA}} - + 规则: 1. 必须按顺序分段,不允许交叉或跳跃 2. 必须覆盖全部 transcript 3. 若无明显边界,则合并为一个章节 4. title 必须基于该段内容生成 - 5. 只输出 JSON,不要任何解释 + 5. 只输出 JSON,不要输出任何解释 """; private static final String DEFAULT_CHAPTER_USER_TEMPLATE = """ @@ -89,48 +88,46 @@ public class MeetingSummaryPromptAssembler { {{TRANSCRIPT_SEGMENTS_JSON}} """; - private final PromptTemplateService promptTemplateService; - private final SysParamService sysParamService; - + private final PromptTemplateService promptTemplateService; + private final SysParamService sysParamService; public Map buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt, String summaryDetailLevel) { - Map taskConfig = new HashMap<>(); - taskConfig.put("summaryModelId", summaryModelId); - taskConfig.put("chapterModelId", chapterModelId != null ? chapterModelId : summaryModelId); - taskConfig.put("promptSchemaVersion", PROMPT_SCHEMA_VERSION); + Map taskConfig = new HashMap<>(); + taskConfig.put("summaryModelId", summaryModelId); + taskConfig.put("chapterModelId", chapterModelId != null ? chapterModelId : summaryModelId); + taskConfig.put("promptSchemaVersion", PROMPT_SCHEMA_VERSION); taskConfig.put("summaryPromptTemplate", resolveSummarySystemTemplate()); taskConfig.put("summaryUserTemplate", resolveSummaryUserTemplate()); taskConfig.put("chapterPromptTemplate", resolveChapterSystemTemplate()); taskConfig.put("chapterUserTemplate", resolveChapterUserTemplate()); - taskConfig.put("summaryDetailLevel", normalizeSummaryDetailLevel(summaryDetailLevel)); + taskConfig.put("summaryDetailLevel", normalizeSummaryDetailLevel(summaryDetailLevel)); taskConfig.put("effectiveTemplatePrompt", resolveTemplatePrompt(promptId)); - taskConfig.put("userPrompt", normalizeOptionalText(userPrompt)); - if (promptId != null) { - taskConfig.put("promptId", promptId); - } - return taskConfig; + taskConfig.put("userPrompt", normalizeOptionalText(userPrompt)); + if (promptId != null) { + taskConfig.put("promptId", promptId); } + return taskConfig; + } - public String buildSystemMessage(Map taskConfig) { - return render(resolveSummarySystemTemplate(taskConfig), buildSummaryValues(taskConfig, null, null, null)); - } + public String buildSystemMessage(Map taskConfig) { + return render(resolveSummarySystemTemplate(taskConfig), buildSummaryValues(taskConfig, null, null, null)); + } public String buildUserMessage(Map taskConfig, Meeting meeting, MeetingSummarySource summarySource, String userPrompt) { return render(resolveSummaryUserTemplate(taskConfig), buildSummaryValues(taskConfig, meeting, summarySource, userPrompt)); - } + } - public String buildUserMessage(Meeting meeting, MeetingSummarySource summarySource, String userPrompt) { - return buildUserMessage(null, meeting, summarySource, userPrompt); - } + public String buildUserMessage(Meeting meeting, MeetingSummarySource summarySource, String userPrompt) { + return buildUserMessage(null, meeting, summarySource, userPrompt); + } public String buildUserMessageTemplate(Map taskConfig, Meeting meeting, String userPrompt) { return render(resolveSummaryUserTemplate(taskConfig), buildSummaryValues(taskConfig, meeting, null, userPrompt)); - } - + } public String buildChapterSystemMessage(Map taskConfig) { return render(resolveChapterSystemTemplate(taskConfig), Map.of(CHAPTER_OUTPUT_SCHEMA, buildChapterOutputSchema())); - } + } public String buildChapterUserMessage(String transcriptSegmentsJson) { return render(DEFAULT_CHAPTER_USER_TEMPLATE, Map.of(TRANSCRIPT_SEGMENTS_JSON, firstNonBlank(transcriptSegmentsJson, ""))); @@ -138,23 +135,19 @@ public class MeetingSummaryPromptAssembler { public String normalizeOptionalText(String value) { return firstNonBlank(value, null); - } + } - public String resolveTemplatePrompt(Long promptId) { - if (promptId == null) { - return ""; - } - PromptTemplate template = promptTemplateService.getById(promptId); - return template == null ? "" : firstNonBlank(template.getPromptContent(), ""); + public String resolveTemplatePrompt(Long promptId) { + if (promptId == null) { + return ""; } + PromptTemplate template = promptTemplateService.getById(promptId); + return template == null ? "" : firstNonBlank(template.getPromptContent(), ""); + } private String resolveSummarySystemTemplate() { String configured = sysParamService.getCachedParamValue(SUMMARY_SYSTEM_TEMPLATE_KEY, ""); - if (StringUtils.hasText(configured)) { - return configured.trim(); - } - String legacy = sysParamService.getCachedParamValue(SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT, ""); - return StringUtils.hasText(legacy) ? legacy.trim() : DEFAULT_SUMMARY_SYSTEM_TEMPLATE; + return StringUtils.hasText(configured) ? configured.trim() : DEFAULT_SUMMARY_SYSTEM_TEMPLATE; } private String resolveSummarySystemTemplate(Map taskConfig) { @@ -184,7 +177,6 @@ public class MeetingSummaryPromptAssembler { return StringUtils.hasText(configured) ? configured.trim() : DEFAULT_CHAPTER_USER_TEMPLATE; } - private Map buildSummaryValues(Map taskConfig, Meeting meeting, MeetingSummarySource summarySource, String userPrompt) { Map values = new LinkedHashMap<>(); values.put(MEETING_TITLE, meeting == null ? "" : firstNonBlank(meeting.getTitle(), "未命名会议")); @@ -207,7 +199,7 @@ public class MeetingSummaryPromptAssembler { return """ 【章节导航】 %s - + 【原始转录】 %s """.formatted(chapterOutlineText, rawTranscriptText); @@ -217,24 +209,29 @@ public class MeetingSummaryPromptAssembler { String result = firstNonBlank(template, ""); if (values == null || values.isEmpty()) { return result; - } + } for (Map.Entry entry : values.entrySet()) { result = result.replace(entry.getKey(), entry.getValue() == null ? "" : entry.getValue()); } return result; - } + } private String buildSummaryOutputSchema() { return """ - { - "summaryContent": string, - "analysis": { - "keywords": array - } - } - 其中summaryContent为完整会议纪要正文,使用 markdown,analysis.keywords为关键词数组 + + + + + 关键词 + + + + 只输出上述 XML,不要输出 JSON,不要添加解释文字。summaryContent 内放 Markdown 正文,建议使用 CDATA 包裹。 """; - } + } private String buildChapterOutputSchema() { return """ @@ -246,25 +243,25 @@ public class MeetingSummaryPromptAssembler { "summary": string, "keywords": array, "startTranscriptId": number, - "endTranscriptId":number, + "endTranscriptId": number, "confidence": number } ] } """; - } + } - private String firstNonBlank(String... values) { - if (values == null) { - return null; - } - for (String value : values) { - if (StringUtils.hasText(value)) { - return value.trim(); - } - } - return null; + private String firstNonBlank(String... values) { + if (values == null) { + return null; } + for (String value : values) { + if (StringUtils.hasText(value)) { + return value.trim(); + } + } + return null; + } private String stringValue(Map source, String key) { if (source == null || key == null) { @@ -274,23 +271,32 @@ public class MeetingSummaryPromptAssembler { return value == null ? null : normalizeOptionalText(String.valueOf(value)); } - private String normalizeSummaryDetailLevel(String summaryDetailLevel) { - if (!StringUtils.hasText(summaryDetailLevel)) { - return MeetingConstants.SUMMARY_DETAIL_STANDARD; - } - String normalized = summaryDetailLevel.trim().toUpperCase(); - return MeetingConstants.SUMMARY_DETAIL_DETAILED.equals(normalized) || MeetingConstants.SUMMARY_DETAIL_BRIEF.equals(normalized) - ? normalized - : MeetingConstants.SUMMARY_DETAIL_STANDARD; + private String normalizeSummaryDetailLevel(String summaryDetailLevel) { + if (!StringUtils.hasText(summaryDetailLevel)) { + return MeetingConstants.SUMMARY_DETAIL_STANDARD; } + String normalized = summaryDetailLevel.trim().toUpperCase(); + return MeetingConstants.SUMMARY_DETAIL_DETAILED.equals(normalized) || MeetingConstants.SUMMARY_DETAIL_BRIEF.equals(normalized) + ? normalized + : MeetingConstants.SUMMARY_DETAIL_STANDARD; + } - private String buildSummaryDetailInstruction(String summaryDetailLevel) { - return switch (normalizeSummaryDetailLevel(summaryDetailLevel)) { - case MeetingConstants.SUMMARY_DETAIL_DETAILED -> - "DETAILED:尽量完整覆盖会议背景、讨论过程、分歧、结论、负责人、时间点和风险,适当展开章节内容。"; - case MeetingConstants.SUMMARY_DETAIL_BRIEF -> - "BRIEF:只保留核心结论、关键决策、待办事项和必要上下文,避免冗长铺陈。"; - default -> "STANDARD:保持信息完整和篇幅平衡,覆盖核心过程、结论和待办,不做过度展开。"; - }; - } + private String buildSummaryDetailInstruction(String summaryDetailLevel) { + return switch (normalizeSummaryDetailLevel(summaryDetailLevel)) { + case MeetingConstants.SUMMARY_DETAIL_DETAILED -> + "目标:完整覆盖结构化关键信息和转录中的有效事实,适合正式纪要、复盘和归档。\n" + + "信息覆盖:不得因为篇幅原因丢失任何关键事实、数字、时间、责任人、部门、决策、行动项、风险、问题和待确认事项。\n" + + "表达方式:在完整覆盖的基础上展开背景、原因、影响范围、过程关系和后续动作;相近表达可以合并,但合并后必须保留原有事实含义。\n" + + "密度:每个模板小节可以充分展开;重要议题可以分层描述;同一事项可补充上下文和执行影响。"; + case MeetingConstants.SUMMARY_DETAIL_BRIEF -> + "目标:完整覆盖结构化关键信息和转录中的有效事实,同时压缩表达,适合快速扫读和简版纪要。\n" + + "信息覆盖:不得因为选择简洁档而丢失关键事实、数字、时间、责任人、部门、决策、行动项、风险、问题和待确认事项。\n" + + "表达方式:把多个相关事实合并成短句或紧凑条目;减少背景解释和过程描述,但必须保留事实本身及其关键限定条件。\n" + + "密度:每个模板小节用更短句子表达;同样的信息写得更凝练,不减少信息项。"; + default -> "目标:完整覆盖结构化关键信息和转录中的有效事实,适合默认正式纪要。\n" + + "信息覆盖:不得遗漏任何关键事实、数字、时间、责任人、部门、决策、行动项、风险、问题和待确认事项。\n" + + "表达方式:用清晰、紧凑的方式组织信息;相近事项可以合并成一条,但不能省略不同事项、不同指标、不同责任方或不同时间点。\n" + + "密度:每个模板小节保持适中篇幅;少写背景,多写结论、进展、决策和执行安排。"; + }; + } } diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java index 4fc25c8..493cead 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java @@ -39,7 +39,7 @@ class MeetingSummaryPromptAssemblerTest { MeetingSummaryPromptAssembler assembler = new MeetingSummaryPromptAssembler(promptTemplateService, sysParamService); Map taskConfig = assembler.buildTaskConfig(2L, 5L, 3L, "关注风险", MeetingConstants.SUMMARY_DETAIL_DETAILED); - assertEquals("v3", taskConfig.get("promptSchemaVersion")); + assertEquals("v4", taskConfig.get("promptSchemaVersion")); assertEquals("总结系统模板", taskConfig.get("summaryPromptTemplate")); assertEquals("总结用户模板", taskConfig.get("summaryUserTemplate")); assertEquals("章节系统模板", taskConfig.get("chapterPromptTemplate")); @@ -69,4 +69,18 @@ class MeetingSummaryPromptAssemblerTest { assertTrue(userMessage.contains("Alice: hello")); assertTrue(userMessage.contains("第一章")); } + + @Test + void buildSystemMessageShouldRequestXmlSummaryContent() { + MeetingSummaryPromptAssembler assembler = new MeetingSummaryPromptAssembler( + mock(PromptTemplateService.class), + mock(SysParamService.class) + ); + + String systemMessage = assembler.buildSystemMessage(Map.of()); + + assertTrue(systemMessage.contains("")); + assertTrue(systemMessage.contains("")); + assertTrue(systemMessage.contains("")); + } }