mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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 =
|
||||
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
|
||||
// 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,
|
||||
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;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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` 都可以为空数组
|
||||
|
||||
@ -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` 返回 `[]`
|
||||
|
||||
@ -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);
|
||||
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));
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user