feat(wiki): wire layer derivation and fact-dependency persistence into ingest

This commit is contained in:
matevip 2026-05-31 07:59:20 +08:00
parent 8e8c1e34c7
commit 55cf2a8458
5 changed files with 119 additions and 9 deletions

View File

@ -90,6 +90,26 @@ public class WikiPageTypeProfileService {
return resolveProfile(kbId).getPageTypes().keySet();
}
/**
* The knowledge layer for a pageType: {@code fact} / {@code experience}.
* A known type with no declared layer defaults to {@code fact} (RFC default,
* keeps legacy types factual); an unknown/blank type returns {@code null}
* so the caller leaves the page's layer untouched.
*/
public String resolveLayer(Long kbId, String pageType) {
WikiPageTypeDef def = resolveProfile(kbId).get(pageType);
if (def == null) {
return null;
}
String layer = def.getLayer();
return (layer == null || layer.isBlank()) ? "fact" : layer.trim().toLowerCase();
}
/** Whether a pageType is in the experience layer (vs fact). */
public boolean isExperience(Long kbId, String pageType) {
return "experience".equals(resolveLayer(kbId, pageType));
}
/**
* The per-stage LLM instruction declared for a pageType, or empty string.
* {@code stage} is {@code route} / {@code create} / {@code merge}. Used to

View File

@ -368,6 +368,16 @@ public class WikiPageService {
.set(WikiPageEntity::getProfileVersion, profileVersion));
}
/** Set only a page's knowledge layer via a partial update (leaves depends_on untouched). */
public void setKnowledgeLayer(Long pageId, String knowledgeLayer) {
if (pageId == null || knowledgeLayer == null) {
return;
}
pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getId, pageId)
.set(WikiPageEntity::getKnowledgeLayer, knowledgeLayer));
}
/** 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) {

View File

@ -74,6 +74,10 @@ public class WikiProcessingService {
@org.springframework.beans.factory.annotation.Autowired(required = false)
private vip.mate.wiki.profile.WikiMetadataValidator metadataValidator;
/** Optional dependency/stale engine for layered-knowledge wiring. */
@org.springframework.beans.factory.annotation.Autowired(required = false)
private vip.mate.wiki.service.WikiDependencyService dependencyService;
/**
* Read-the-failover-chain handle. Optional so the existing constructors and
* lazy-mode tests don't have to thread a new dependency. When null, the
@ -1170,6 +1174,7 @@ public class WikiProcessingService {
pageType = pageTypeProfileService.normalizePageType(kbId, pageType);
}
JsonNode metadataNode = pageJson.path("metadata");
JsonNode dependsOnNode = pageJson.path("depends_on");
if (content.isBlank()) {
log.info("[Wiki] BatchCreate: blank content for slug='{}', retrying individually", slug);
final String blankSlug = slug;
@ -1198,7 +1203,7 @@ public class WikiProcessingService {
boolean wasCreated = false;
boolean ok = false;
try {
wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType, metadataNode);
wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType, metadataNode, dependsOnNode);
if (wasCreated) {
created.incrementAndGet();
totalCreated++;
@ -1392,18 +1397,24 @@ public class WikiProcessingService {
*/
private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String slug, String title, String content, String pageSummary) {
return savePageContent(kb, raw, slug, title, content, pageSummary, null, null);
return savePageContent(kb, raw, slug, title, content, pageSummary, null, null, null);
}
private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String slug, String title, String content, String pageSummary,
String pageType) {
return savePageContent(kb, raw, slug, title, content, pageSummary, pageType, null);
return savePageContent(kb, raw, slug, title, content, pageSummary, pageType, null, null);
}
private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String slug, String title, String content, String pageSummary,
String pageType, JsonNode metadataNode) {
return savePageContent(kb, raw, slug, title, content, pageSummary, pageType, metadataNode, null);
}
private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String slug, String title, String content, String pageSummary,
String pageType, JsonNode metadataNode, JsonNode dependsOnNode) {
Long kbId = kb.getId();
Long rawId = raw.getId();
@ -1418,7 +1429,7 @@ public class WikiProcessingService {
String actualSlug = existingByCanonical.getSlug();
pageService.updatePageByAi(kbId, actualSlug, content, pageSummary, rawId);
pageService.mergeSourceLineage(existingByCanonical.getId(), rawId, raw.getTitle());
afterPagePersisted(existingByCanonical.getId(), kbId, pageType, metadataNode);
afterPagePersisted(existingByCanonical.getId(), kbId, pageType, metadataNode, dependsOnNode);
log.info("[Wiki] Phase B create slug='{}' canonical-matches existing '{}', updated",
slug, actualSlug);
return false;
@ -1435,7 +1446,7 @@ public class WikiProcessingService {
if (winner != null) {
pageService.updatePageByAi(kbId, winnerSlug, content, pageSummary, rawId);
pageService.mergeSourceLineage(winner.getId(), rawId, raw.getTitle());
afterPagePersisted(winner.getId(), kbId, pageType, metadataNode);
afterPagePersisted(winner.getId(), kbId, pageType, metadataNode, dependsOnNode);
log.info("[Wiki] Phase B create slug='{}' lost slug-claim race to '{}', updated",
slug, winnerSlug);
return false;
@ -1451,7 +1462,7 @@ public class WikiProcessingService {
if (existing != null) {
pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId);
pageService.mergeSourceLineage(existing.getId(), rawId, raw.getTitle());
afterPagePersisted(existing.getId(), kbId, pageType, metadataNode);
afterPagePersisted(existing.getId(), kbId, pageType, metadataNode, dependsOnNode);
log.info("[Wiki] Phase B create page slug='{}' done (updated existing)", slug);
return false;
}
@ -1460,7 +1471,7 @@ public class WikiProcessingService {
try {
WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds, pageType);
pageService.mergeSourceLineage(created.getId(), rawId, raw.getTitle());
afterPagePersisted(created.getId(), kbId, pageType, metadataNode);
afterPagePersisted(created.getId(), kbId, pageType, metadataNode, dependsOnNode);
log.info("[Wiki] Phase B create page slug='{}' done (created)", slug);
citationService.buildCitationsAsync(created.getId(), kbId);
return true;
@ -1469,7 +1480,7 @@ public class WikiProcessingService {
pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId);
WikiPageEntity raced = pageService.getBySlug(kbId, slug);
if (raced != null) {
afterPagePersisted(raced.getId(), kbId, pageType, metadataNode);
afterPagePersisted(raced.getId(), kbId, pageType, metadataNode, dependsOnNode);
}
log.info("[Wiki] Phase B create page slug='{}' lost INSERT race -> updated existing", slug);
return false;
@ -1488,8 +1499,11 @@ public class WikiProcessingService {
* one outlet means the existing-page-update and race-arbitration paths get
* the same metadata and trigger handling as a clean create.
*/
private void afterPagePersisted(Long pageId, Long kbId, String pageType, JsonNode metadataNode) {
private void afterPagePersisted(Long pageId, Long kbId, String pageType,
JsonNode metadataNode, JsonNode dependsOnNode) {
applyValidatedMetadata(pageId, kbId, pageType, metadataNode);
deriveKnowledgeLayer(pageId, kbId, pageType);
applyDependencies(pageId, kbId, pageType, dependsOnNode);
// The page is committed (createPage / updatePageByAi are their own
// transactions), so the count is accurate. Idempotent + dedup-guarded
// downstream, so firing on update paths is safe.
@ -1498,6 +1512,50 @@ public class WikiProcessingService {
}
}
/** Stamp the page's knowledge layer (fact/experience) derived from its pageType profile. */
private void deriveKnowledgeLayer(Long pageId, Long kbId, String pageType) {
if (pageTypeProfileService == null || pageType == null || pageType.isBlank()) {
return;
}
String layer = pageTypeProfileService.resolveLayer(kbId, pageType);
if (layer != null) {
pageService.setKnowledgeLayer(pageId, layer);
}
}
/**
* Persist an experience page's fact dependencies declared by the LLM
* ({@code depends_on}: slugs). Resolves slugs to ids and delegates to the
* dependency engine, which rejects cross-KB / non-fact / missing targets;
* rejections are logged as a warning (non-blocking, MVP).
*/
private void applyDependencies(Long pageId, Long kbId, String pageType, JsonNode dependsOnNode) {
if (dependencyService == null || pageTypeProfileService == null
|| dependsOnNode == null || !dependsOnNode.isArray() || dependsOnNode.isEmpty()) {
return;
}
if (!pageTypeProfileService.isExperience(kbId, pageType)) {
return; // only experience pages declare fact dependencies
}
java.util.List<Long> depIds = new java.util.ArrayList<>();
for (JsonNode n : dependsOnNode) {
String slug = n.asText("");
if (slug.isBlank()) continue;
WikiPageEntity dep = pageService.getBySlug(kbId, slug);
if (dep != null) {
depIds.add(dep.getId());
}
}
try {
java.util.List<String> rejected = dependencyService.setDependencies(kbId, pageId, depIds);
if (!rejected.isEmpty()) {
log.warn("[Wiki] page {} dependency warnings: {}", pageId, rejected);
}
} catch (Exception e) {
log.warn("[Wiki] dependency persistence failed for page {}: {}", pageId, e.getMessage());
}
}
private String emptyOr(String s) {
return (s == null || s.isBlank()) ? "(无)" : s;
}

View File

@ -52,6 +52,7 @@
- `summary`:一段话简短摘要
- `page_type`:页面类型,从下面"允许的页面类型"列表中选一个;都不合适时选 concept。
- `metadata`(可选):与所选 page_type 对应的结构化字段对象,只输出该类型声明的字段(带"required metadata"标注的字段应尽量补全)。
- `depends_on`(可选,仅经验层页面需要):本页所依赖的**事实层页面 slug 数组**。经验层(如 analysis/pattern/regime)必须列出其结论所基于的事实页 slug;事实层页面留空或不输出。
允许的页面类型:
{allowed_page_types}

View File

@ -8,6 +8,7 @@ import vip.mate.wiki.repository.WikiPageTypeProfileMapper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
@ -89,6 +90,26 @@ class WikiPageTypeProfileServiceTest {
assertTrue(fragment.contains("- other"), fragment);
}
@Test
void resolveLayer_defaultsKnownTypesToFact_experienceWhenDeclared() {
WikiPageTypeProfileEntity row = new WikiPageTypeProfileEntity();
row.setKbId(1L);
row.setEnabled(1);
row.setConfigJson("{\"pageTypes\":{"
+ "\"episode\":{\"layer\":\"fact\"},"
+ "\"concept\":{}," // known type, no layer defaults to fact
+ "\"pattern\":{\"layer\":\"experience\"}}}");
when(mapper.selectOne(any())).thenReturn(row);
assertEquals("fact", service.resolveLayer(1L, "episode"));
assertEquals("fact", service.resolveLayer(1L, "concept"));
assertEquals("experience", service.resolveLayer(1L, "pattern"));
assertTrue(service.isExperience(1L, "pattern"));
assertFalse(service.isExperience(1L, "episode"));
// unknown type null (caller leaves layer untouched)
assertNull(service.resolveLayer(1L, "made-up"));
}
@Test
void stageInstructionAndTemplate_areResolvedPerType() {
WikiPageTypeProfileEntity row = new WikiPageTypeProfileEntity();