diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEnrichmentApplier.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEnrichmentApplier.java index c0060166..4c1bfb1b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEnrichmentApplier.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEnrichmentApplier.java @@ -41,13 +41,39 @@ public final class WikiEnrichmentApplier { private static final Pattern REPLACEMENT_SHAPE = Pattern.compile("^\\[\\[([^\\[\\]|]+)(?:\\|([^\\[\\]]+))?]]$"); + /** + * Fenced code block — matches a triple-backtick line up to the next one. + * Same shape used in {@link WikiLinkService} so the applier and the + * extractor agree on what counts as code. + */ + private static final Pattern FENCED_CODE = Pattern.compile( + "(?m)^```[\\s\\S]*?^```", Pattern.MULTILINE); + + /** Matches inline {@code `...`} spans. Non-greedy so adjacent spans stay separate. */ + private static final Pattern INLINE_CODE = Pattern.compile("`[^`\\n]*?`"); + private WikiEnrichmentApplier() {} public static Result apply(String originalContent, EnrichmentPlan plan) { - return apply(originalContent, plan, DEFAULT_MAX_REPLACEMENTS); + return apply(originalContent, plan, DEFAULT_MAX_REPLACEMENTS, null); } public static Result apply(String originalContent, EnrichmentPlan plan, int maxReplacements) { + return apply(originalContent, plan, maxReplacements, null); + } + + /** + * Apply with an optional KB slug whitelist. When {@code allowedSlugsLower} + * is non-null, any replacement whose target slug is not in the set is + * silently dropped rather than failing the whole plan — matches the RFC + * "validate + drop hallucinated targets" intent for analyze-driven + * generation and lets the rest of the plan still land. {@code null} + * disables the check (legacy behaviour for the existing batch enrich + * paths that already validate elsewhere). + */ + public static Result apply(String originalContent, EnrichmentPlan plan, + int maxReplacements, + java.util.Set allowedSlugsLower) { if (originalContent == null) return Result.rejected("content is null"); if (plan == null || plan.isEmpty()) { return Result.unchanged(originalContent); @@ -58,9 +84,12 @@ public final class WikiEnrichmentApplier { } // 1) Per-original index of all candidate positions in the original text, - // skipping positions that fall inside an existing wikilink. + // skipping positions that fall inside an existing wikilink OR inside + // a fenced/inline code block. The combined mask is what we test — + // a doc that teaches wikilink syntax inside ```fence``` must not + // have its examples silently wrapped. java.util.Map> positionsByOriginal = new java.util.HashMap<>(); - boolean[] insideWikilink = computeWikilinkMask(originalContent); + boolean[] skipMask = computeSkipMask(originalContent); // 2) Plan splices: for each replacement pick positions[occurrence-1]. List splices = new ArrayList<>(); // [start, end, replacementIndex] @@ -82,9 +111,17 @@ public final class WikiEnrichmentApplier { return Result.rejected("visible text mismatch: replacement='" + replacement + "' must render '" + original + "'"); } + // Whitelist gate (RFC §4 Phase 5): when the caller supplied an + // allowed slug set, drop entries that fall outside it instead of + // landing them. Matches the "do not invent slugs" rule on the + // analyze→generate path without aborting the rest of the plan. + if (allowedSlugsLower != null + && !allowedSlugsLower.contains(slug.toLowerCase(java.util.Locale.ROOT))) { + continue; + } List positions = positionsByOriginal.computeIfAbsent(original, - o -> findPositions(originalContent, o, insideWikilink)); + o -> findPositions(originalContent, o, skipMask)); int idx = r.occurrence() - 1; if (idx < 0 || idx >= positions.size()) { // Skip silently — the page may have been re-edited since the LLM saw it. @@ -151,15 +188,26 @@ public final class WikiEnrichmentApplier { return out.toString(); } - private static boolean[] computeWikilinkMask(String content) { + /** + * Combined "do not splice here" mask — true at every offset that lies + * inside an existing wikilink, a fenced code block, or an inline code + * span. Used to keep enrichment out of code examples and existing links. + */ + private static boolean[] computeSkipMask(String content) { boolean[] mask = new boolean[content.length()]; - Matcher m = WIKILINK.matcher(content); - while (m.find()) { - for (int i = m.start(); i < m.end(); i++) mask[i] = true; - } + markPattern(mask, content, WIKILINK); + markPattern(mask, content, FENCED_CODE); + markPattern(mask, content, INLINE_CODE); return mask; } + private static void markPattern(boolean[] mask, String content, Pattern pattern) { + Matcher m = pattern.matcher(content); + while (m.find()) { + for (int i = m.start(); i < m.end() && i < mask.length; i++) mask[i] = true; + } + } + private static List findPositions(String content, String needle, boolean[] insideWikilink) { List out = new ArrayList<>(); if (needle.isEmpty()) return out; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index 2aee2701..a97dc442 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -2,6 +2,7 @@ package vip.mate.wiki.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.SystemMessage; @@ -52,6 +53,7 @@ public class WikiProcessingService { private final WikiPageService pageService; private final WikiChunkService chunkService; private final WikiEmbeddingService embeddingService; + private final WikiLinkService linkService; private final WikiProperties properties; private final ModelConfigService modelConfigService; private final AgentGraphBuilder agentGraphBuilder; @@ -801,9 +803,7 @@ public class WikiProcessingService { String routeSystem = PromptLoader.loadPrompt("wiki/route-system"); String routeUserTemplate = PromptLoader.loadPrompt("wiki/route-user"); - String documentMapSection = (documentMap != null && !documentMap.isBlank()) - ? "## 文档全局概念地图(预分析结果,供路由参考)\n\n```json\n" + documentMap + "\n```\n" - : ""; + String documentMapSection = buildDocumentMapSection(documentMap); String routeUser = routeUserTemplate .replace("{config}", configContent) .replace("{document_map_section}", documentMapSection) @@ -1066,9 +1066,7 @@ public class WikiProcessingService { String batchSystem = PromptLoader.loadPrompt("wiki/batch-create-system"); String batchUserTemplate = PromptLoader.loadPrompt("wiki/batch-create-user"); - String docMapSection = (documentMap != null && !documentMap.isBlank()) - ? "## 文档全局概念地图(预分析结果,供页面内容生成参考)\n\n```json\n" + documentMap + "\n```\n" - : ""; + String docMapSection = buildDocumentMapSection(documentMap); String batchUser = batchUserTemplate .replace("{config}", configContent) .replace("{document_map_section}", docMapSection) @@ -1594,10 +1592,16 @@ public class WikiProcessingService { String sample = textContent.length() > sampleChars ? textContent.substring(0, sampleChars) + "\n...[文档较长,以上为节选]" : textContent; + // Inject the existing-pages index so the LLM can pick a real `related_pages` + // whitelist of slugs that already exist in the KB. Generation prompts + // downstream see the validated whitelist via `documentMap`, which lets + // them link confidently instead of inventing targets. + String existingPagesIndex = buildExistingPagesIndex(kb.getId()); String system = PromptLoader.loadPrompt("wiki/analyze-system"); String userTemplate = PromptLoader.loadPrompt("wiki/analyze-user"); String user = userTemplate .replace("{raw_title}", raw.getTitle()) + .replace("{existing_pages}", existingPagesIndex) .replace("{text_sample}", sample); Prompt prompt = new Prompt(List.of( new SystemMessage(system), @@ -1609,11 +1613,17 @@ public class WikiProcessingService { kb.getId(), vip.mate.wiki.job.WikiJobStep.ROUTE); JsonNode json = parseJsonResponse(response); if (json != null) { - log.info("[Wiki] Document analysis done for raw={}: topics={}, concepts={}", + // Validate related_pages against the active KB slug set BEFORE + // letting it flow downstream. An LLM that ignores the "must + // come from the index" rule and invents slugs would otherwise + // pollute the generation prompt, undoing the work of Phase 3. + JsonNode validated = validateRelatedPages(kb.getId(), json); + log.info("[Wiki] Document analysis done for raw={}: topics={}, concepts={}, related_pages={}", raw.getId(), - json.path("topics").size(), - json.path("key_concepts").size()); - return json.toPrettyString(); + validated.path("topics").size(), + validated.path("key_concepts").size(), + validated.path("related_pages").size()); + return validated.toPrettyString(); } } catch (Exception e) { log.warn("[Wiki] Document analysis failed for raw={}, continuing without: {}", raw.getId(), e.getMessage()); @@ -1621,6 +1631,86 @@ public class WikiProcessingService { return ""; } + /** + * Render the analyze-stage output for inclusion in route / batch-create + * user prompts. The full JSON goes into a fenced code block, and any + * {@code related_pages} array is also surfaced as a plain "recommended + * link targets" section right above it so the LLM doesn't have to + * parse JSON to find the whitelist. + */ + private String buildDocumentMapSection(String documentMap) { + if (documentMap == null || documentMap.isBlank()) return ""; + StringBuilder sb = new StringBuilder(); + try { + JsonNode node = objectMapper.readTree(documentMap); + JsonNode related = node.path("related_pages"); + if (related.isArray() && related.size() > 0) { + sb.append("## 推荐链接到的页面(由分析阶段产出,已通过 slug 白名单校验,可优先使用)\n\n"); + for (JsonNode el : related) { + String slug = el.asText("").trim(); + if (slug.isEmpty()) continue; + sb.append("- [[").append(slug).append("]]\n"); + } + sb.append("\n"); + } + } catch (Exception ignored) { + // documentMap might not be parseable JSON (older runs, partial + // output) — fall through and emit the raw block below. + } + sb.append("## 文档全局概念地图(预分析结果,供页面内容生成参考)\n\n```json\n") + .append(documentMap).append("\n```\n"); + return sb.toString(); + } + + /** + * Drop any {@code related_pages} entry not in the KB's active slug set. + *

+ * The analyze-stage system prompt explicitly tells the LLM that every + * entry must come from the supplied index, but production LLMs are not + * 100% reliable on negative constraints. This server-side validator is + * the contract enforcement: invalid entries are silently dropped (with + * a single warning log per analyze call, batched), so the downstream + * generation prompt never sees an invented slug masquerading as a + * curated whitelist. + */ + private JsonNode validateRelatedPages(Long kbId, JsonNode analysisJson) { + JsonNode relatedNode = analysisJson.path("related_pages"); + if (!relatedNode.isArray() || relatedNode.size() == 0) return analysisJson; + + java.util.Set activeSlugs; + try { + activeSlugs = linkService.lowercaseSlugSet(pageService.listSummaries(kbId)); + } catch (RuntimeException e) { + // Without a slug set, no validation is possible. Drop the whole + // related_pages array — better than passing through unvalidated + // suggestions that could be hallucinated. + log.warn("[Wiki] Cannot validate related_pages for kbId={}, dropping array: {}", + kbId, e.toString()); + ObjectNode result = analysisJson.deepCopy(); + result.putArray("related_pages"); + return result; + } + + com.fasterxml.jackson.databind.node.ArrayNode keptArray = objectMapper.createArrayNode(); + java.util.List dropped = new java.util.ArrayList<>(); + for (JsonNode el : relatedNode) { + String slug = el.asText("").trim(); + if (slug.isEmpty()) continue; + if (activeSlugs.contains(slug.toLowerCase(java.util.Locale.ROOT))) { + keptArray.add(slug); + } else { + dropped.add(slug); + } + } + if (!dropped.isEmpty()) { + log.warn("[Wiki] Analyze dropped {} hallucinated related_pages entries for kbId={}: {}", + dropped.size(), kbId, dropped); + } + ObjectNode result = analysisJson.deepCopy(); + result.set("related_pages", keptArray); + return result; + } + /** * Build the "existing pages" index that the LLM consults when picking * cross-references during page generation / merge / compile. diff --git a/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt index 27509c31..eb0804b7 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt @@ -1,10 +1,11 @@ -你是一个知识库结构分析助手。你的任务是阅读原始材料,输出一份简洁的概念地图,供后续 Wiki 路由阶段参考。 +你是一个知识库结构分析助手。你的任务是阅读原始材料,输出一份简洁的概念地图 + 推荐链接白名单,供后续 Wiki 路由 / 生成阶段参考。 ## 你做什么 1. 识别文档覆盖的**核心主题**(5-15 个) 2. 列出**关键概念**(每个概念给出名称、建议 slug、重要度) 3. 用一段话描述文档的**整体结构**,帮助路由阶段理解各 chunk 的上下文 +4. 从「已有 Wiki 页面索引」中挑出与本文档真实相关的 slug,作为后续生成阶段的**推荐链接白名单** ## 输出格式 @@ -15,15 +16,21 @@ "key_concepts": [ {"name": "概念名称", "slug": "concept-slug", "importance": "high"} ], - "structure_notes": "一段话描述文档整体结构和主要章节" + "structure_notes": "一段话描述文档整体结构和主要章节", + "related_pages": ["existing-slug-a", "existing-slug-b"] } 字段说明: - `topics`:文档覆盖的核心主题列表,字符串数组,5-15 条 - `key_concepts`:关键概念,每条包含 name(人类可读)、slug(URL 安全小写连字符)、importance(high / medium) - `structure_notes`:1-3 句话,描述文档结构,帮助路由阶段在只看到局部 chunk 时理解全局 +- `related_pages`:**从上文「已有 Wiki 页面索引」中**挑选与本文档真实相关的 slug 数组。规则: + - **每个 slug 必须 100% 来自上文索引**,禁止发明新 slug + - 数组长度 0~20;只挑明显相关的页面,不要拼凑数量 + - 排序按相关度从高到低 + - 无相关页面时返回空数组 `[]` -## slug 规范 +## slug 规范(仅用于 `key_concepts` 中的建议 slug) - 多音节中文词按整词分组拼音,不要按字一隔 - ✅ `zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍) @@ -34,4 +41,4 @@ - 输出体积控制在几百到两千字以内 - 不要输出任何页面正文,只输出概念地图 -- 如果文档内容不足(如空白、纯目录),`key_concepts` 可以为空数组 +- 如果文档内容不足(如空白、纯目录),`key_concepts` 和 `related_pages` 都可以为空数组 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt index 127bea58..6ac6a22e 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt @@ -2,6 +2,10 @@ {raw_title} +## 已有 Wiki 页面索引(用于挑选 related_pages 白名单;每行格式 `[[slug]] — 标题 — 摘要`) + +{existing_pages} + ## 文档内容(节选,用于全局结构分析) {text_sample} @@ -9,3 +13,5 @@ --- 请分析以上文档,输出概念地图 JSON。只输出 JSON,不要 markdown 代码块。 +- `related_pages` 中的每个 slug 必须 100% 来自上文「已有 Wiki 页面索引」 +- 没有相关已有页面时 `related_pages` 返回 `[]` diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEnrichmentApplierPhase5Test.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEnrichmentApplierPhase5Test.java new file mode 100644 index 00000000..0201f86a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEnrichmentApplierPhase5Test.java @@ -0,0 +1,129 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.dto.EnrichmentPlan; +import vip.mate.wiki.dto.EnrichmentReplacement; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * RFC §4 Phase 5 additions to {@link WikiEnrichmentApplier}: + *

    + *
  1. do not wrap text that already sits inside a fenced code block
  2. + *
  3. do not wrap text inside an inline {@code `code`} span
  4. + *
  5. when an allowed-slug whitelist is supplied, drop patches that + * target a slug outside the set (instead of failing the whole plan)
  6. + *
+ * These complement the existing PR-5b coverage; new tests live in a separate + * class so the originally-passing assertions stay independent. + */ +class WikiEnrichmentApplierPhase5Test { + + private static EnrichmentPlan plan(EnrichmentReplacement... rs) { + return new EnrichmentPlan(List.of(rs)); + } + + @Test + @DisplayName("does not wrap occurrences inside a fenced code block") + void skipsFencedCode() { + String src = "First, mention kubernetes in prose.\n\n" + + "```\n" + + "Then kubernetes inside a fence stays literal.\n" + + "```\n" + + "Closing kubernetes here too."; + WikiEnrichmentApplier.Result r = WikiEnrichmentApplier.apply(src, + plan(new EnrichmentReplacement("kubernetes", "[[kubernetes]]", 1))); + assertInstanceOf(WikiEnrichmentApplier.Result.Applied.class, r); + String out = r.content(); + // First prose occurrence: wrapped. + assertTrue(out.startsWith("First, mention [[kubernetes]] in prose."), + "first prose occurrence must wrap, got: " + out); + // Inside fence: unchanged. + assertTrue(out.contains("Then kubernetes inside a fence stays literal."), + "fenced occurrence must remain literal, got: " + out); + // Closing prose: NOT wrapped (occurrence=1 only). + assertTrue(out.endsWith("Closing kubernetes here too."), + "second prose occurrence not asked for, must remain literal, got: " + out); + } + + @Test + @DisplayName("wrap targets second prose occurrence when the first sits inside a fence") + void firstWrapTargetsFirstNonCodeOccurrence() { + String src = "```\nThe first kubernetes is in code.\n```\nThen kubernetes in prose."; + WikiEnrichmentApplier.Result r = WikiEnrichmentApplier.apply(src, + plan(new EnrichmentReplacement("kubernetes", "[[kubernetes]]", 1))); + assertInstanceOf(WikiEnrichmentApplier.Result.Applied.class, r); + String out = r.content(); + assertTrue(out.contains("The first kubernetes is in code."), + "fenced occurrence remains literal, got: " + out); + assertTrue(out.endsWith("Then [[kubernetes]] in prose."), + "prose occurrence (the first non-code one) is what gets wrapped, got: " + out); + } + + @Test + @DisplayName("does not wrap inside inline `code` spans") + void skipsInlineCode() { + String src = "Show `kubernetes` as inline code; mention kubernetes in prose."; + WikiEnrichmentApplier.Result r = WikiEnrichmentApplier.apply(src, + plan(new EnrichmentReplacement("kubernetes", "[[kubernetes]]", 1))); + assertInstanceOf(WikiEnrichmentApplier.Result.Applied.class, r); + String out = r.content(); + assertTrue(out.contains("`kubernetes`"), "inline code must stay literal: " + out); + assertTrue(out.contains("mention [[kubernetes]] in prose"), "prose wrapped: " + out); + } + + @Test + @DisplayName("whitelist drops patches whose target slug isn't allowed") + void whitelistDropsUnknownSlugs() { + String src = "Spring AI rocks; Linux too."; + Set allowed = Set.of("spring-ai"); + WikiEnrichmentApplier.Result r = WikiEnrichmentApplier.apply( + src, + plan( + new EnrichmentReplacement("Spring AI", "[[spring-ai|Spring AI]]", 1), + new EnrichmentReplacement("Linux", "[[linux|Linux]]", 1) + ), + WikiEnrichmentApplier.DEFAULT_MAX_REPLACEMENTS, + allowed); + assertInstanceOf(WikiEnrichmentApplier.Result.Applied.class, r); + String out = r.content(); + // spring-ai is in the whitelist → wrapped. + assertTrue(out.contains("[[spring-ai|Spring AI]]"), + "whitelisted slug must wrap, got: " + out); + // linux is NOT in the whitelist → silently dropped, original text intact. + assertTrue(out.contains("Linux too"), "dropped patch must leave text untouched, got: " + out); + assertTrue(!out.contains("[[linux"), "dropped patch must not produce a [[linux...]], got: " + out); + } + + @Test + @DisplayName("whitelist null disables the gate (legacy callers keep working)") + void whitelistNullDisablesGate() { + String src = "Linux is fine."; + WikiEnrichmentApplier.Result r = WikiEnrichmentApplier.apply( + src, + plan(new EnrichmentReplacement("Linux", "[[linux|Linux]]", 1)), + WikiEnrichmentApplier.DEFAULT_MAX_REPLACEMENTS, + null); + assertInstanceOf(WikiEnrichmentApplier.Result.Applied.class, r); + assertEquals("[[linux|Linux]] is fine.", r.content()); + } + + @Test + @DisplayName("whitelist empty drops every patch but keeps original content") + void whitelistEmptyDropsAll() { + String src = "Linux is fine."; + WikiEnrichmentApplier.Result r = WikiEnrichmentApplier.apply( + src, + plan(new EnrichmentReplacement("Linux", "[[linux|Linux]]", 1)), + WikiEnrichmentApplier.DEFAULT_MAX_REPLACEMENTS, + Set.of()); + assertInstanceOf(WikiEnrichmentApplier.Result.Unchanged.class, r); + assertEquals(src, r.content()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingFallbackTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingFallbackTest.java index 1994e8cf..0dce5681 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingFallbackTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingFallbackTest.java @@ -54,16 +54,18 @@ class WikiProcessingFallbackTest { modelProviderService = mock(ModelProviderService.class); healthTracker = mock(ProviderHealthTracker.class); + ObjectMapper om = new ObjectMapper(); service = new WikiProcessingService( mock(WikiKnowledgeBaseService.class), mock(WikiRawMaterialService.class), mock(WikiPageService.class), mock(WikiChunkService.class), mock(WikiEmbeddingService.class), + new WikiLinkService(om), new WikiProperties(), modelConfigService, agentGraphBuilder, - new ObjectMapper(), + om, mock(WikiProgressBus.class), mock(WikiCitationService.class), mock(ApplicationEventPublisher.class)); diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java index f17a8b69..f49591a0 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java @@ -61,9 +61,11 @@ class WikiProcessingServiceLazyTest { progressBus = mock(WikiProgressBus.class); citationService = mock(WikiCitationService.class); + ObjectMapper om = new ObjectMapper(); service = new WikiProcessingService( kbService, rawService, pageService, chunkService, embeddingService, - properties, modelConfigService, agentGraphBuilder, new ObjectMapper(), + new WikiLinkService(om), + properties, modelConfigService, agentGraphBuilder, om, progressBus, citationService, mock(org.springframework.context.ApplicationEventPublisher.class)); }