feat(wiki): analyze-stage slug whitelist + code-aware enrich applier

This commit is contained in:
matevip 2026-05-28 08:17:27 +08:00
parent 16eac232c4
commit a71c49d374
7 changed files with 309 additions and 25 deletions

View File

@ -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<String> 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<String, List<Integer>> positionsByOriginal = new java.util.HashMap<>();
boolean[] insideWikilink = computeWikilinkMask(originalContent);
boolean[] skipMask = computeSkipMask(originalContent);
// 2) Plan splices: for each replacement pick positions[occurrence-1].
List<int[]> 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
// analyzegenerate path without aborting the rest of the plan.
if (allowedSlugsLower != null
&& !allowedSlugsLower.contains(slug.toLowerCase(java.util.Locale.ROOT))) {
continue;
}
List<Integer> 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<Integer> findPositions(String content, String needle, boolean[] insideWikilink) {
List<Integer> out = new ArrayList<>();
if (needle.isEmpty()) return out;

View File

@ -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.
* <p>
* 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<String> 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<String> 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.

View File

@ -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人类可读、slugURL 安全小写连字符、importancehigh / 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` 都可以为空数组

View File

@ -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` 返回 `[]`

View File

@ -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}:
* <ol>
* <li>do not wrap text that already sits inside a fenced code block</li>
* <li>do not wrap text inside an inline {@code `code`} span</li>
* <li>when an allowed-slug whitelist is supplied, drop patches that
* target a slug outside the set (instead of failing the whole plan)</li>
* </ol>
* 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<String> 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());
}
}

View File

@ -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));

View File

@ -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));
}