mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(wiki): analyze-stage slug whitelist + code-aware enrich applier
This commit is contained in:
parent
16eac232c4
commit
a71c49d374
@ -41,13 +41,39 @@ public final class WikiEnrichmentApplier {
|
|||||||
private static final Pattern REPLACEMENT_SHAPE =
|
private static final Pattern REPLACEMENT_SHAPE =
|
||||||
Pattern.compile("^\\[\\[([^\\[\\]|]+)(?:\\|([^\\[\\]]+))?]]$");
|
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() {}
|
private WikiEnrichmentApplier() {}
|
||||||
|
|
||||||
public static Result apply(String originalContent, EnrichmentPlan plan) {
|
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) {
|
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 (originalContent == null) return Result.rejected("content is null");
|
||||||
if (plan == null || plan.isEmpty()) {
|
if (plan == null || plan.isEmpty()) {
|
||||||
return Result.unchanged(originalContent);
|
return Result.unchanged(originalContent);
|
||||||
@ -58,9 +84,12 @@ public final class WikiEnrichmentApplier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1) Per-original index of all candidate positions in the original text,
|
// 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<>();
|
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].
|
// 2) Plan splices: for each replacement pick positions[occurrence-1].
|
||||||
List<int[]> splices = new ArrayList<>(); // [start, end, replacementIndex]
|
List<int[]> splices = new ArrayList<>(); // [start, end, replacementIndex]
|
||||||
@ -82,9 +111,17 @@ public final class WikiEnrichmentApplier {
|
|||||||
return Result.rejected("visible text mismatch: replacement='"
|
return Result.rejected("visible text mismatch: replacement='"
|
||||||
+ replacement + "' must render '" + original + "'");
|
+ 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<Integer> positions = positionsByOriginal.computeIfAbsent(original,
|
List<Integer> positions = positionsByOriginal.computeIfAbsent(original,
|
||||||
o -> findPositions(originalContent, o, insideWikilink));
|
o -> findPositions(originalContent, o, skipMask));
|
||||||
int idx = r.occurrence() - 1;
|
int idx = r.occurrence() - 1;
|
||||||
if (idx < 0 || idx >= positions.size()) {
|
if (idx < 0 || idx >= positions.size()) {
|
||||||
// Skip silently — the page may have been re-edited since the LLM saw it.
|
// 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();
|
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()];
|
boolean[] mask = new boolean[content.length()];
|
||||||
Matcher m = WIKILINK.matcher(content);
|
markPattern(mask, content, WIKILINK);
|
||||||
while (m.find()) {
|
markPattern(mask, content, FENCED_CODE);
|
||||||
for (int i = m.start(); i < m.end(); i++) mask[i] = true;
|
markPattern(mask, content, INLINE_CODE);
|
||||||
}
|
|
||||||
return mask;
|
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) {
|
private static List<Integer> findPositions(String content, String needle, boolean[] insideWikilink) {
|
||||||
List<Integer> out = new ArrayList<>();
|
List<Integer> out = new ArrayList<>();
|
||||||
if (needle.isEmpty()) return out;
|
if (needle.isEmpty()) return out;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package vip.mate.wiki.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.messages.SystemMessage;
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
@ -52,6 +53,7 @@ public class WikiProcessingService {
|
|||||||
private final WikiPageService pageService;
|
private final WikiPageService pageService;
|
||||||
private final WikiChunkService chunkService;
|
private final WikiChunkService chunkService;
|
||||||
private final WikiEmbeddingService embeddingService;
|
private final WikiEmbeddingService embeddingService;
|
||||||
|
private final WikiLinkService linkService;
|
||||||
private final WikiProperties properties;
|
private final WikiProperties properties;
|
||||||
private final ModelConfigService modelConfigService;
|
private final ModelConfigService modelConfigService;
|
||||||
private final AgentGraphBuilder agentGraphBuilder;
|
private final AgentGraphBuilder agentGraphBuilder;
|
||||||
@ -801,9 +803,7 @@ public class WikiProcessingService {
|
|||||||
|
|
||||||
String routeSystem = PromptLoader.loadPrompt("wiki/route-system");
|
String routeSystem = PromptLoader.loadPrompt("wiki/route-system");
|
||||||
String routeUserTemplate = PromptLoader.loadPrompt("wiki/route-user");
|
String routeUserTemplate = PromptLoader.loadPrompt("wiki/route-user");
|
||||||
String documentMapSection = (documentMap != null && !documentMap.isBlank())
|
String documentMapSection = buildDocumentMapSection(documentMap);
|
||||||
? "## 文档全局概念地图(预分析结果,供路由参考)\n\n```json\n" + documentMap + "\n```\n"
|
|
||||||
: "";
|
|
||||||
String routeUser = routeUserTemplate
|
String routeUser = routeUserTemplate
|
||||||
.replace("{config}", configContent)
|
.replace("{config}", configContent)
|
||||||
.replace("{document_map_section}", documentMapSection)
|
.replace("{document_map_section}", documentMapSection)
|
||||||
@ -1066,9 +1066,7 @@ public class WikiProcessingService {
|
|||||||
|
|
||||||
String batchSystem = PromptLoader.loadPrompt("wiki/batch-create-system");
|
String batchSystem = PromptLoader.loadPrompt("wiki/batch-create-system");
|
||||||
String batchUserTemplate = PromptLoader.loadPrompt("wiki/batch-create-user");
|
String batchUserTemplate = PromptLoader.loadPrompt("wiki/batch-create-user");
|
||||||
String docMapSection = (documentMap != null && !documentMap.isBlank())
|
String docMapSection = buildDocumentMapSection(documentMap);
|
||||||
? "## 文档全局概念地图(预分析结果,供页面内容生成参考)\n\n```json\n" + documentMap + "\n```\n"
|
|
||||||
: "";
|
|
||||||
String batchUser = batchUserTemplate
|
String batchUser = batchUserTemplate
|
||||||
.replace("{config}", configContent)
|
.replace("{config}", configContent)
|
||||||
.replace("{document_map_section}", docMapSection)
|
.replace("{document_map_section}", docMapSection)
|
||||||
@ -1594,10 +1592,16 @@ public class WikiProcessingService {
|
|||||||
String sample = textContent.length() > sampleChars
|
String sample = textContent.length() > sampleChars
|
||||||
? textContent.substring(0, sampleChars) + "\n...[文档较长,以上为节选]"
|
? textContent.substring(0, sampleChars) + "\n...[文档较长,以上为节选]"
|
||||||
: textContent;
|
: 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 system = PromptLoader.loadPrompt("wiki/analyze-system");
|
||||||
String userTemplate = PromptLoader.loadPrompt("wiki/analyze-user");
|
String userTemplate = PromptLoader.loadPrompt("wiki/analyze-user");
|
||||||
String user = userTemplate
|
String user = userTemplate
|
||||||
.replace("{raw_title}", raw.getTitle())
|
.replace("{raw_title}", raw.getTitle())
|
||||||
|
.replace("{existing_pages}", existingPagesIndex)
|
||||||
.replace("{text_sample}", sample);
|
.replace("{text_sample}", sample);
|
||||||
Prompt prompt = new Prompt(List.of(
|
Prompt prompt = new Prompt(List.of(
|
||||||
new SystemMessage(system),
|
new SystemMessage(system),
|
||||||
@ -1609,11 +1613,17 @@ public class WikiProcessingService {
|
|||||||
kb.getId(), vip.mate.wiki.job.WikiJobStep.ROUTE);
|
kb.getId(), vip.mate.wiki.job.WikiJobStep.ROUTE);
|
||||||
JsonNode json = parseJsonResponse(response);
|
JsonNode json = parseJsonResponse(response);
|
||||||
if (json != null) {
|
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(),
|
raw.getId(),
|
||||||
json.path("topics").size(),
|
validated.path("topics").size(),
|
||||||
json.path("key_concepts").size());
|
validated.path("key_concepts").size(),
|
||||||
return json.toPrettyString();
|
validated.path("related_pages").size());
|
||||||
|
return validated.toPrettyString();
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[Wiki] Document analysis failed for raw={}, continuing without: {}", raw.getId(), e.getMessage());
|
log.warn("[Wiki] Document analysis failed for raw={}, continuing without: {}", raw.getId(), e.getMessage());
|
||||||
@ -1621,6 +1631,86 @@ public class WikiProcessingService {
|
|||||||
return "";
|
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
|
* Build the "existing pages" index that the LLM consults when picking
|
||||||
* cross-references during page generation / merge / compile.
|
* cross-references during page generation / merge / compile.
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
你是一个知识库结构分析助手。你的任务是阅读原始材料,输出一份简洁的概念地图,供后续 Wiki 路由阶段参考。
|
你是一个知识库结构分析助手。你的任务是阅读原始材料,输出一份简洁的概念地图 + 推荐链接白名单,供后续 Wiki 路由 / 生成阶段参考。
|
||||||
|
|
||||||
## 你做什么
|
## 你做什么
|
||||||
|
|
||||||
1. 识别文档覆盖的**核心主题**(5-15 个)
|
1. 识别文档覆盖的**核心主题**(5-15 个)
|
||||||
2. 列出**关键概念**(每个概念给出名称、建议 slug、重要度)
|
2. 列出**关键概念**(每个概念给出名称、建议 slug、重要度)
|
||||||
3. 用一段话描述文档的**整体结构**,帮助路由阶段理解各 chunk 的上下文
|
3. 用一段话描述文档的**整体结构**,帮助路由阶段理解各 chunk 的上下文
|
||||||
|
4. 从「已有 Wiki 页面索引」中挑出与本文档真实相关的 slug,作为后续生成阶段的**推荐链接白名单**
|
||||||
|
|
||||||
## 输出格式
|
## 输出格式
|
||||||
|
|
||||||
@ -15,15 +16,21 @@
|
|||||||
"key_concepts": [
|
"key_concepts": [
|
||||||
{"name": "概念名称", "slug": "concept-slug", "importance": "high"}
|
{"name": "概念名称", "slug": "concept-slug", "importance": "high"}
|
||||||
],
|
],
|
||||||
"structure_notes": "一段话描述文档整体结构和主要章节"
|
"structure_notes": "一段话描述文档整体结构和主要章节",
|
||||||
|
"related_pages": ["existing-slug-a", "existing-slug-b"]
|
||||||
}
|
}
|
||||||
|
|
||||||
字段说明:
|
字段说明:
|
||||||
- `topics`:文档覆盖的核心主题列表,字符串数组,5-15 条
|
- `topics`:文档覆盖的核心主题列表,字符串数组,5-15 条
|
||||||
- `key_concepts`:关键概念,每条包含 name(人类可读)、slug(URL 安全小写连字符)、importance(high / medium)
|
- `key_concepts`:关键概念,每条包含 name(人类可读)、slug(URL 安全小写连字符)、importance(high / medium)
|
||||||
- `structure_notes`:1-3 句话,描述文档结构,帮助路由阶段在只看到局部 chunk 时理解全局
|
- `structure_notes`:1-3 句话,描述文档结构,帮助路由阶段在只看到局部 chunk 时理解全局
|
||||||
|
- `related_pages`:**从上文「已有 Wiki 页面索引」中**挑选与本文档真实相关的 slug 数组。规则:
|
||||||
|
- **每个 slug 必须 100% 来自上文索引**,禁止发明新 slug
|
||||||
|
- 数组长度 0~20;只挑明显相关的页面,不要拼凑数量
|
||||||
|
- 排序按相关度从高到低
|
||||||
|
- 无相关页面时返回空数组 `[]`
|
||||||
|
|
||||||
## slug 规范
|
## slug 规范(仅用于 `key_concepts` 中的建议 slug)
|
||||||
|
|
||||||
- 多音节中文词按整词分组拼音,不要按字一隔
|
- 多音节中文词按整词分组拼音,不要按字一隔
|
||||||
- ✅ `zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍)
|
- ✅ `zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍)
|
||||||
@ -34,4 +41,4 @@
|
|||||||
|
|
||||||
- 输出体积控制在几百到两千字以内
|
- 输出体积控制在几百到两千字以内
|
||||||
- 不要输出任何页面正文,只输出概念地图
|
- 不要输出任何页面正文,只输出概念地图
|
||||||
- 如果文档内容不足(如空白、纯目录),`key_concepts` 可以为空数组
|
- 如果文档内容不足(如空白、纯目录),`key_concepts` 和 `related_pages` 都可以为空数组
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
{raw_title}
|
{raw_title}
|
||||||
|
|
||||||
|
## 已有 Wiki 页面索引(用于挑选 related_pages 白名单;每行格式 `[[slug]] — 标题 — 摘要`)
|
||||||
|
|
||||||
|
{existing_pages}
|
||||||
|
|
||||||
## 文档内容(节选,用于全局结构分析)
|
## 文档内容(节选,用于全局结构分析)
|
||||||
|
|
||||||
{text_sample}
|
{text_sample}
|
||||||
@ -9,3 +13,5 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
请分析以上文档,输出概念地图 JSON。只输出 JSON,不要 markdown 代码块。
|
请分析以上文档,输出概念地图 JSON。只输出 JSON,不要 markdown 代码块。
|
||||||
|
- `related_pages` 中的每个 slug 必须 100% 来自上文「已有 Wiki 页面索引」
|
||||||
|
- 没有相关已有页面时 `related_pages` 返回 `[]`
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -54,16 +54,18 @@ class WikiProcessingFallbackTest {
|
|||||||
modelProviderService = mock(ModelProviderService.class);
|
modelProviderService = mock(ModelProviderService.class);
|
||||||
healthTracker = mock(ProviderHealthTracker.class);
|
healthTracker = mock(ProviderHealthTracker.class);
|
||||||
|
|
||||||
|
ObjectMapper om = new ObjectMapper();
|
||||||
service = new WikiProcessingService(
|
service = new WikiProcessingService(
|
||||||
mock(WikiKnowledgeBaseService.class),
|
mock(WikiKnowledgeBaseService.class),
|
||||||
mock(WikiRawMaterialService.class),
|
mock(WikiRawMaterialService.class),
|
||||||
mock(WikiPageService.class),
|
mock(WikiPageService.class),
|
||||||
mock(WikiChunkService.class),
|
mock(WikiChunkService.class),
|
||||||
mock(WikiEmbeddingService.class),
|
mock(WikiEmbeddingService.class),
|
||||||
|
new WikiLinkService(om),
|
||||||
new WikiProperties(),
|
new WikiProperties(),
|
||||||
modelConfigService,
|
modelConfigService,
|
||||||
agentGraphBuilder,
|
agentGraphBuilder,
|
||||||
new ObjectMapper(),
|
om,
|
||||||
mock(WikiProgressBus.class),
|
mock(WikiProgressBus.class),
|
||||||
mock(WikiCitationService.class),
|
mock(WikiCitationService.class),
|
||||||
mock(ApplicationEventPublisher.class));
|
mock(ApplicationEventPublisher.class));
|
||||||
|
|||||||
@ -61,9 +61,11 @@ class WikiProcessingServiceLazyTest {
|
|||||||
progressBus = mock(WikiProgressBus.class);
|
progressBus = mock(WikiProgressBus.class);
|
||||||
citationService = mock(WikiCitationService.class);
|
citationService = mock(WikiCitationService.class);
|
||||||
|
|
||||||
|
ObjectMapper om = new ObjectMapper();
|
||||||
service = new WikiProcessingService(
|
service = new WikiProcessingService(
|
||||||
kbService, rawService, pageService, chunkService, embeddingService,
|
kbService, rawService, pageService, chunkService, embeddingService,
|
||||||
properties, modelConfigService, agentGraphBuilder, new ObjectMapper(),
|
new WikiLinkService(om),
|
||||||
|
properties, modelConfigService, agentGraphBuilder, om,
|
||||||
progressBus, citationService,
|
progressBus, citationService,
|
||||||
mock(org.springframework.context.ApplicationEventPublisher.class));
|
mock(org.springframework.context.ApplicationEventPublisher.class));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user