From 30e7e67bb6510382249d75a33ae820bc29f0e45b Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 26 Apr 2026 19:53:12 +0800 Subject: [PATCH] feat(wiki): LLM-narrated overview section with debounced regen + Recent Updates list + scaffold self-heal --- .../vip/mate/wiki/event/WikiKbDirtyEvent.java | 26 ++ .../vip/mate/wiki/service/WikiLogService.java | 14 +- .../wiki/service/WikiNarrativeService.java | 319 ++++++++++++++++++ .../wiki/service/WikiOverviewService.java | 62 +++- .../wiki/service/WikiProcessingService.java | 23 +- .../wiki/service/WikiScaffoldService.java | 21 +- .../prompts/wiki/narrative-system.txt | 23 ++ .../resources/prompts/wiki/narrative-user.txt | 19 ++ 8 files changed, 494 insertions(+), 13 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/event/WikiKbDirtyEvent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/service/WikiNarrativeService.java create mode 100644 mateclaw-server/src/main/resources/prompts/wiki/narrative-system.txt create mode 100644 mateclaw-server/src/main/resources/prompts/wiki/narrative-user.txt diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiKbDirtyEvent.java b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiKbDirtyEvent.java new file mode 100644 index 00000000..a27b7952 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiKbDirtyEvent.java @@ -0,0 +1,26 @@ +package vip.mate.wiki.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * Fired after a knowledge base's content meaningfully changes — i.e. raw + * material ingest commits, or a non-trivial compile lands. Listeners that + * want to refresh derived artefacts (overview narrative, embeddings drift + * checks, search index) subscribe here. + * + *

Carries only {@code kbId}. Listeners debounce or batch on their own. + * The intent is "this KB is dirty, rebuild downstream when convenient", + * not "this exact raw was just processed" — the latter already has + * {@link WikiProcessingEvent}.

+ */ +@Getter +public class WikiKbDirtyEvent extends ApplicationEvent { + + private final Long kbId; + + public WikiKbDirtyEvent(Object source, Long kbId) { + super(source); + this.kbId = kbId; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLogService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLogService.java index 39db8f40..3fbbc45a 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLogService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLogService.java @@ -37,6 +37,7 @@ public class WikiLogService { private final WikiPageService pageService; private final WikiPageMapper pageMapper; + private final WikiScaffoldService scaffoldService; /** Common entry types so callers don't have to invent strings. */ public enum EventType { @@ -46,7 +47,9 @@ public class WikiLogService { /** * Append a single bullet under today's section header. Idempotent in the * sense that two simultaneous calls produce two bullets — never crashes, - * never overwrites prior content. + * never overwrites prior content. Auto-heals when the log page is missing + * by triggering scaffold once and retrying — covers KBs created before the + * scaffold migration shipped. * * @param kbId knowledge base id * @param event high-level category @@ -56,9 +59,12 @@ public class WikiLogService { if (kbId == null || event == null || body == null || body.isBlank()) return; WikiPageEntity log = pageService.getBySlug(kbId, WikiScaffoldService.LOG_SLUG); if (log == null) { - // Caller is expected to ensure scaffold first; bail quietly. - WikiLogService.log.debug("[WikiLog] No log page for kbId={}, skipping append", kbId); - return; + scaffoldService.ensureScaffold(kbId); + log = pageService.getBySlug(kbId, WikiScaffoldService.LOG_SLUG); + if (log == null) { + WikiLogService.log.debug("[WikiLog] No log page for kbId={} after scaffold, skipping append", kbId); + return; + } } try { String existing = log.getContent() == null ? "# Log\n" : log.getContent(); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiNarrativeService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiNarrativeService.java new file mode 100644 index 00000000..a6c9934e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiNarrativeService.java @@ -0,0 +1,319 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.PreDestroy; +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.prompt.Prompt; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.prompt.PromptLoader; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.event.WikiKbDirtyEvent; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.WikiModelRoutingService; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiPageMapper; +import vip.mate.wiki.repository.WikiRawMaterialMapper; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Generates and maintains the LLM-narrated section of a knowledge base's + * {@code overview} system page. Listens for {@link WikiKbDirtyEvent}s after + * commit, debounces per-KB so a burst of ingests collapses into a single LLM + * call, then rewrites only the narrative marker block — leaving the + * deterministic stats block (managed by {@link WikiOverviewService}) and any + * user-authored prose untouched. + * + *

Marker contract

+ * The narrative lives between: + *
+ * <!-- mate:overview:narrative:v1:start -->
+ *   ... LLM-rewritten 2-3 sentence summary ...
+ * <!-- mate:overview:narrative:v1:end -->
+ * 
+ * If the markers are missing (legacy overview pages), the narrative is + * inserted right after the existing stats block — so visual order on the + * rendered page is: stats → narrative → user prose. + * + *

Failure modes

+ * Every failure mode short-circuits gracefully — narrative regen is best- + * effort decoration, never a blocker: + * + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiNarrativeService { + + public static final String NARRATIVE_START = ""; + public static final String NARRATIVE_END = ""; + + /** Debounce window: a burst of ingests within this period collapses into a single LLM call. */ + private static final long DEBOUNCE_MS = 10_000L; + /** Hard cap on the LLM-generated narrative length (chars). */ + private static final int MAX_NARRATIVE_CHARS = 600; + /** How many recent raw titles to feed the LLM as context. */ + private static final int RECENT_SOURCES_LIMIT = 10; + /** How many existing page titles to feed the LLM as context. */ + private static final int TOP_PAGES_LIMIT = 15; + /** Spring AI's internal retries are off — wiki has its own. */ + private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build(); + + private final WikiPageService pageService; + private final WikiPageMapper pageMapper; + private final WikiRawMaterialMapper rawMapper; + private final WikiKnowledgeBaseService kbService; + private final WikiScaffoldService scaffoldService; + private final WikiModelRoutingService modelRoutingService; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + + /** kbId → pending regen task. Coalesces bursts. */ + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "wiki-narrative"); + t.setDaemon(true); + return t; + }); + + /** + * Spring fires this AFTER_COMMIT so we never run on a transaction that + * subsequently rolled back. {@code fallbackExecution=true} keeps it + * working when the publisher isn't inside a transaction (test paths, + * imperative ingest from the UI thread). + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onKbDirty(WikiKbDirtyEvent event) { + scheduleRegen(event.getKbId()); + } + + /** + * Schedule (or re-schedule) a narrative regen. Cancels any in-flight + * scheduled task for the same KB so a burst of N ingests produces one + * LLM call, not N. Public so admin endpoints / manual rebuild can call + * directly. + */ + public void scheduleRegen(Long kbId) { + if (kbId == null) return; + pending.compute(kbId, (k, existing) -> { + if (existing != null) existing.cancel(false); + return scheduler.schedule(() -> runRegen(k), DEBOUNCE_MS, TimeUnit.MILLISECONDS); + }); + } + + private void runRegen(Long kbId) { + try { + regenerateNow(kbId); + } catch (Exception e) { + log.warn("[WikiNarrative] Regen failed for kbId={}: {}", kbId, e.getMessage()); + } finally { + pending.remove(kbId); + } + } + + /** + * Synchronous regeneration — exposed so an admin endpoint or test can + * call without going through the debouncer. + */ + public void regenerateNow(Long kbId) { + if (kbId == null) return; + + WikiPageEntity overview = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); + if (overview == null) { + scaffoldService.ensureScaffold(kbId); + overview = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); + if (overview == null) { + log.debug("[WikiNarrative] No overview page for kbId={} after scaffold, skipping", kbId); + return; + } + } + + List recentRaws = rawMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, kbId) + .isNotNull(WikiRawMaterialEntity::getLastProcessedAt) + .orderByDesc(WikiRawMaterialEntity::getLastProcessedAt) + .last("LIMIT " + RECENT_SOURCES_LIMIT)); + if (recentRaws == null || recentRaws.isEmpty()) { + // Empty KB — nothing to summarise. Leave whatever is in the markers. + log.debug("[WikiNarrative] No processed sources for kbId={}, skipping", kbId); + return; + } + + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + String kbTitle = (kb != null && kb.getName() != null) ? kb.getName() : ("KB #" + kbId); + + // Top pages = most recent non-system pages by update time. listByKbId + // already excludes archived; we slice to TOP_PAGES_LIMIT after a + // fresh sort because the underlying default order isn't guaranteed. + List kbPages = pageMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, kbId) + .ne(WikiPageEntity::getPageType, WikiScaffoldService.SYSTEM_PAGE_TYPE) + .orderByDesc(WikiPageEntity::getUpdateTime) + .last("LIMIT " + TOP_PAGES_LIMIT)); + + String currentNarrative = extractNarrative(overview.getContent()); + + ChatModel chatModel = resolveChatModel(kbId); + if (chatModel == null) { + log.debug("[WikiNarrative] No chat model resolvable for kbId={}, skipping", kbId); + return; + } + + String narrative; + try { + Prompt prompt = buildPrompt(kbTitle, recentRaws, kbPages, currentNarrative); + String raw = chatModel.call(prompt).getResult().getOutput().getText(); + narrative = sanitize(raw); + } catch (Exception e) { + log.warn("[WikiNarrative] LLM call failed for kbId={}: {}", kbId, e.getMessage()); + return; + } + if (narrative == null || narrative.isBlank()) { + log.debug("[WikiNarrative] LLM returned blank narrative for kbId={}, keeping existing", kbId); + return; + } + + // Re-read the page right before write so we don't clobber a concurrent + // stats refresh from WikiOverviewService. + WikiPageEntity fresh = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); + if (fresh == null) return; + String spliced = spliceNarrative(fresh.getContent(), narrative); + if (spliced.equals(fresh.getContent())) return; + fresh.setContent(spliced); + pageMapper.updateById(fresh); + log.info("[WikiNarrative] Refreshed narrative for kbId={} ({} chars)", kbId, narrative.length()); + } + + // ------------------------------------------------------------------ + // Helpers — visible for testing + // ------------------------------------------------------------------ + + String extractNarrative(String content) { + if (content == null) return ""; + int s = content.indexOf(NARRATIVE_START); + int e = content.indexOf(NARRATIVE_END); + if (s < 0 || e < 0 || e < s) return ""; + String inner = content.substring(s + NARRATIVE_START.length(), e).trim(); + return inner; + } + + String spliceNarrative(String content, String narrative) { + String generated = NARRATIVE_START + "\n" + narrative.trim() + "\n" + NARRATIVE_END; + if (content == null || content.isEmpty()) { + return "# Overview\n\n" + generated + "\n"; + } + int start = content.indexOf(NARRATIVE_START); + int end = content.indexOf(NARRATIVE_END); + if (start >= 0 && end > start) { + return content.substring(0, start) + + generated + + content.substring(end + NARRATIVE_END.length()); + } + // Markers missing — drop the narrative right after the stats block (if + // present) so visual order is stats → narrative; else append at end. + int statsEnd = content.indexOf(WikiOverviewService.MARKER_END); + if (statsEnd >= 0) { + int splitAt = statsEnd + WikiOverviewService.MARKER_END.length(); + String before = content.substring(0, splitAt); + String after = content.substring(splitAt); + String prefix = before.endsWith("\n") ? before : before + "\n"; + String suffix = after.startsWith("\n") ? after : "\n" + after; + return prefix + "\n" + generated + suffix; + } + String trimmed = content.endsWith("\n") ? content : content + "\n"; + return trimmed + "\n" + generated + "\n"; + } + + String sanitize(String raw) { + if (raw == null) return null; + String s = raw.trim(); + // Strip stray markdown code fences the model sometimes wraps even though + // the system prompt forbids them. + if (s.startsWith("```")) { + int firstNl = s.indexOf('\n'); + if (firstNl > 0) s = s.substring(firstNl + 1); + if (s.endsWith("```")) s = s.substring(0, s.length() - 3); + s = s.trim(); + } + // Collapse internal newlines — narrative is supposed to be a single paragraph. + s = s.replaceAll("\\s*\\n+\\s*", " ").trim(); + if (s.length() > MAX_NARRATIVE_CHARS) { + s = s.substring(0, MAX_NARRATIVE_CHARS).trim() + "…"; + } + return s; + } + + private Prompt buildPrompt(String kbTitle, + List recentRaws, + List topPages, + String currentNarrative) { + StringBuilder sources = new StringBuilder(); + for (WikiRawMaterialEntity r : recentRaws) { + String t = r.getTitle() == null || r.getTitle().isBlank() + ? ("source #" + r.getId()) : r.getTitle(); + sources.append("- ").append(t) + .append(" (").append(r.getSourceType() == null ? "?" : r.getSourceType()).append(")\n"); + } + StringBuilder pages = new StringBuilder(); + if (topPages != null) { + for (WikiPageEntity p : topPages) { + if (p.getTitle() == null || p.getTitle().isBlank()) continue; + pages.append("- ").append(p.getTitle()).append('\n'); + } + } + if (pages.length() == 0) pages.append("_(无)_\n"); + String narrative = (currentNarrative == null || currentNarrative.isBlank()) + ? "_(尚无)_" : currentNarrative; + + String system = PromptLoader.loadPrompt("wiki/narrative-system"); + String userTemplate = PromptLoader.loadPrompt("wiki/narrative-user"); + String user = userTemplate + .replace("{kb_title}", kbTitle) + .replace("{recent_sources}", sources.toString().trim()) + .replace("{top_pages}", pages.toString().trim()) + .replace("{current_narrative}", narrative); + + return new Prompt(List.of(new SystemMessage(system), new UserMessage(user))); + } + + private ChatModel resolveChatModel(Long kbId) { + try { + Long modelId = modelRoutingService.selectModelId(kbId, "heavy_ingest", WikiJobStep.SUMMARY); + ModelConfigEntity model = modelConfigService.getModel(modelId); + if (model != null) { + return agentGraphBuilder.buildRuntimeChatModel(model, NO_RETRY); + } + } catch (Exception e) { + log.debug("[WikiNarrative] Model routing failed for kbId={}: {}", kbId, e.getMessage()); + } + return null; + } + + @PreDestroy + void shutdown() { + scheduler.shutdownNow(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiOverviewService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiOverviewService.java index 73a25bbe..f3ae7f3a 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiOverviewService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiOverviewService.java @@ -13,6 +13,7 @@ import vip.mate.wiki.repository.WikiRawMaterialMapper; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; /** * RFC-051 PR-2b: deterministic overview rebuilder. @@ -45,20 +46,29 @@ public class WikiOverviewService { private final WikiPageMapper pageMapper; private final WikiRawMaterialMapper rawMapper; private final WikiChunkMapper chunkMapper; + private final WikiScaffoldService scaffoldService; private static final DateTimeFormatter ISO = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + /** Number of raw materials surfaced in the "Recent Updates" section. */ + private static final int RECENT_UPDATES_LIMIT = 5; /** * Rebuild the marker region of the overview page for {@code kbId}. - * No-op when the overview page is missing — call - * {@code WikiScaffoldService.ensureScaffold} first. + * Auto-heals when the overview page is missing by triggering + * {@link WikiScaffoldService#ensureScaffold(Long)} once and retrying — this + * covers KBs created before the scaffold migration shipped. */ public void rebuild(Long kbId) { if (kbId == null) return; WikiPageEntity overview = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); if (overview == null) { - log.debug("[WikiOverview] No overview page for kbId={}, skipping rebuild", kbId); - return; + // Self-heal for legacy KBs: scaffold then retry once. + scaffoldService.ensureScaffold(kbId); + overview = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); + if (overview == null) { + log.debug("[WikiOverview] No overview page for kbId={} after scaffold, skipping rebuild", kbId); + return; + } } try { String stats = computeStatsBlock(kbId); @@ -115,16 +125,60 @@ public class WikiOverviewService { - Chunks: %d - Last ingest: %s + ## Recent Updates + + %s + ## Coverage - Embedding coverage: %d / %d (%d%%) - Pages with wikilinks: %d / %d (%d%%) """.formatted( rawCount, pageCount, chunkCount, lastIngest, + renderRecentUpdates(kbId), embeddedChunks, chunkCount, embedPct, pagesWithLinks, pageCount, linkPct); } + /** + * Render the most recently processed sources as a Markdown bullet list, + * one bullet per raw material — title, source type, ingest time, and + * chunk count. Sources still {@code pending} or never processed are + * skipped (they'd dilute the "what just changed" signal). Empty wikis + * surface a single "No sources ingested yet." line. + */ + private String renderRecentUpdates(Long kbId) { + List recent = rawMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, kbId) + .isNotNull(WikiRawMaterialEntity::getLastProcessedAt) + .orderByDesc(WikiRawMaterialEntity::getLastProcessedAt) + .last("LIMIT " + RECENT_UPDATES_LIMIT)); + if (recent == null || recent.isEmpty()) { + return "_No sources ingested yet._"; + } + StringBuilder sb = new StringBuilder(); + for (WikiRawMaterialEntity raw : recent) { + long chunks = chunkMapper.selectCount( + new LambdaQueryWrapper() + .eq(WikiChunkEntity::getRawId, raw.getId())); + String when = raw.getLastProcessedAt().format(ISO); + String title = raw.getTitle() == null || raw.getTitle().isBlank() + ? ("source #" + raw.getId()) : raw.getTitle(); + String type = raw.getSourceType() == null ? "?" : raw.getSourceType(); + String status = raw.getProcessingStatus() == null ? "" : raw.getProcessingStatus(); + String statusBadge = "partial".equals(status) ? " ⚠ partial" : ""; + sb.append("- ").append(when).append(" — ").append(title) + .append(" (").append(type).append(", ").append(chunks).append(" chunks)") + .append(statusBadge).append('\n'); + } + // Trim the trailing newline so the text-block formatting below is clean. + if (sb.length() > 0 && sb.charAt(sb.length() - 1) == '\n') { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + String spliceMarkerRegion(String content, String newBlock) { if (content == null || content.isEmpty()) { // No prior overview — synthesize one with both markers. 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 1a3c9b76..e673f348 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 @@ -56,6 +56,7 @@ public class WikiProcessingService { private final ObjectMapper objectMapper; private final WikiProgressBus progressBus; private final WikiCitationService citationService; + private final org.springframework.context.ApplicationEventPublisher eventPublisher; @org.springframework.beans.factory.annotation.Autowired(required = false) @org.springframework.context.annotation.Lazy @@ -358,10 +359,13 @@ public class WikiProcessingService { } // RFC-051 PR-2c: log every non-failed eager ingest. Failures already get a - // RAW_FAILED broadcast and an error message in the raw row. + // RAW_FAILED broadcast and an error message in the raw row. Title goes first + // so the log reads as "what just landed" instead of an opaque raw id. if (logService != null && !"failed".equals(finalStatus)) { + String title = (raw.getTitle() == null || raw.getTitle().isBlank()) + ? ("raw#" + rawId) : raw.getTitle(); logService.append(kb.getId(), WikiLogService.EventType.INGEST, - "eager " + finalStatus + " raw=" + rawId + "eager " + finalStatus + " · " + title + " · " + totalPages + " pages · " + totalChunks + " chunks"); } // RFC-051 PR-2b: refresh overview stats whenever a raw lands in a terminal state @@ -369,6 +373,12 @@ public class WikiProcessingService { if (overviewService != null && !"failed".equals(finalStatus)) { overviewService.rebuild(kb.getId()); } + // Tier 2: signal "KB content is dirty" so WikiNarrativeService can + // schedule (debounced) an LLM-generated overview narrative refresh. + // Stats rebuild above is sync; narrative regen runs after-commit. + if (!"failed".equals(finalStatus)) { + eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kb.getId())); + } log.info("[Wiki] Processing completed for raw={}, kbId={}, generatedPages={}, totalPages={}", rawId, kb.getId(), totalPages, pageCount); @@ -1977,13 +1987,18 @@ public class WikiProcessingService { "kbPageCount", pageCount, "totalChunks", totalChunks)); - // RFC-051 PR-2c: write an activity log entry. + // RFC-051 PR-2c: write an activity log entry. Lead with title so the log + // is human-readable; raw id is implied by chunk lineage. if (logService != null) { + String title = (raw.getTitle() == null || raw.getTitle().isBlank()) + ? ("raw#" + rawId) : raw.getTitle(); logService.append(kbId, WikiLogService.EventType.INGEST, - "lazy ingest raw=" + rawId + " · " + totalChunks + " chunks"); + "lazy ingest · " + title + " · " + totalChunks + " chunks"); } // RFC-051 PR-2b: refresh overview stats. if (overviewService != null) overviewService.rebuild(kbId); + // Tier 2: dirty event drives the LLM-narrated overview section. + eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kbId)); log.info("[Wiki] Lazy processing completed for raw={}, kbId={}, chunks={}", rawId, kbId, totalChunks); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java index 70eb1746..f83e52b0 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java @@ -86,6 +86,21 @@ public class WikiScaffoldService { return "# Log\n\n## " + java.time.LocalDate.now() + " init\n\n- System pages initialized.\n"; } + /** + * Default scaffold for the overview page. + *

+ * Two distinct marker regions: + *

    + *
  • {@code mate:overview:v1} — deterministic stats block, owned by + * {@link WikiOverviewService} and rebuilt synchronously on every + * successful ingest.
  • + *
  • {@code mate:overview:narrative:v1} — LLM-narrated 2-3 sentence + * summary, owned by {@code WikiNarrativeService}, rewritten + * asynchronously after a debounced {@code WikiKbDirtyEvent}.
  • + *
+ * Anything outside both marker pairs is user-authored prose and is + * preserved verbatim by both writers. + */ private static final String DEFAULT_OVERVIEW = """ # Overview @@ -99,7 +114,7 @@ public class WikiScaffoldService { ## Recent Updates - No updates yet. + _No sources ingested yet._ ## Coverage @@ -107,5 +122,9 @@ public class WikiScaffoldService { - Pages with wikilinks: 0 - Isolated pages: 0 + + + _Narrative summary will appear after the first ingest._ + """; } diff --git a/mateclaw-server/src/main/resources/prompts/wiki/narrative-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/narrative-system.txt new file mode 100644 index 00000000..28d7d29b --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/narrative-system.txt @@ -0,0 +1,23 @@ +你是一个知识库的叙述者。你的任务:根据知识库的标题、最近导入的来源文档、和已有页面标题,生成 **2-3 句话** 的叙述性摘要,告诉读者「这个知识库主要讲什么」。 + +## 输出规则(严格遵守) + +- **跟随主体内容的语言**:中文材料 → 中文摘要;英文材料 → 英文摘要;混合时跟随多数 +- **总长度不超过 200 字**(中)或 80 words(英) +- **2-3 句**:第一句点明主导主题,后续 1-2 句补充范围或角度 +- **直接给叙述本身**:不要 markdown 标题、列表、代码块、引用、链接、强调符号 +- **不写元话术**:不说「这个知识库包括」「以下是」「本文档」「我们将」 —— 直接讲内容 +- **不堆砌细节**:避免逐项罗列文件名或页面名 +- **保持中性叙述**:不要营销腔,不要"详尽"、"权威"、"精华"这类形容词 + +## 当材料不足时 + +- 如果只有 1-2 个来源、内容很窄:照实写一句话即可,不要硬凑 +- 如果完全没有可识别主题:输出 `_暂无内容摘要_`(中)或 `_No summary yet_`(英),单行 + +## 你不要做什么 + +- ❌ 不要复述输入里的元数据(标题、时间、计数) +- ❌ 不要列出页面名 +- ❌ 不要分段、不要换行(输出就是一段连续文字) +- ❌ 不要解释你写了什么 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/narrative-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/narrative-user.txt new file mode 100644 index 00000000..d9a83f1b --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/narrative-user.txt @@ -0,0 +1,19 @@ +## 知识库标题 + +{kb_title} + +## 最近导入的来源(最多 10 条,时间倒序) + +{recent_sources} + +## 已有页面标题(按更新时间倒序,最多 15 条) + +{top_pages} + +## 当前叙述(如已存在,可在此基础上调整) + +{current_narrative} + +--- + +请基于以上信息,生成 2-3 句新的叙述。直接给叙述本身,不要任何前后缀。