mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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;
|
package vip.mate.wiki.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
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;
|
||||||
@ -9,9 +11,12 @@ import org.springframework.ai.chat.model.ChatResponse;
|
|||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.wiki.WikiProperties;
|
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.job.WikiModelRoutingService;
|
||||||
import vip.mate.wiki.model.WikiPageEntity;
|
import vip.mate.wiki.model.WikiPageEntity;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@ -19,9 +24,14 @@ import java.util.concurrent.Semaphore;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-031: Lightweight wikilink enrichment service.
|
* RFC-031 + RFC-051 PR-5b: lightweight wikilink enrichment.
|
||||||
* Adds [[slug]] cross-references to page content without modifying
|
* <p>
|
||||||
* the actual text. Corresponds to llm_wiki's enrich-wikilinks.ts.
|
* 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
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -31,12 +41,13 @@ public class WikiLinkEnrichmentService {
|
|||||||
private final WikiPageService pageService;
|
private final WikiPageService pageService;
|
||||||
private final WikiModelRoutingService routingService;
|
private final WikiModelRoutingService routingService;
|
||||||
private final WikiProperties wikiProperties;
|
private final WikiProperties wikiProperties;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
private static final ExecutorService WIKI_EXECUTOR =
|
private static final ExecutorService WIKI_EXECUTOR =
|
||||||
Executors.newVirtualThreadPerTaskExecutor();
|
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) {
|
public void enrichPage(Long pageId, Long modelId) {
|
||||||
WikiPageEntity page = pageService.getById(pageId);
|
WikiPageEntity page = pageService.getById(pageId);
|
||||||
@ -44,18 +55,11 @@ public class WikiLinkEnrichmentService {
|
|||||||
|
|
||||||
String index = buildIndexPrompt(page.getKbId());
|
String index = buildIndexPrompt(page.getKbId());
|
||||||
ChatModel chatModel = routingService.buildChatModel(modelId);
|
ChatModel chatModel = routingService.buildChatModel(modelId);
|
||||||
String enriched = callEnrichLlm(chatModel, page.getContent(), index);
|
applyEnrichment(page, chatModel, index);
|
||||||
|
|
||||||
if (enriched != null
|
|
||||||
&& enriched.length() >= page.getContent().length() * wikiProperties.getWikilinkMinContentRatio()) {
|
|
||||||
page.setContent(enriched);
|
|
||||||
page.setOutgoingLinks(pageService.extractLinksAsJson(enriched));
|
|
||||||
pageService.updateById(page);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
public void enrichAllPages(Long kbId, Long modelId) {
|
||||||
List<WikiPageEntity> pages = pageService.listByKbIdWithContent(kbId);
|
List<WikiPageEntity> pages = pageService.listByKbIdWithContent(kbId);
|
||||||
@ -75,45 +79,121 @@ public class WikiLinkEnrichmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void enrichPageWithIndex(WikiPageEntity page, Long modelId, String index) {
|
private void enrichPageWithIndex(WikiPageEntity page, Long modelId, String index) {
|
||||||
|
if (page == null || page.getContent() == null) return;
|
||||||
ChatModel chatModel = routingService.buildChatModel(modelId);
|
ChatModel chatModel = routingService.buildChatModel(modelId);
|
||||||
String enriched = callEnrichLlm(chatModel, page.getContent(), index);
|
applyEnrichment(page, chatModel, index);
|
||||||
if (enriched != null
|
}
|
||||||
&& enriched.length() >= page.getContent().length() * wikiProperties.getWikilinkMinContentRatio()) {
|
|
||||||
page.setContent(enriched);
|
private void applyEnrichment(WikiPageEntity page, ChatModel chatModel, String index) {
|
||||||
page.setOutgoingLinks(pageService.extractLinksAsJson(enriched));
|
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);
|
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 = """
|
String systemPrompt = """
|
||||||
You are a wiki cross-referencing assistant.
|
You are a wiki cross-referencing assistant.
|
||||||
Your ONLY job: add [[slug]] markers around entity and concept names that appear in the wiki index.
|
Your ONLY job: emit a JSON replacement plan that wraps existing words/phrases
|
||||||
Rules:
|
with [[wikilinks]] from the supplied wiki index.
|
||||||
- Do NOT change any content, rewrite sentences, or add new text.
|
|
||||||
- Do NOT modify YAML frontmatter.
|
Strict output contract — return ONLY this JSON object, nothing else:
|
||||||
- Only wrap existing words/phrases with [[ and ]].
|
{
|
||||||
- Use the exact slug from the wiki index (not the title).
|
"replacements": [
|
||||||
- Return the COMPLETE page text with [[wikilinks]] added.
|
{"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 {
|
try {
|
||||||
ChatResponse response = model.call(
|
ChatResponse response = chatModel.call(
|
||||||
new Prompt(List.of(
|
new Prompt(List.of(
|
||||||
new SystemMessage(systemPrompt),
|
new SystemMessage(systemPrompt),
|
||||||
new UserMessage(userPrompt)
|
new UserMessage(userPrompt)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
return response.getResult().getOutput().getText();
|
String text = response.getResult().getOutput().getText();
|
||||||
|
return parsePlan(text);
|
||||||
} catch (Exception e) {
|
} 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;
|
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) {
|
private String buildIndexPrompt(Long kbId) {
|
||||||
return pageService.listSummaries(kbId).stream()
|
return pageService.listSummaries(kbId).stream()
|
||||||
|
.filter(p -> !"system".equals(p.getPageType()))
|
||||||
.map(p -> p.getSlug() + " → " + p.getTitle())
|
.map(p -> p.getSlug() + " → " + p.getTitle())
|
||||||
.collect(Collectors.joining("\n"));
|
.collect(Collectors.joining("\n"));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user