feat(wiki): PR-5b enrichment via replacement plan

This commit is contained in:
matevip 2026-04-25 19:02:31 +08:00
parent 726265780e
commit f4d4e973df
4 changed files with 353 additions and 30 deletions

View File

@ -0,0 +1,22 @@
package vip.mate.wiki.dto;
import java.util.List;
/**
* RFC-051 PR-5b: replacement-plan output of {@code WikiLinkEnrichmentService}.
* <p>
* The LLM is asked to return a list of surgical wrap operations rather than
* a full rewritten page body. Each {@link EnrichmentReplacement} describes
* "wrap the Nth occurrence of {@code original} with {@code replacement}".
* Java validates and applies them, guaranteeing no non-link prose changes.
*/
public record EnrichmentPlan(List<EnrichmentReplacement> replacements) {
public EnrichmentPlan {
if (replacements == null) replacements = List.of();
}
public boolean isEmpty() {
return replacements.isEmpty();
}
}

View File

@ -0,0 +1,23 @@
package vip.mate.wiki.dto;
/**
* RFC-051 PR-5b: one entry of {@link EnrichmentPlan}.
* <p>
* Semantics:
* <ul>
* <li>{@code original} literal text that must already exist in the page.</li>
* <li>{@code replacement} must be a wikilink form, either {@code [[slug]]}
* or {@code [[slug|label]]}. The visible text after wrapping has to
* equal {@code original}, otherwise the replacement is rejected.</li>
* <li>{@code occurrence} 1-based index. {@code 1} means the first
* occurrence in the page; {@code 2} means the second; and so on. Counts
* skip text already inside another wikilink.</li>
* </ul>
* Default occurrence is 1 when the LLM omits the field.
*/
public record EnrichmentReplacement(String original, String replacement, int occurrence) {
public EnrichmentReplacement {
if (occurrence <= 0) occurrence = 1;
}
}

View File

@ -0,0 +1,198 @@
package vip.mate.wiki.service;
import vip.mate.wiki.dto.EnrichmentPlan;
import vip.mate.wiki.dto.EnrichmentReplacement;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* RFC-051 PR-5b: validate and apply a replacement-plan enrichment without
* letting the LLM touch non-link prose.
* <p>
* The applier is intentionally pure: no Spring beans, no I/O. That keeps the
* critical path testable as a plain JUnit class and lets the round-trip
* invariant ("stripped text equals original") get pinned down in one place.
*
* <h2>Invariants</h2>
* <ol>
* <li>Each {@link EnrichmentReplacement#replacement()} must be a wikilink
* in {@code [[slug]]} or {@code [[slug|label]]} form.</li>
* <li>The visible text of the replacement (slug for the bare form, label
* for the alias form) must equal {@link EnrichmentReplacement#original()}
* byte-for-byte.</li>
* <li>After applying every replacement, stripping all wikilinks back to
* their visible text must yield exactly the input page content.</li>
* </ol>
* Failures abort the apply and return {@link Result#rejected(String)}; the
* caller is expected to leave the page untouched on rejection.
*/
public final class WikiEnrichmentApplier {
/** Default cap so a runaway LLM can't propose 1000 wraps per page. */
public static final int DEFAULT_MAX_REPLACEMENTS = 50;
/** Matches {@code [[anything]]} (greedy through to the next ]] but not nested). */
private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\[\\]]+)]]");
/** Matches {@code [[slug]]} or {@code [[slug|label]]} on the replacement string. */
private static final Pattern REPLACEMENT_SHAPE =
Pattern.compile("^\\[\\[([^\\[\\]|]+)(?:\\|([^\\[\\]]+))?]]$");
private WikiEnrichmentApplier() {}
public static Result apply(String originalContent, EnrichmentPlan plan) {
return apply(originalContent, plan, DEFAULT_MAX_REPLACEMENTS);
}
public static Result apply(String originalContent, EnrichmentPlan plan, int maxReplacements) {
if (originalContent == null) return Result.rejected("content is null");
if (plan == null || plan.isEmpty()) {
return Result.unchanged(originalContent);
}
if (plan.replacements().size() > maxReplacements) {
return Result.rejected("too many replacements: "
+ plan.replacements().size() + " > " + maxReplacements);
}
// 1) Per-original index of all candidate positions in the original text,
// skipping positions that fall inside an existing wikilink.
java.util.Map<String, List<Integer>> positionsByOriginal = new java.util.HashMap<>();
boolean[] insideWikilink = computeWikilinkMask(originalContent);
// 2) Plan splices: for each replacement pick positions[occurrence-1].
List<int[]> splices = new ArrayList<>(); // [start, end, replacementIndex]
List<String> replacementTexts = new ArrayList<>();
for (EnrichmentReplacement r : plan.replacements()) {
String original = r.original();
String replacement = r.replacement();
if (original == null || original.isEmpty()) {
return Result.rejected("empty original in replacement");
}
Matcher shape = REPLACEMENT_SHAPE.matcher(replacement == null ? "" : replacement);
if (!shape.matches()) {
return Result.rejected("replacement is not a wikilink form: " + replacement);
}
String slug = shape.group(1).trim();
String label = shape.group(2);
String visible = (label == null) ? slug : label;
if (!visible.equals(original)) {
return Result.rejected("visible text mismatch: replacement='"
+ replacement + "' must render '" + original + "'");
}
List<Integer> positions = positionsByOriginal.computeIfAbsent(original,
o -> findPositions(originalContent, o, insideWikilink));
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.
continue;
}
int start = positions.get(idx);
splices.add(new int[]{start, start + original.length(), replacementTexts.size()});
replacementTexts.add(replacement);
}
if (splices.isEmpty()) {
return Result.unchanged(originalContent);
}
// 3) Apply in reverse offset order so earlier indices don't shift.
splices.sort((a, b) -> Integer.compare(b[0], a[0]));
StringBuilder sb = new StringBuilder(originalContent);
java.util.Set<Integer> claimed = new java.util.HashSet<>();
int applied = 0;
for (int[] sp : splices) {
int start = sp[0];
int end = sp[1];
// Reject overlapping splices defensively.
for (int i = start; i < end; i++) {
if (claimed.contains(i)) {
return Result.rejected("overlapping splice at " + start);
}
}
sb.replace(start, end, replacementTexts.get(sp[2]));
for (int i = start; i < end; i++) claimed.add(i);
applied++;
}
// 4) Round-trip: stripping wikilinks from the result must reproduce input.
String enriched = sb.toString();
if (!stripWikilinks(enriched).equals(stripWikilinks(originalContent))) {
return Result.rejected("round-trip invariant violated");
}
return Result.applied(enriched, applied);
}
/**
* Strip every {@code [[...]]} down to its visible text:
* <ul>
* <li>{@code [[slug]]} {@code slug}</li>
* <li>{@code [[slug|label]]} {@code label}</li>
* </ul>
* Used both for round-trip validation and for diff testing.
*/
public static String stripWikilinks(String content) {
if (content == null) return "";
Matcher m = WIKILINK.matcher(content);
StringBuilder out = new StringBuilder(content.length());
int last = 0;
while (m.find()) {
out.append(content, last, m.start());
String inner = m.group(1);
int pipe = inner.indexOf('|');
String visible = pipe >= 0 ? inner.substring(pipe + 1) : inner;
out.append(visible);
last = m.end();
}
out.append(content, last, content.length());
return out.toString();
}
private static boolean[] computeWikilinkMask(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;
}
return mask;
}
private static List<Integer> findPositions(String content, String needle, boolean[] insideWikilink) {
List<Integer> out = new ArrayList<>();
if (needle.isEmpty()) return out;
int from = 0;
while (from <= content.length() - needle.length()) {
int p = content.indexOf(needle, from);
if (p < 0) break;
// Skip if any byte of the match falls inside an existing wikilink.
boolean overlap = false;
for (int i = p; i < p + needle.length(); i++) {
if (insideWikilink[i]) { overlap = true; break; }
}
if (!overlap) out.add(p);
from = p + 1;
}
return out;
}
/**
* Outcome of {@link #apply(String, EnrichmentPlan)}.
*/
public sealed interface Result permits Result.Applied, Result.Unchanged, Result.Rejected {
String content();
static Result applied(String content, int count) { return new Applied(content, count); }
static Result unchanged(String content) { return new Unchanged(content); }
static Result rejected(String reason) { return new Rejected(reason); }
record Applied(String content, int replacementCount) implements Result {}
record Unchanged(String content) implements Result {}
record Rejected(String reason) implements Result {
@Override public String content() { return null; }
}
}
}

View File

@ -1,5 +1,7 @@
package vip.mate.wiki.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
@ -9,9 +11,12 @@ import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Service;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.dto.EnrichmentPlan;
import vip.mate.wiki.dto.EnrichmentReplacement;
import vip.mate.wiki.job.WikiModelRoutingService;
import vip.mate.wiki.model.WikiPageEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@ -19,9 +24,14 @@ import java.util.concurrent.Semaphore;
import java.util.stream.Collectors;
/**
* RFC-031: Lightweight wikilink enrichment service.
* Adds [[slug]] cross-references to page content without modifying
* the actual text. Corresponds to llm_wiki's enrich-wikilinks.ts.
* RFC-031 + RFC-051 PR-5b: lightweight wikilink enrichment.
* <p>
* Earlier versions asked the LLM for a fully rewritten page and trusted the
* response if it was "long enough" which let weak models silently drop
* paragraphs, translate prose, or rephrase claims while pretending to only
* add brackets. This rewrite switches to a replacement plan: the LLM
* proposes wraps, Java applies them surgically, and a round-trip check
* guarantees the non-link prose is unchanged byte-for-byte.
*/
@Slf4j
@Service
@ -31,12 +41,13 @@ public class WikiLinkEnrichmentService {
private final WikiPageService pageService;
private final WikiModelRoutingService routingService;
private final WikiProperties wikiProperties;
private final ObjectMapper objectMapper;
private static final ExecutorService WIKI_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor();
/**
* Enrich a single page with [[wikilinks]].
* Enrich a single page with [[wikilinks]] using a replacement plan.
*/
public void enrichPage(Long pageId, Long modelId) {
WikiPageEntity page = pageService.getById(pageId);
@ -44,18 +55,11 @@ public class WikiLinkEnrichmentService {
String index = buildIndexPrompt(page.getKbId());
ChatModel chatModel = routingService.buildChatModel(modelId);
String enriched = callEnrichLlm(chatModel, page.getContent(), index);
if (enriched != null
&& enriched.length() >= page.getContent().length() * wikiProperties.getWikilinkMinContentRatio()) {
page.setContent(enriched);
page.setOutgoingLinks(pageService.extractLinksAsJson(enriched));
pageService.updateById(page);
}
applyEnrichment(page, chatModel, index);
}
/**
* Batch-enrich all pages in a KB (e.g. after initial ingest).
* Batch-enrich all pages in a KB.
*/
public void enrichAllPages(Long kbId, Long modelId) {
List<WikiPageEntity> pages = pageService.listByKbIdWithContent(kbId);
@ -75,45 +79,121 @@ public class WikiLinkEnrichmentService {
}
private void enrichPageWithIndex(WikiPageEntity page, Long modelId, String index) {
if (page == null || page.getContent() == null) return;
ChatModel chatModel = routingService.buildChatModel(modelId);
String enriched = callEnrichLlm(chatModel, page.getContent(), index);
if (enriched != null
&& enriched.length() >= page.getContent().length() * wikiProperties.getWikilinkMinContentRatio()) {
page.setContent(enriched);
page.setOutgoingLinks(pageService.extractLinksAsJson(enriched));
applyEnrichment(page, chatModel, index);
}
private void applyEnrichment(WikiPageEntity page, ChatModel chatModel, String index) {
EnrichmentPlan plan = requestPlan(chatModel, page.getContent(), index, page.getSlug());
if (plan == null || plan.isEmpty()) {
return; // LLM had nothing to add or call failed; leave page alone.
}
WikiEnrichmentApplier.Result result = WikiEnrichmentApplier.apply(page.getContent(), plan);
if (result instanceof WikiEnrichmentApplier.Result.Rejected rejected) {
log.warn("[WikiEnrich] Plan rejected for slug={}: {}", page.getSlug(), rejected.reason());
return;
}
if (result instanceof WikiEnrichmentApplier.Result.Unchanged) {
return;
}
if (result instanceof WikiEnrichmentApplier.Result.Applied applied) {
page.setContent(applied.content());
page.setOutgoingLinks(pageService.extractLinksAsJson(applied.content()));
pageService.updateById(page);
log.info("[WikiEnrich] Applied {} replacements on slug={}", applied.replacementCount(), page.getSlug());
}
}
private String callEnrichLlm(ChatModel model, String content, String index) {
private EnrichmentPlan requestPlan(ChatModel chatModel, String content, String index, String slug) {
String systemPrompt = """
You are a wiki cross-referencing assistant.
Your ONLY job: add [[slug]] markers around entity and concept names that appear in the wiki index.
Rules:
- Do NOT change any content, rewrite sentences, or add new text.
- Do NOT modify YAML frontmatter.
- Only wrap existing words/phrases with [[ and ]].
- Use the exact slug from the wiki index (not the title).
- Return the COMPLETE page text with [[wikilinks]] added.
Your ONLY job: emit a JSON replacement plan that wraps existing words/phrases
with [[wikilinks]] from the supplied wiki index.
Strict output contract return ONLY this JSON object, nothing else:
{
"replacements": [
{"original": "<exact substring of the page>",
"replacement": "[[<slug>]]" or "[[<slug>|<label>]]",
"occurrence": <1-based int>}
]
}
Rules replacements that violate any rule will be rejected by the server:
- Do NOT rewrite, translate, summarize, or add prose. You are only allowed to wrap.
- The "replacement" string must contain exactly ONE wikilink and nothing else.
- The visible text of the wikilink (the slug, or the label after |) must equal
"original" byte-for-byte. Casing, punctuation, and whitespace must match.
- Use slugs from the supplied wiki index. Do not invent new slugs.
- "occurrence" counts only matches outside any existing [[...]]. Default 1.
- Skip occurrences that are already inside an existing wikilink.
- It is fine to return an empty replacements array if nothing fits.
""";
String userPrompt = "Wiki Index (slug → title):\n" + index + "\n\nPage content:\n" + content;
String userPrompt = "Wiki Index (slug → title):\n" + index
+ "\n\nPage content (slug=" + slug + "):\n" + content;
try {
ChatResponse response = model.call(
ChatResponse response = chatModel.call(
new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)
))
);
return response.getResult().getOutput().getText();
String text = response.getResult().getOutput().getText();
return parsePlan(text);
} catch (Exception e) {
log.warn("[WikiEnrich] Failed to enrich page: {}", e.getMessage());
log.warn("[WikiEnrich] Plan request failed for slug={}: {}", slug, e.getMessage());
return null;
}
}
EnrichmentPlan parsePlan(String text) {
if (text == null || text.isBlank()) return null;
// Tolerate fenced code blocks and stray prose around the JSON object.
String trimmed = text.trim();
if (trimmed.startsWith("```")) {
int firstNl = trimmed.indexOf('\n');
int lastFence = trimmed.lastIndexOf("```");
if (firstNl > 0 && lastFence > firstNl) {
trimmed = trimmed.substring(firstNl + 1, lastFence).trim();
}
}
try {
JsonNode root = objectMapper.readTree(trimmed);
return planFromJson(root);
} catch (Exception ignored) {
int s = trimmed.indexOf('{');
int e = trimmed.lastIndexOf('}');
if (s >= 0 && e > s) {
try {
return planFromJson(objectMapper.readTree(trimmed.substring(s, e + 1)));
} catch (Exception ignored2) {
return null;
}
}
return null;
}
}
private EnrichmentPlan planFromJson(JsonNode root) {
if (root == null) return null;
JsonNode arr = root.path("replacements");
if (!arr.isArray()) return new EnrichmentPlan(List.of());
List<EnrichmentReplacement> out = new ArrayList<>(arr.size());
for (JsonNode node : arr) {
String original = node.path("original").asText("");
String replacement = node.path("replacement").asText("");
int occurrence = node.path("occurrence").asInt(1);
if (original.isEmpty() || replacement.isEmpty()) continue;
out.add(new EnrichmentReplacement(original, replacement, occurrence));
}
return new EnrichmentPlan(out);
}
private String buildIndexPrompt(Long kbId) {
return pageService.listSummaries(kbId).stream()
.filter(p -> !"system".equals(p.getPageType()))
.map(p -> p.getSlug() + "" + p.getTitle())
.collect(Collectors.joining("\n"));
}