mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): PR-5b enrichment via replacement plan
This commit is contained in:
parent
726265780e
commit
f4d4e973df
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user