diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java
index 3f48ceba..e6918577 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java
@@ -178,4 +178,26 @@ public class WikiProperties {
* and weaker models may need the fallback).
*/
private boolean useStructuredRoute = false;
+
+ /**
+ * RFC-051 follow-up: how many pages the enrich service packs into a single
+ * LLM call. {@code 1} (default) reproduces the legacy behavior of one
+ * call per page. {@code 5}–{@code 10} is reasonable for most chat models;
+ * weaker locally-served models may need to stay at 1.
+ *
+ * Larger batches reduce LLM cost roughly proportional to the batch size,
+ * but each batch's prompt grows linearly with the included page bodies,
+ * so very long pages still benefit from single-page mode. Pages exceeding
+ * {@link #enrichBatchPerPageMaxChars} are excluded from the batch and
+ * enriched individually.
+ */
+ private int enrichBatchSize = 1;
+
+ /**
+ * RFC-051 follow-up: per-page content cap when packing pages into an
+ * enrich batch. Pages whose body exceeds this size fall through to a
+ * single-page enrich call so the batch prompt stays bounded. The cap
+ * applies only to the prompt; the applier always sees full content.
+ */
+ private int enrichBatchPerPageMaxChars = 3000;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentBatchPlan.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentBatchPlan.java
new file mode 100644
index 00000000..cf5c3d51
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentBatchPlan.java
@@ -0,0 +1,23 @@
+package vip.mate.wiki.dto;
+
+import java.util.Map;
+
+/**
+ * RFC-051 follow-up: multi-page enrich response.
+ *
+ * Maps page slug → {@link EnrichmentPlan}. Slugs missing from the map are
+ * treated as "LLM proposed nothing for that page" and skipped silently.
+ * Each plan still goes through the full {@code WikiEnrichmentApplier}
+ * round-trip validation, so a malformed plan for one page can't corrupt the
+ * others — that page is just rejected and its peers move on.
+ */
+public record EnrichmentBatchPlan(Map plans) {
+
+ public EnrichmentBatchPlan {
+ if (plans == null) plans = Map.of();
+ }
+
+ public boolean isEmpty() {
+ return plans.isEmpty();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java
index ba9a3642..1b7daf56 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java
@@ -11,6 +11,7 @@ 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.EnrichmentBatchPlan;
import vip.mate.wiki.dto.EnrichmentPlan;
import vip.mate.wiki.dto.EnrichmentReplacement;
import vip.mate.wiki.job.WikiModelRoutingService;
@@ -67,21 +68,75 @@ public class WikiLinkEnrichmentService {
/**
* Batch-enrich all pages in a KB.
+ *
+ * Internally honors {@code mate.wiki.enrich-batch-size}: when {@code 1}
+ * (the default), pages are enriched one-per-LLM-call as before. When
+ * larger, pages are grouped into batches and the LLM is asked for a
+ * multi-page replacement plan. Pages whose body exceeds
+ * {@code enrich-batch-per-page-max-chars} fall through to single-page
+ * mode so the batch prompt stays bounded.
*/
public void enrichAllPages(Long kbId, Long modelId) {
List pages = pageService.listByKbIdWithContent(kbId);
+ if (pages.isEmpty()) return;
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();
- }
- });
+ int batchSize = Math.max(1, wikiProperties.getEnrichBatchSize());
+ int perPageCap = Math.max(500, wikiProperties.getEnrichBatchPerPageMaxChars());
+
+ if (batchSize <= 1) {
+ // Legacy per-page parallel path.
+ Semaphore sem = new Semaphore(wikiProperties.getMaxParallelPhaseBPages());
+ for (WikiPageEntity page : pages) {
+ sem.acquireUninterruptibly();
+ WIKI_EXECUTOR.submit(() -> {
+ try {
+ enrichPageWithIndex(page, modelId, index);
+ } finally {
+ sem.release();
+ }
+ });
+ }
+ return;
+ }
+
+ // Split: oversized pages go solo so the batch prompt stays bounded.
+ List batchable = new ArrayList<>(pages.size());
+ List oversized = new ArrayList<>();
+ for (WikiPageEntity p : pages) {
+ if (p.getContent() != null && p.getContent().length() > perPageCap) {
+ oversized.add(p);
+ } else if (p.getContent() != null) {
+ batchable.add(p);
+ }
+ }
+
+ ChatModel chatModel = routingService.buildChatModel(modelId);
+
+ // Process batches sequentially — typical batch is ~5 pages, single LLM call,
+ // already a fraction of the cost of the previous one-per-page parallelism.
+ for (int i = 0; i < batchable.size(); i += batchSize) {
+ List batch = batchable.subList(i, Math.min(i + batchSize, batchable.size()));
+ try {
+ applyBatchEnrichment(batch, chatModel, index, perPageCap);
+ } catch (Exception e) {
+ log.warn("[WikiEnrich] Batch enrichment failed (size={}): {}", batch.size(), e.getMessage());
+ }
+ }
+
+ // Oversized pages fall back to single-page enrich, in parallel.
+ if (!oversized.isEmpty()) {
+ Semaphore sem = new Semaphore(wikiProperties.getMaxParallelPhaseBPages());
+ for (WikiPageEntity page : oversized) {
+ sem.acquireUninterruptibly();
+ WIKI_EXECUTOR.submit(() -> {
+ try {
+ enrichPageWithIndex(page, modelId, index);
+ } finally {
+ sem.release();
+ }
+ });
+ }
}
}
@@ -91,6 +146,34 @@ public class WikiLinkEnrichmentService {
applyEnrichment(page, chatModel, index);
}
+ /**
+ * Run one batch LLM call covering N pages and apply each per-slug plan
+ * independently. A malformed plan for one slug doesn't affect peers.
+ */
+ private void applyBatchEnrichment(List batch, ChatModel chatModel,
+ String index, int perPageCap) {
+ if (batch.isEmpty()) return;
+ EnrichmentBatchPlan batchPlan = requestBatchPlan(chatModel, batch, index, perPageCap);
+ if (batchPlan == null || batchPlan.isEmpty()) return;
+
+ for (WikiPageEntity page : batch) {
+ EnrichmentPlan plan = batchPlan.plans().get(page.getSlug());
+ if (plan == null || plan.isEmpty()) continue;
+ WikiEnrichmentApplier.Result result = WikiEnrichmentApplier.apply(page.getContent(), plan);
+ if (result instanceof WikiEnrichmentApplier.Result.Rejected rejected) {
+ log.warn("[WikiEnrich] Batch plan rejected for slug={}: {}", page.getSlug(), rejected.reason());
+ continue;
+ }
+ if (result instanceof WikiEnrichmentApplier.Result.Applied applied) {
+ page.setContent(applied.content());
+ page.setOutgoingLinks(pageService.extractLinksAsJson(applied.content()));
+ pageService.updateById(page);
+ log.info("[WikiEnrich] Batch applied {} replacements on slug={}",
+ applied.replacementCount(), page.getSlug());
+ }
+ }
+ }
+
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
@@ -209,6 +292,107 @@ public class WikiLinkEnrichmentService {
.collect(Collectors.joining("\n"));
}
+ /**
+ * Batch enrich prompt: ask the LLM for one JSON object keyed by slug.
+ * Returns {@code null} on any LLM call failure; per-slug parse failures
+ * yield empty plans (no-op for that page).
+ */
+ private EnrichmentBatchPlan requestBatchPlan(ChatModel chatModel,
+ List batch,
+ String index,
+ int perPageCap) {
+ 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 — for EACH page in the batch.
+
+ Strict output contract — return ONLY this JSON object, nothing else:
+ {
+ "plans": {
+ "": {
+ "replacements": [
+ {"original": "",
+ "replacement": "[[]]" or "[[|