diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDependencyService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDependencyService.java new file mode 100644 index 00000000..7b6fa869 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDependencyService.java @@ -0,0 +1,140 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.wiki.model.WikiPageDependencyEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageDependencyMapper; +import vip.mate.wiki.repository.WikiPageMapper; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Maintains the experience→fact dependency graph and propagates staleness when + * a fact page changes. + * + *

Dependencies are stored by page id. An edge is accepted only when the + * target is a {@code fact}-layer page in the same KB (cross-KB and + * experience→experience edges are rejected). When a fact page changes, every + * page depending on it is marked stale via a single batch update keyed on the + * reverse index, rather than per-row in the ingest transaction. + * + * @author MateClaw Team + */ +@Slf4j +@Service +public class WikiDependencyService { + + private final WikiPageDependencyMapper dependencyMapper; + private final WikiPageMapper pageMapper; + private final WikiPageService pageService; + private final ObjectMapper objectMapper; + + public WikiDependencyService(WikiPageDependencyMapper dependencyMapper, WikiPageMapper pageMapper, + WikiPageService pageService, ObjectMapper objectMapper) { + this.dependencyMapper = dependencyMapper; + this.pageMapper = pageMapper; + this.pageService = pageService; + this.objectMapper = objectMapper; + } + + /** + * Replace an experience page's fact dependencies. Rejected targets (missing, + * cross-KB, or non-fact) are skipped and returned so the caller can record a + * warning. The page's {@code depends_on_json} snapshot is refreshed too. + * + * @return the list of rejected target ids with a reason + */ + public List setDependencies(Long kbId, Long pageId, List dependsOnPageIds) { + List rejected = new ArrayList<>(); + Set accepted = new LinkedHashSet<>(); + if (dependsOnPageIds != null) { + for (Long target : dependsOnPageIds) { + if (target == null || target.equals(pageId)) { + continue; + } + WikiPageEntity targetPage = pageMapper.selectById(target); + if (targetPage == null || !kbId.equals(targetPage.getKbId())) { + rejected.add(target + ": not found in this KB"); + continue; + } + if (targetPage.getArchived() != null && targetPage.getArchived() == 1) { + rejected.add(target + ": archived"); + continue; + } + if (!isFactLayer(targetPage)) { + rejected.add(target + ": dependency target is not a fact-layer page"); + continue; + } + accepted.add(target); + } + } + + // Soft-delete existing edges for this page, then insert the accepted set. + dependencyMapper.delete(new LambdaQueryWrapper() + .eq(WikiPageDependencyEntity::getPageId, pageId)); + for (Long target : accepted) { + WikiPageDependencyEntity edge = new WikiPageDependencyEntity(); + edge.setKbId(kbId); + edge.setPageId(pageId); + edge.setDependsOnPageId(target); + edge.setDependencyType("fact"); + edge.setCreateTime(LocalDateTime.now()); + edge.setUpdateTime(LocalDateTime.now()); + dependencyMapper.insert(edge); + } + try { + String json = objectMapper.writeValueAsString(accepted); + pageService.setLayerAndDependencies(pageId, "experience", json); + } catch (Exception e) { + log.warn("[WikiDep] failed to write depends_on_json for page {}: {}", pageId, e.getMessage()); + } + return rejected; + } + + /** + * Mark every page depending on {@code factPageId} as stale. Returns the + * number of pages marked. Idempotent — re-running on already-stale pages is + * harmless. + */ + public int markDependentsStale(Long kbId, Long factPageId, String reason) { + List edges = dependencyMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiPageDependencyEntity::getKbId, kbId) + .eq(WikiPageDependencyEntity::getDependsOnPageId, factPageId)); + if (edges.isEmpty()) { + return 0; + } + Set dependentIds = new LinkedHashSet<>(); + for (WikiPageDependencyEntity edge : edges) { + dependentIds.add(edge.getPageId()); + } + String reasonJson = buildReasonJson(factPageId, reason); + int marked = pageService.markStale(dependentIds, reasonJson); + log.info("[WikiDep] fact page {} changed -> marked {} dependent page(s) stale", factPageId, marked); + return marked; + } + + private boolean isFactLayer(WikiPageEntity page) { + String layer = page.getKnowledgeLayer(); + // Unspecified layer is treated as fact (RFC default), so legacy pages + // remain valid dependency targets. + return layer == null || layer.isBlank() || "fact".equalsIgnoreCase(layer.trim()); + } + + private String buildReasonJson(Long factPageId, String reason) { + try { + return objectMapper.writeValueAsString(java.util.Map.of( + "factPageId", String.valueOf(factPageId), + "reason", reason == null ? "fact page updated" : reason)); + } catch (Exception e) { + return "{\"factPageId\":\"" + factPageId + "\"}"; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index 750138c8..5b4ffade 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -368,6 +368,39 @@ public class WikiPageService { .set(WikiPageEntity::getProfileVersion, profileVersion)); } + /** Set a page's knowledge layer and depends-on snapshot via a partial update. */ + public void setLayerAndDependencies(Long pageId, String knowledgeLayer, String dependsOnJson) { + if (pageId == null) { + return; + } + pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, pageId) + .set(WikiPageEntity::getKnowledgeLayer, knowledgeLayer) + .set(WikiPageEntity::getDependsOnJson, dependsOnJson)); + } + + /** Mark a batch of pages stale with a shared reason JSON via a partial update. */ + public int markStale(java.util.Collection pageIds, String staleReasonJson) { + if (pageIds == null || pageIds.isEmpty()) { + return 0; + } + return pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .in(WikiPageEntity::getId, pageIds) + .set(WikiPageEntity::getStale, 1) + .set(WikiPageEntity::getStaleReasonJson, staleReasonJson)); + } + + /** Clear the stale flag on a single page (e.g. after regeneration). */ + public void clearStale(Long pageId) { + if (pageId == null) { + return; + } + pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, pageId) + .set(WikiPageEntity::getStale, 0) + .set(WikiPageEntity::getStaleReasonJson, null)); + } + /** * List pages derived from a specific raw material (for UI sidebar filtering). * Uses a LIKE search on sourceRawIds JSON field — cheap and dialect-agnostic. diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiDependencyServiceE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiDependencyServiceE2ETest.java new file mode 100644 index 00000000..870b7065 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiDependencyServiceE2ETest.java @@ -0,0 +1,99 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import vip.mate.wiki.model.WikiPageEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end test of the dependency graph and stale propagation against H2: + * valid fact dependencies are recorded, illegal ones rejected, and updating a + * fact page marks its dependents stale. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class WikiDependencyServiceE2ETest { + + @Autowired + private WikiDependencyService dependencyService; + @Autowired + private WikiPageService pageService; + + private static final java.util.concurrent.atomic.AtomicLong SEQ = + new java.util.concurrent.atomic.AtomicLong(System.nanoTime()); + + private WikiPageEntity factPage(long kb, String slug) { + WikiPageEntity p = pageService.createPage(kb, slug, "Fact " + slug, "body", "s", "[1]", "episode"); + pageService.setLayerAndDependencies(p.getId(), "fact", null); + return pageService.getBySlug(kb, slug); + } + + private WikiPageEntity experiencePage(long kb, String slug) { + WikiPageEntity p = pageService.createPage(kb, slug, "Exp " + slug, "body", "s", "[1]", "analysis"); + pageService.setLayerAndDependencies(p.getId(), "experience", null); + return pageService.getBySlug(kb, slug); + } + + @Test + void validFactDependency_isRecorded_andStalePropagates() { + long kb = SEQ.incrementAndGet(); + WikiPageEntity fact = factPage(kb, "fact-" + kb); + WikiPageEntity exp = experiencePage(kb, "exp-" + kb); + + List rejected = dependencyService.setDependencies(kb, exp.getId(), List.of(fact.getId())); + assertTrue(rejected.isEmpty(), () -> "unexpected rejections: " + rejected); + + // Fact page changes -> dependent experience page goes stale. + int marked = dependencyService.markDependentsStale(kb, fact.getId(), "fact body changed"); + assertEquals(1, marked); + + WikiPageEntity reloaded = pageService.getBySlug(kb, "exp-" + kb); + assertEquals(1, reloaded.getStale()); + assertTrue(reloaded.getStaleReasonJson().contains(String.valueOf(fact.getId()))); + + // Regenerating clears the flag. + pageService.clearStale(reloaded.getId()); + assertEquals(0, pageService.getBySlug(kb, "exp-" + kb).getStale()); + } + + @Test + void experienceTargetDependency_isRejected() { + long kb = SEQ.incrementAndGet(); + WikiPageEntity expA = experiencePage(kb, "expA-" + kb); + WikiPageEntity expB = experiencePage(kb, "expB-" + kb); + + // Depending on an experience page (not a fact) must be rejected. + List rejected = dependencyService.setDependencies(kb, expA.getId(), List.of(expB.getId())); + assertFalse(rejected.isEmpty()); + assertTrue(rejected.get(0).contains("not a fact-layer")); + + // No stale propagation since no edge was created. + assertEquals(0, dependencyService.markDependentsStale(kb, expB.getId(), "x")); + } + + @Test + void crossKbDependency_isRejected() { + long kbA = SEQ.incrementAndGet(); + long kbB = SEQ.incrementAndGet(); + WikiPageEntity factOther = factPage(kbB, "factB-" + kbB); + WikiPageEntity exp = experiencePage(kbA, "expA2-" + kbA); + + List rejected = dependencyService.setDependencies(kbA, exp.getId(), List.of(factOther.getId())); + assertFalse(rejected.isEmpty()); + assertTrue(rejected.get(0).contains("not found in this KB")); + } +}