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