feat(wiki): hot cache rebuild engine — LLM updater, scheduler, conversation-end listener

This commit is contained in:
matevip 2026-05-02 21:45:55 +08:00
parent 93807d85ef
commit 5e936fd50a
8 changed files with 625 additions and 0 deletions

View File

@ -0,0 +1,59 @@
package vip.mate.wiki.hotcache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.memory.event.ConversationCompletedEvent;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import java.util.List;
/**
* Wires the wiki hot-cache rebuild trigger into the existing event flow.
*
* <p>Today only {@link ConversationCompletedEvent} is published when a
* chat turn ends, we schedule a rebuild for the agent's primary KB.
* (Per the existing chat retrieval path, "primary" is the first entry
* from {@link WikiKnowledgeBaseService#listByAgentId}, ordered by recent
* update.)
*
* <p>The compile-completed and page-updated events that the design
* envisions don't have publishers in the codebase yet; their listeners
* will land in the PRs that introduce those publishers, to avoid
* shipping dead glue. The scheduler exposes {@code rebuildNowBlocking}
* so the future admin API can drive {@code MANUAL} rebuilds without
* going through events.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HotCacheEventListener {
private final HotCacheUpdateScheduler scheduler;
private final WikiKnowledgeBaseService kbService;
@EventListener
public void onConversationEnd(ConversationCompletedEvent event) {
Long agentId = event.agentId();
if (agentId == null) return;
Long kbId = resolvePrimaryKb(agentId);
if (kbId == null) {
log.debug("[HotCache] agent={} has no KB; skip post-conversation rebuild", agentId);
return;
}
scheduler.scheduleRebuild(kbId, HotCacheUpdateReason.CONVERSATION_END);
}
private Long resolvePrimaryKb(Long agentId) {
try {
List<WikiKnowledgeBaseEntity> kbs = kbService.listByAgentId(agentId);
if (kbs.isEmpty()) return null;
return kbs.get(0).getId();
} catch (Exception e) {
log.debug("[HotCache] KB resolution failed for agent={}: {}", agentId, e.getMessage());
return null;
}
}
}

View File

@ -0,0 +1,38 @@
package vip.mate.wiki.hotcache;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Tunables for the wiki hot-cache rebuild pipeline. Defaults match the
* "ship cautiously" preset short rebuild window, conservative LLM
* timeout, modest input slice.
*
* <p>Override per-environment via {@code mateclaw.wiki.hot-cache.*}.
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "mateclaw.wiki.hot-cache")
public class HotCacheProperties {
/** Minimum gap between two LLM-driven rebuilds for the same KB. */
private Duration debounce = Duration.ofMinutes(5);
/** Time window for "recent" log/page entries fed to the LLM. */
private Duration recentWindow = Duration.ofHours(24);
/** Hard cap on rendered hot cache body length (post-LLM truncation point). */
private int maxChars = 4096;
/** Cap on number of recent created/updated pages fed to the prompt (each side). */
private int maxRecentPages = 10;
/** Cap on chars from the previous hot cache body fed back as context. */
private int previousContentCap = 1500;
/** Cap on chars from the KB activity log markdown fed to the prompt. */
private int logExcerptCap = 2000;
}

View File

@ -0,0 +1,67 @@
package vip.mate.wiki.hotcache;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.wiki.model.WikiPageEntity;
import java.time.Instant;
import java.util.List;
/**
* Builds the user-facing prompt for one hot-cache rebuild call by
* substituting placeholders in {@code wiki/hot-cache-rebuild-user.txt}.
*
* <p>System prompt is loaded once and held identical across calls the
* intent is to keep prompt-cache reuse high once
* {@code wiki.compile.cache.enabled} is wired in a follow-up PR.
*/
@Component
@RequiredArgsConstructor
public class HotCacheRebuildPromptBuilder {
private final HotCacheProperties props;
/**
* Returns a single user-message body with the placeholders substituted.
* Inputs are sliced to the configured caps before injection.
*/
public String buildUser(String previousContent,
String logMarkdownExcerpt,
List<WikiPageEntity> recentCreates,
List<WikiPageEntity> recentUpdates) {
String template = PromptLoader.loadPrompt("wiki/hot-cache-rebuild-user");
return template
.replace("{iso_timestamp}", Instant.now().toString())
.replace("{recent_window}", props.getRecentWindow().toString())
.replace("{previous_content}", abbreviate(blankToNone(previousContent), props.getPreviousContentCap()))
.replace("{log_excerpt}", abbreviate(blankToNone(logMarkdownExcerpt), props.getLogExcerptCap()))
.replace("{recent_creates}", renderPages(recentCreates))
.replace("{recent_updates}", renderPages(recentUpdates));
}
public String buildSystem() {
return PromptLoader.loadPrompt("wiki/hot-cache-rebuild-system");
}
private static String renderPages(List<WikiPageEntity> pages) {
if (pages == null || pages.isEmpty()) return "(none)";
StringBuilder sb = new StringBuilder();
for (WikiPageEntity p : pages) {
String slug = p.getSlug() == null ? "?" : p.getSlug();
String title = p.getTitle() == null ? slug : p.getTitle();
sb.append("- [[").append(slug).append("]] ").append(title).append('\n');
}
return sb.toString().stripTrailing();
}
private static String blankToNone(String s) {
return (s == null || s.isBlank()) ? "(none)" : s;
}
private static String abbreviate(String s, int max) {
if (s == null) return "";
if (s.length() <= max) return s;
return s.substring(0, max - 1) + "";
}
}

View File

@ -0,0 +1,93 @@
package vip.mate.wiki.hotcache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.wiki.model.WikiHotCacheEntity;
import java.time.Instant;
import java.time.ZoneId;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;
/**
* Off-loads hot-cache rebuilds onto a virtual-thread executor and applies
* two safety nets:
*
* <ul>
* <li>A per-KB {@link ReentrantLock} so two threads can never race on
* the same row.</li>
* <li>A debounce window ({@link HotCacheProperties#getDebounce()}):
* within that window of the last rebuild start, additional events
* are dropped because the next rebuild already covers them.</li>
* </ul>
*
* <p>Both checks are best-effort there's no global coordination, so
* two JVMs can each do one rebuild back-to-back. The
* {@code last_rebuild_started_at} timestamp the updater writes is the
* cross-process signal that PR-2's debounce uses; a future PR can lift
* this to a distributed lock if churn shows up in metrics.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HotCacheUpdateScheduler {
private final HotCacheProperties props;
private final WikiHotCacheService cacheService;
private final WikiHotCacheUpdater updater;
private final ConcurrentMap<Long, ReentrantLock> locks = new ConcurrentHashMap<>();
/** Virtual threads — rebuilds are LLM-bound, not CPU-bound. */
private static final ExecutorService REBUILD_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor();
/** Schedule a rebuild for {@code kbId}; returns immediately. */
public void scheduleRebuild(Long kbId, HotCacheUpdateReason reason) {
if (kbId == null) return;
REBUILD_EXECUTOR.execute(() -> attemptRebuild(kbId, reason));
}
/** Synchronous variant — used by tests + the (future) admin API. */
public void rebuildNowBlocking(Long kbId, HotCacheUpdateReason reason) {
if (kbId == null) return;
attemptRebuild(kbId, reason);
}
private void attemptRebuild(Long kbId, HotCacheUpdateReason reason) {
ReentrantLock lock = locks.computeIfAbsent(kbId, k -> new ReentrantLock());
if (!lock.tryLock()) {
log.debug("[HotCache][kb={}] rebuild already running; reason={} skipped", kbId, reason);
return;
}
try {
if (withinDebounceWindow(kbId, reason)) return;
updater.rebuild(kbId, reason);
} catch (Exception e) {
log.warn("[HotCache][kb={}] scheduled rebuild crashed; reason={}: {}",
kbId, reason, e.getMessage(), e);
} finally {
lock.unlock();
}
}
private boolean withinDebounceWindow(Long kbId, HotCacheUpdateReason reason) {
// MANUAL bypasses debounce operators triggering by hand expect to
// see their click take effect.
if (reason == HotCacheUpdateReason.MANUAL) return false;
WikiHotCacheEntity existing = cacheService.findByKb(kbId).orElse(null);
if (existing == null || existing.getLastRebuildStartedAt() == null) return false;
Instant lastStart = existing.getLastRebuildStartedAt()
.atZone(ZoneId.systemDefault()).toInstant();
if (Instant.now().isBefore(lastStart.plus(props.getDebounce()))) {
log.debug("[HotCache][kb={}] within debounce window; reason={} skipped", kbId, reason);
return true;
}
return false;
}
}

View File

@ -0,0 +1,266 @@
package vip.mate.wiki.hotcache;
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 vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.system.featureflag.FeatureFlagService;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.WikiModelRoutingService;
import vip.mate.wiki.metrics.WikiMetrics;
import vip.mate.wiki.model.WikiHotCacheEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiHotCacheMapper;
import vip.mate.wiki.service.WikiPageService;
import vip.mate.wiki.service.WikiScaffoldService;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
/**
* Performs one LLM-driven hot cache rebuild for a single KB. Caller
* (typically {@link HotCacheUpdateScheduler}) handles debounce + per-KB
* locking; this class is otherwise stateless and idempotent.
*
* <p>Pipeline: gather inputs (previous body + activity log excerpt +
* recent creates / updates) build a system+user prompt pair resolve a
* KB-routed chat model the same way the wiki narrative refresher does
* call truncate to the configured cap diff against the existing
* content hash write only if changed.
*
* <p>Failure modes (flag off, model resolution miss, LLM error) all
* surface as warnings in the row's {@code last_rebuild_error}. They never
* propagate up the triggering event must complete regardless of cache
* health.
*/
@Slf4j
@Service
public class WikiHotCacheUpdater {
private static final String FLAG = "wiki.hot_cache.enabled";
private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build();
private final WikiHotCacheService cacheService;
private final WikiHotCacheMapper mapper;
private final HotCacheRebuildPromptBuilder promptBuilder;
private final HotCacheProperties props;
private final WikiModelRoutingService modelRoutingService;
private final ModelConfigService modelConfigService;
private final AgentGraphBuilder agentGraphBuilder;
private final FeatureFlagService featureFlagService;
private final WikiMetrics metrics;
private final WikiPageService pageService;
public WikiHotCacheUpdater(WikiHotCacheService cacheService,
WikiHotCacheMapper mapper,
HotCacheRebuildPromptBuilder promptBuilder,
HotCacheProperties props,
WikiModelRoutingService modelRoutingService,
ModelConfigService modelConfigService,
AgentGraphBuilder agentGraphBuilder,
FeatureFlagService featureFlagService,
WikiMetrics metrics,
WikiPageService pageService) {
this.cacheService = cacheService;
this.mapper = mapper;
this.promptBuilder = promptBuilder;
this.props = props;
this.modelRoutingService = modelRoutingService;
this.modelConfigService = modelConfigService;
this.agentGraphBuilder = agentGraphBuilder;
this.featureFlagService = featureFlagService;
this.metrics = metrics;
this.pageService = pageService;
}
/**
* Rebuild the hot cache row for {@code kbId}. Safe to call concurrently;
* the scheduler holds the lock that makes it serial per KB.
*/
public void rebuild(Long kbId, HotCacheUpdateReason reason) {
if (kbId == null) return;
if (!featureFlagService.isEnabledForKb(FLAG, kbId)) {
log.debug("[HotCache][kb={}] flag disabled; skip rebuild reason={}", kbId, reason);
return;
}
Instant start = Instant.now();
markRebuildStarted(kbId);
try {
// 1. Inputs.
String prevContent = cacheService.getContentOrNull(kbId);
LocalDateTime since = LocalDateTime.now().minus(props.getRecentWindow());
List<WikiPageEntity> recentCreates =
pageService.findRecentCreated(kbId, since, props.getMaxRecentPages());
List<WikiPageEntity> recentUpdates =
pageService.findRecentUpdated(kbId, since, props.getMaxRecentPages());
String logExcerpt = readLogExcerpt(kbId);
// 2. Cheap exit: if the KB has had no activity at all in the
// window, skip the LLM call entirely (the snapshot would be
// no different from what's already there).
if (recentCreates.isEmpty() && recentUpdates.isEmpty()
&& (logExcerpt == null || logExcerpt.isBlank())) {
log.debug("[HotCache][kb={}] no recent activity; skip rebuild reason={}", kbId, reason);
clearRebuildMarker(kbId);
return;
}
// 3. Resolve a chat model for this KB.
ChatModel chatModel = resolveChatModel(kbId);
if (chatModel == null) {
log.debug("[HotCache][kb={}] no chat model resolvable; skip rebuild", kbId);
recordError(kbId, "no chat model resolvable",
Duration.between(start, Instant.now()).toMillis());
return;
}
// 4. Build prompts + call LLM.
String system = promptBuilder.buildSystem();
String user = promptBuilder.buildUser(prevContent, logExcerpt, recentCreates, recentUpdates);
Prompt prompt = new Prompt(List.of(new SystemMessage(system), new UserMessage(user)));
String rendered = chatModel.call(prompt).getResult().getOutput().getText();
// 5. Truncate + persist.
String truncated = truncate(rendered, props.getMaxChars());
if (truncated == null || truncated.isBlank()) {
log.warn("[HotCache][kb={}] LLM returned empty body; treating as failure", kbId);
recordError(kbId, "LLM returned empty body",
Duration.between(start, Instant.now()).toMillis());
return;
}
String hash = sha256(truncated);
persistRebuild(kbId, truncated, hash, reason, start);
metrics.recordCompileStage("hot-cache-rebuild", kbId,
Duration.between(start, Instant.now()));
log.info("[HotCache][kb={}] rebuilt; reason={} chars={} duration_ms={}",
kbId, reason, truncated.length(),
Duration.between(start, Instant.now()).toMillis());
} catch (Exception e) {
log.warn("[HotCache][kb={}] rebuild failed; reason={}: {}",
kbId, reason, e.getMessage(), e);
recordError(kbId, e.getMessage(),
Duration.between(start, Instant.now()).toMillis());
// Intentionally swallow caller's event must complete.
}
}
private void persistRebuild(Long kbId, String content, String hash,
HotCacheUpdateReason reason, Instant start) {
WikiHotCacheEntity row = cacheService.findByKb(kbId).orElseGet(() -> {
WikiHotCacheEntity fresh = new WikiHotCacheEntity();
fresh.setKbId(kbId);
fresh.setRebuildCount(0L);
return fresh;
});
boolean unchanged = hash.equals(row.getContentHash());
if (!unchanged) {
row.setContent(content);
row.setContentHash(hash);
row.setRebuildCount((row.getRebuildCount() == null ? 0L : row.getRebuildCount()) + 1);
}
row.setLastUpdated(LocalDateTime.now());
row.setUpdateReason(reason.name());
row.setLastRebuildDurationMs(Duration.between(start, Instant.now()).toMillis());
row.setLastRebuildError(null);
if (row.getId() == null) {
mapper.insert(row);
} else {
mapper.updateById(row);
}
if (unchanged) {
log.debug("[HotCache][kb={}] body unchanged; only timestamps refreshed", kbId);
}
}
/**
* Reads the most recent slice of the KB's activity log markdown page
* (created by {@code WikiLogService.append}). Bounded to
* {@link HotCacheProperties#getLogExcerptCap()} chars from the tail
* older sections are dropped. Returns null if the log page is absent.
*/
private String readLogExcerpt(Long kbId) {
try {
WikiPageEntity log = pageService.getBySlug(kbId, WikiScaffoldService.LOG_SLUG);
if (log == null) return null;
String content = log.getContent();
if (content == null) return null;
int cap = props.getLogExcerptCap();
if (content.length() <= cap) return content;
// Tail: latest entries are at the bottom of the markdown.
return "\n" + content.substring(content.length() - cap);
} catch (Exception e) {
log.debug("[HotCache][kb={}] log page read failed: {}", kbId, e.getMessage());
return null;
}
}
private ChatModel resolveChatModel(Long kbId) {
try {
Long modelId = modelRoutingService.selectModelId(kbId, "hot_cache_rebuild", WikiJobStep.SUMMARY);
ModelConfigEntity model = modelConfigService.getModel(modelId);
if (model == null) return null;
return agentGraphBuilder.buildRuntimeChatModel(model, NO_RETRY);
} catch (Exception e) {
log.debug("[HotCache][kb={}] model routing failed: {}", kbId, e.getMessage());
return null;
}
}
private void markRebuildStarted(Long kbId) {
cacheService.findByKb(kbId).ifPresent(row -> {
row.setLastRebuildStartedAt(LocalDateTime.now());
mapper.updateById(row);
});
}
private void clearRebuildMarker(Long kbId) {
cacheService.findByKb(kbId).ifPresent(row -> {
row.setLastRebuildStartedAt(null);
mapper.updateById(row);
});
}
private void recordError(Long kbId, String error, long durationMs) {
cacheService.findByKb(kbId).ifPresent(row -> {
row.setLastRebuildError(error != null && error.length() > 500
? error.substring(0, 500) : error);
row.setLastRebuildDurationMs(durationMs);
row.setLastRebuildStartedAt(null);
mapper.updateById(row);
});
}
private static String truncate(String s, int max) {
if (s == null) return "";
if (s.length() <= max) return s;
return s.substring(0, max - 1) + "";
}
private static String sha256(String s) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
} catch (Exception e) {
return "no-hash";
}
}
}

View File

@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageMapper;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
@ -72,6 +73,47 @@ public class WikiPageService {
* default {@link #listByKbId} filter. Used by the admin UI's "show archived"
* panel so users can see what they archived and recover it.
*/
/**
* Pages in {@code kbId} created at or after {@code since}, newest first.
* Used by the hot-cache rebuilder to surface "what was just added";
* archived pages and system pages (overview/log) are excluded so the
* snapshot stays focused on user-visible knowledge.
*/
public List<WikiPageEntity> findRecentCreated(Long kbId, LocalDateTime since, int limit) {
if (kbId == null || since == null || limit <= 0) return java.util.List.of();
List<WikiPageEntity> rows = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, kbId)
.ne(WikiPageEntity::getArchived, 1)
.ne(WikiPageEntity::getPageType, WikiScaffoldService.SYSTEM_PAGE_TYPE)
.ge(WikiPageEntity::getCreateTime, since)
.orderByDesc(WikiPageEntity::getCreateTime)
.last("LIMIT " + limit));
rows.forEach(p -> p.setContent(null));
return rows;
}
/**
* Pages in {@code kbId} updated at or after {@code since}, newest first.
* Same exclusions as {@link #findRecentCreated}.
*
* <p>A row that was both created and updated in the window will appear
* in both lists the caller deduplicates if needed.
*/
public List<WikiPageEntity> findRecentUpdated(Long kbId, LocalDateTime since, int limit) {
if (kbId == null || since == null || limit <= 0) return java.util.List.of();
List<WikiPageEntity> rows = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, kbId)
.ne(WikiPageEntity::getArchived, 1)
.ne(WikiPageEntity::getPageType, WikiScaffoldService.SYSTEM_PAGE_TYPE)
.ge(WikiPageEntity::getUpdateTime, since)
.orderByDesc(WikiPageEntity::getUpdateTime)
.last("LIMIT " + limit));
rows.forEach(p -> p.setContent(null));
return rows;
}
public List<WikiPageEntity> listArchivedByKbId(Long kbId) {
List<WikiPageEntity> pages = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()

View File

@ -0,0 +1,39 @@
You are the wiki hot cache rebuilder. Your job: produce a concise "what
happened recently" snapshot for the given knowledge base, so the next AI
session can pick up where the last one left off without re-reading every
wiki page.
Output STRICT markdown with this exact structure:
---
type: meta
updated: {iso_timestamp}
---
## Last Updated
{One sentence summarizing the most recent activity, ≤ 100 chars.}
## Key Recent Facts
- {Most important takeaway from recent ingest, ≤ 80 chars}
- {Second most important, ≤ 80 chars}
- {Third most important, ≤ 80 chars}
## Recent Changes
Created: {wikilinks separated by commas, e.g. [[page-1]], [[page-2]]}
Updated: {same shape}
Flagged: {contradictions or ambiguous links worth attention}
## Active Threads
- {Open research direction, ≤ 100 chars}
- {Open question, ≤ 100 chars}
CONSTRAINTS:
- TOTAL output (including frontmatter) MUST be ≤ 4000 characters.
- Use ONLY information from the inputs the user provides; do NOT
speculate, do NOT invent page slugs.
- Sections may be empty (write "(none)" if so), but the four headers
MUST appear, in order, exactly as shown.
- Output ONLY the markdown above — no fences, no preamble, no
explanation, no closing remarks.
- Match the language of the input pages (Chinese inputs → Chinese
snapshot).

View File

@ -0,0 +1,21 @@
## Previous hot cache (for continuity; may be empty)
{previous_content}
## Recent activity log excerpt (last {recent_window})
{log_excerpt}
## Recently created pages
{recent_creates}
## Recently updated pages
{recent_updates}
---
Produce the rebuilt hot cache snapshot now, using the structure and
constraints in the system prompt. Use the timestamp {iso_timestamp} in
the frontmatter.