feat(wiki): fact/experience dependency graph and stale propagation engine

This commit is contained in:
matevip 2026-05-31 07:56:10 +08:00
parent 28284fbcac
commit 66e4788226
3 changed files with 272 additions and 0 deletions

View File

@ -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 experiencefact dependency graph and propagates staleness when
* a fact page changes.
*
* <p>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
* experienceexperience 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<String> setDependencies(Long kbId, Long pageId, List<Long> dependsOnPageIds) {
List<String> rejected = new ArrayList<>();
Set<Long> 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<WikiPageDependencyEntity>()
.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<WikiPageDependencyEntity> edges = dependencyMapper.selectList(
new LambdaQueryWrapper<WikiPageDependencyEntity>()
.eq(WikiPageDependencyEntity::getKbId, kbId)
.eq(WikiPageDependencyEntity::getDependsOnPageId, factPageId));
if (edges.isEmpty()) {
return 0;
}
Set<Long> 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 + "\"}";
}
}
}

View File

@ -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<WikiPageEntity>()
.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<Long> pageIds, String staleReasonJson) {
if (pageIds == null || pageIds.isEmpty()) {
return 0;
}
return pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<WikiPageEntity>()
.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<WikiPageEntity>()
.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.

View File

@ -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<String> 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<String> 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<String> rejected = dependencyService.setDependencies(kbA, exp.getId(), List.of(factOther.getId()));
assertFalse(rejected.isEmpty());
assertTrue(rejected.get(0).contains("not found in this KB"));
}
}