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; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; 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.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * RFC-031 + RFC-051 PR-5b: lightweight wikilink enrichment. *

* 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 @RequiredArgsConstructor 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(); /** Matches existing wikilinks. Group 1 is the inner text (slug or slug|label). */ private static final Pattern WIKILINK_USAGE = Pattern.compile("\\[\\[([^\\[\\]]+)]]"); /** * Enrich a single page with [[wikilinks]] using a replacement plan. */ public void enrichPage(Long pageId, Long modelId) { WikiPageEntity page = pageService.getById(pageId); if (page == null || page.getContent() == null) return; String index = buildIndexPrompt(page.getKbId()); ChatModel chatModel = routingService.buildChatModel(modelId); applyEnrichment(page, chatModel, index); } /** * Batch-enrich all pages in a KB. */ public void enrichAllPages(Long kbId, Long modelId) { List pages = pageService.listByKbIdWithContent(kbId); String index = buildIndexPrompt(kbId); Semaphore sem = new Semaphore(wikiProperties.getMaxParallelPhaseBPages()); for (WikiPageEntity page : pages) { sem.acquireUninterruptibly(); WIKI_EXECUTOR.submit(() -> { try { enrichPageWithIndex(page, modelId, index); } finally { sem.release(); } }); } } private void enrichPageWithIndex(WikiPageEntity page, Long modelId, String index) { if (page == null || page.getContent() == null) return; ChatModel chatModel = routingService.buildChatModel(modelId); applyEnrichment(page, chatModel, index); } private void applyEnrichment(WikiPageEntity page, ChatModel chatModel, String index) { // RFC-051 follow-up: tell the LLM how many times each slug is already linked // in this page so it doesn't waste effort re-proposing positions that are // already wrapped (which the applier would skip anyway, just more cheaply). String indexWithUsage = decorateIndexWithUsage(index, page.getContent()); EnrichmentPlan plan = requestPlan(chatModel, page.getContent(), indexWithUsage, 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 EnrichmentPlan requestPlan(ChatModel chatModel, String content, String index, String slug) { String systemPrompt = """ You are a wiki cross-referencing assistant. 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": "", "replacement": "[[]]" or "[[|