mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): fact/experience dependency graph and stale propagation engine
This commit is contained in:
parent
28284fbcac
commit
66e4788226
@ -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.
|
||||
*
|
||||
* <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
|
||||
* 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<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 + "\"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user