mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-17 12:54:40 +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(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).
|
* List pages derived from a specific raw material (for UI sidebar filtering).
|
||||||
* Uses a LIKE search on sourceRawIds JSON field — cheap and dialect-agnostic.
|
* 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