* 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 "[[|