mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): LLM-narrated overview section with debounced regen + Recent Updates list + scaffold self-heal
This commit is contained in:
parent
fa5eaf83b4
commit
30e7e67bb6
@ -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.
|
||||
*
|
||||
* <p>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}.</p>
|
||||
*/
|
||||
@Getter
|
||||
public class WikiKbDirtyEvent extends ApplicationEvent {
|
||||
|
||||
private final Long kbId;
|
||||
|
||||
public WikiKbDirtyEvent(Object source, Long kbId) {
|
||||
super(source);
|
||||
this.kbId = kbId;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <h2>Marker contract</h2>
|
||||
* The narrative lives between:
|
||||
* <pre>
|
||||
* <!-- mate:overview:narrative:v1:start -->
|
||||
* ... LLM-rewritten 2-3 sentence summary ...
|
||||
* <!-- mate:overview:narrative:v1:end -->
|
||||
* </pre>
|
||||
* 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.
|
||||
*
|
||||
* <h2>Failure modes</h2>
|
||||
* Every failure mode short-circuits gracefully — narrative regen is best-
|
||||
* effort decoration, never a blocker:
|
||||
* <ul>
|
||||
* <li>Empty KB (no processed raws) → skip silently</li>
|
||||
* <li>No LLM model resolvable → skip silently, log debug</li>
|
||||
* <li>LLM call throws / times out → keep existing narrative, log warn</li>
|
||||
* <li>LLM returns empty / overlong garbage → keep existing narrative</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WikiNarrativeService {
|
||||
|
||||
public static final String NARRATIVE_START = "<!-- mate:overview:narrative:v1:start -->";
|
||||
public static final String NARRATIVE_END = "<!-- mate:overview:narrative:v1: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<Long, ScheduledFuture<?>> 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<WikiRawMaterialEntity> recentRaws = rawMapper.selectList(
|
||||
new LambdaQueryWrapper<WikiRawMaterialEntity>()
|
||||
.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<WikiPageEntity> kbPages = pageMapper.selectList(
|
||||
new LambdaQueryWrapper<WikiPageEntity>()
|
||||
.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<WikiRawMaterialEntity> recentRaws,
|
||||
List<WikiPageEntity> 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();
|
||||
}
|
||||
}
|
||||
@ -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<WikiRawMaterialEntity> recent = rawMapper.selectList(
|
||||
new LambdaQueryWrapper<WikiRawMaterialEntity>()
|
||||
.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<WikiChunkEntity>()
|
||||
.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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
* <p>
|
||||
* Two distinct marker regions:
|
||||
* <ul>
|
||||
* <li>{@code mate:overview:v1} — deterministic stats block, owned by
|
||||
* {@link WikiOverviewService} and rebuilt synchronously on every
|
||||
* successful ingest.</li>
|
||||
* <li>{@code mate:overview:narrative:v1} — LLM-narrated 2-3 sentence
|
||||
* summary, owned by {@code WikiNarrativeService}, rewritten
|
||||
* asynchronously after a debounced {@code WikiKbDirtyEvent}.</li>
|
||||
* </ul>
|
||||
* 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
|
||||
<!-- mate:overview:v1:end -->
|
||||
|
||||
<!-- mate:overview:narrative:v1:start -->
|
||||
_Narrative summary will appear after the first ingest._
|
||||
<!-- mate:overview:narrative:v1:end -->
|
||||
""";
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
你是一个知识库的叙述者。你的任务:根据知识库的标题、最近导入的来源文档、和已有页面标题,生成 **2-3 句话** 的叙述性摘要,告诉读者「这个知识库主要讲什么」。
|
||||
|
||||
## 输出规则(严格遵守)
|
||||
|
||||
- **跟随主体内容的语言**:中文材料 → 中文摘要;英文材料 → 英文摘要;混合时跟随多数
|
||||
- **总长度不超过 200 字**(中)或 80 words(英)
|
||||
- **2-3 句**:第一句点明主导主题,后续 1-2 句补充范围或角度
|
||||
- **直接给叙述本身**:不要 markdown 标题、列表、代码块、引用、链接、强调符号
|
||||
- **不写元话术**:不说「这个知识库包括」「以下是」「本文档」「我们将」 —— 直接讲内容
|
||||
- **不堆砌细节**:避免逐项罗列文件名或页面名
|
||||
- **保持中性叙述**:不要营销腔,不要"详尽"、"权威"、"精华"这类形容词
|
||||
|
||||
## 当材料不足时
|
||||
|
||||
- 如果只有 1-2 个来源、内容很窄:照实写一句话即可,不要硬凑
|
||||
- 如果完全没有可识别主题:输出 `_暂无内容摘要_`(中)或 `_No summary yet_`(英),单行
|
||||
|
||||
## 你不要做什么
|
||||
|
||||
- ❌ 不要复述输入里的元数据(标题、时间、计数)
|
||||
- ❌ 不要列出页面名
|
||||
- ❌ 不要分段、不要换行(输出就是一段连续文字)
|
||||
- ❌ 不要解释你写了什么
|
||||
@ -0,0 +1,19 @@
|
||||
## 知识库标题
|
||||
|
||||
{kb_title}
|
||||
|
||||
## 最近导入的来源(最多 10 条,时间倒序)
|
||||
|
||||
{recent_sources}
|
||||
|
||||
## 已有页面标题(按更新时间倒序,最多 15 条)
|
||||
|
||||
{top_pages}
|
||||
|
||||
## 当前叙述(如已存在,可在此基础上调整)
|
||||
|
||||
{current_narrative}
|
||||
|
||||
---
|
||||
|
||||
请基于以上信息,生成 2-3 句新的叙述。直接给叙述本身,不要任何前后缀。
|
||||
Loading…
Reference in New Issue
Block a user