diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index cc12d971..a691c01f 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -16,6 +16,7 @@ import vip.mate.agent.prompt.PromptLoader; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelConfigService; import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.job.WikiKbConfig; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; @@ -146,6 +147,14 @@ public class WikiProcessingService { kbService.updateStatus(kb.getId(), "processing"); + // RFC-051 PR-1b: lazy ingest short-circuit. Per KB config, skip the heavy + // pipeline entirely: extract → chunk → embed → completed. 0 pages is the + // expected outcome, not a failure. ingestMode==null keeps existing behavior. + if ("lazy".equals(resolveIngestMode(kb))) { + processLazyIngest(kb, raw); + return; + } + // RFC-030 §9.1: create a processing job record and track its ID for stage transitions Long jobId = null; if (wikiJobService != null) { @@ -1625,4 +1634,97 @@ public class WikiProcessingService { return null; } } + + /** + * RFC-051 PR-1b: read {@code ingestMode} from KB config JSON. Returns null + * on any parse error or missing field so the caller falls through to eager. + */ + private String resolveIngestMode(WikiKnowledgeBaseEntity kb) { + if (kb == null || kb.getConfigContent() == null) return null; + try { + WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class); + return config.getIngestMode(); + } catch (Exception e) { + log.warn("[Wiki] Failed to parse KB config for ingest mode, falling back to eager: {}", e.getMessage()); + return null; + } + } + + /** + * RFC-051 PR-1b: lazy ingest — chunk + embed, no page generation. + *

+ * Intentionally minimal: reuses the legacy {@code persistChunks(List, offsets)} + * overload (no structural metadata; that lands in PR-1c with the preprocessor) + * and the existing {@code embedMissingChunks} entry point. Zero pages is the + * expected outcome, not a failure. + */ + private void processLazyIngest(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw) { + Long rawId = raw.getId(); + Long kbId = kb.getId(); + log.info("[Wiki] Lazy ingest starting for raw={}, kbId={}", rawId, kbId); + + rawService.updateProgress(rawId, "lazy", 0, 0); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_STARTED, + java.util.Map.of("rawId", rawId, "phase", "lazy")); + + try { + String textContent = rawService.getTextContent(raw); + if (textContent == null || textContent.isBlank()) { + rawService.updateProcessingStatus(rawId, "failed", "No text content available"); + kbService.updateStatus(kbId, "active"); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_FAILED, + java.util.Map.of("rawId", rawId, "error", "No text content available")); + return; + } + + List chunksWithOffset = splitIntoChunksWithOffsets(textContent); + List chunks = chunksWithOffset.stream().map(ChunkWithOffset::text).toList(); + List offsets = chunksWithOffset.stream() + .map(c -> new int[]{c.startOffset(), c.endOffset()}).toList(); + chunkService.persistChunks(kbId, rawId, chunks, offsets); + int totalChunks = chunks.size(); + log.info("[Wiki] Lazy ingest persisted {} chunks for raw={}", totalChunks, rawId); + + // Async embedding — mirror the eager path so a slow embedding model + // does not block the raw from reaching completed. + final Long fKbId = kbId; + WIKI_EXECUTOR.submit(() -> { + try { + int embedded = embeddingService.embedMissingChunks(fKbId); + if (embedded > 0) { + log.info("[Wiki] Lazy async embedding completed: kbId={}, embedded={}", fKbId, embedded); + } + } catch (Exception ex) { + log.warn("[Wiki] Lazy async embedding failed for kbId={}: {}", fKbId, ex.getMessage()); + } + }); + + rawService.updateProcessingStatus(rawId, "completed", null); + if (raw.getContentHash() != null) { + rawService.setLastProcessedHash(rawId, raw.getContentHash()); + } + rawService.updateProgress(rawId, "done", totalChunks, totalChunks); + int pageCount = pageService.countByKbId(kbId); + kbService.setPageCount(kbId, pageCount); + kbService.updateStatus(kbId, "active"); + + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_COMPLETED, + java.util.Map.of( + "rawId", rawId, + "status", "completed", + "totalPages", 0, + "kbPageCount", pageCount, + "totalChunks", totalChunks)); + + log.info("[Wiki] Lazy processing completed for raw={}, kbId={}, chunks={}", + rawId, kbId, totalChunks); + } catch (Exception e) { + log.error("[Wiki] Lazy processing failed for raw={}: {}", rawId, e.getMessage(), e); + rawService.updateProcessingStatus(rawId, "failed", e.getMessage()); + kbService.updateStatus(kbId, "active"); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_FAILED, + java.util.Map.of("rawId", rawId, + "error", e.getMessage() == null ? "unknown" : e.getMessage())); + } + } } diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 6e4af92b..6c09c4ad 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1307,6 +1307,11 @@ export default { configPanel: { embeddingModel: 'Embedding Model', embeddingModelHint: 'Semantic search model for this KB; leave empty for system default', + ingestMode: 'Ingest Mode', + ingestModeEager: 'Eager', + ingestModeLazy: 'Lazy', + ingestModeEagerHint: 'Upload runs the full LLM pipeline to generate wiki pages immediately.', + ingestModeLazyHint: 'Upload only extracts, chunks, and embeds. No page generation; search works right away.', modelStrategy: 'Model Strategy', globalDefault: 'Global default', selectModel: 'Select a model…', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 063db738..c469b57f 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1317,6 +1317,11 @@ export default { configPanel: { embeddingModel: '向量化模型', embeddingModelHint: '语义搜索模型,留空使用系统默认', + ingestMode: '入库模式', + ingestModeEager: '立即生成页面', + ingestModeLazy: '先入索引', + ingestModeEagerHint: '上传后立即调用 LLM 生成完整 Wiki 页面。', + ingestModeLazyHint: '上传仅抽取、切片、向量化,跳过页面生成;上传即可搜索,页面按需编译。', modelStrategy: '模型策略', globalDefault: '跟随全局默认', selectModel: '选择可用模型…', diff --git a/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue index 9d634470..e8e14f5d 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue @@ -19,6 +19,33 @@ + +

+
+
+
{{ t('wiki.configPanel.ingestMode') }}
+
+ {{ ingestMode === 'lazy' + ? t('wiki.configPanel.ingestModeLazyHint') + : t('wiki.configPanel.ingestModeEagerHint') }} +
+
+ +
+
+ + +
+
+
@@ -177,6 +204,29 @@ async function saveEmbeddingBinding() { } } +// ── Ingest mode (RFC-051 PR-1b) ── +// eager = legacy heavy pipeline (extract → chunk → route/create/merge LLM → pages). +// lazy = extract → chunk → embed → completed (0 pages is success). +const ingestMode = ref<'eager' | 'lazy'>('eager') +const savingIngestMode = ref(false) + +async function saveIngestMode() { + if (!store.currentKB) return + savingIngestMode.value = true + try { + let existingConfig: any = {} + try { + if (store.currentKB.configContent) existingConfig = JSON.parse(store.currentKB.configContent) + } catch { /* config may be plain text rules */ } + existingConfig.ingestMode = ingestMode.value + await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2)) + } catch (e) { + console.error('[WikiConfig] Failed to save ingest mode', e) + } finally { + savingIngestMode.value = false + } +} + // ── Model strategy ── const stepKeys = ['route', 'create_page', 'merge_page', 'enrich', 'summary'] const stepModels = reactive>({}) @@ -192,6 +242,7 @@ function loadStepModels() { stepKeys.forEach(k => (stepModels[k] = '')) fallbackModelIds.value = [] wikiGlobalModelId.value = '' + ingestMode.value = 'eager' if (!store.currentKB) return try { const cfg = store.currentKB.configContent ? JSON.parse(store.currentKB.configContent) : null @@ -203,6 +254,7 @@ function loadStepModels() { } if (cfg?.fallbackModelIds) fallbackModelIds.value = cfg.fallbackModelIds.map(String) if (cfg?.wikiDefaultModelId) wikiGlobalModelId.value = String(cfg.wikiDefaultModelId) + if (cfg?.ingestMode === 'lazy') ingestMode.value = 'lazy' } catch { /* not JSON */ } } @@ -416,4 +468,28 @@ loadProviderNames().then(() => { } .btn-save:hover { opacity: 0.88; } .btn-save:disabled { background: var(--mc-border); cursor: not-allowed; } + +/* Ingest mode radio group */ +.ingest-mode-row { display: flex; gap: 8px; flex-wrap: wrap; } +.ingest-mode-option { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--mc-border-light); + border-radius: 8px; + background: var(--mc-bg-elevated); + font-size: 12px; + color: var(--mc-text-secondary); + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.ingest-mode-option input { margin: 0; cursor: pointer; } +.ingest-mode-option--active { + border-color: var(--mc-primary); + color: var(--mc-primary); + background: var(--mc-primary-bg); + font-weight: 600; +} +.ingest-mode-option__label { line-height: 1; }