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(); 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. * The per-stage LLM instruction declared for a pageType, or empty string.
* {@code stage} is {@code route} / {@code create} / {@code merge}. Used to * {@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(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. */ /** Set a page's knowledge layer and depends-on snapshot via a partial update. */
public void setLayerAndDependencies(Long pageId, String knowledgeLayer, String dependsOnJson) { public void setLayerAndDependencies(Long pageId, String knowledgeLayer, String dependsOnJson) {
if (pageId == null) { if (pageId == null) {

View File

@ -74,6 +74,10 @@ public class WikiProcessingService {
@org.springframework.beans.factory.annotation.Autowired(required = false) @org.springframework.beans.factory.annotation.Autowired(required = false)
private vip.mate.wiki.profile.WikiMetadataValidator metadataValidator; 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 * 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 * 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); pageType = pageTypeProfileService.normalizePageType(kbId, pageType);
} }
JsonNode metadataNode = pageJson.path("metadata"); JsonNode metadataNode = pageJson.path("metadata");
JsonNode dependsOnNode = pageJson.path("depends_on");
if (content.isBlank()) { if (content.isBlank()) {
log.info("[Wiki] BatchCreate: blank content for slug='{}', retrying individually", slug); log.info("[Wiki] BatchCreate: blank content for slug='{}', retrying individually", slug);
final String blankSlug = slug; final String blankSlug = slug;
@ -1198,7 +1203,7 @@ public class WikiProcessingService {
boolean wasCreated = false; boolean wasCreated = false;
boolean ok = false; boolean ok = false;
try { try {
wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType, metadataNode); wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType, metadataNode, dependsOnNode);
if (wasCreated) { if (wasCreated) {
created.incrementAndGet(); created.incrementAndGet();
totalCreated++; totalCreated++;
@ -1392,18 +1397,24 @@ public class WikiProcessingService {
*/ */
private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String slug, String title, String content, String pageSummary) { 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, private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String slug, String title, String content, String pageSummary, String slug, String title, String content, String pageSummary,
String pageType) { 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, private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String slug, String title, String content, String pageSummary, String slug, String title, String content, String pageSummary,
String pageType, JsonNode metadataNode) { 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 kbId = kb.getId();
Long rawId = raw.getId(); Long rawId = raw.getId();
@ -1418,7 +1429,7 @@ public class WikiProcessingService {
String actualSlug = existingByCanonical.getSlug(); String actualSlug = existingByCanonical.getSlug();
pageService.updatePageByAi(kbId, actualSlug, content, pageSummary, rawId); pageService.updatePageByAi(kbId, actualSlug, content, pageSummary, rawId);
pageService.mergeSourceLineage(existingByCanonical.getId(), rawId, raw.getTitle()); 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", log.info("[Wiki] Phase B create slug='{}' canonical-matches existing '{}', updated",
slug, actualSlug); slug, actualSlug);
return false; return false;
@ -1435,7 +1446,7 @@ public class WikiProcessingService {
if (winner != null) { if (winner != null) {
pageService.updatePageByAi(kbId, winnerSlug, content, pageSummary, rawId); pageService.updatePageByAi(kbId, winnerSlug, content, pageSummary, rawId);
pageService.mergeSourceLineage(winner.getId(), rawId, raw.getTitle()); 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", log.info("[Wiki] Phase B create slug='{}' lost slug-claim race to '{}', updated",
slug, winnerSlug); slug, winnerSlug);
return false; return false;
@ -1451,7 +1462,7 @@ public class WikiProcessingService {
if (existing != null) { if (existing != null) {
pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId); pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId);
pageService.mergeSourceLineage(existing.getId(), rawId, raw.getTitle()); 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); log.info("[Wiki] Phase B create page slug='{}' done (updated existing)", slug);
return false; return false;
} }
@ -1460,7 +1471,7 @@ public class WikiProcessingService {
try { try {
WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds, pageType); WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds, pageType);
pageService.mergeSourceLineage(created.getId(), rawId, raw.getTitle()); 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); log.info("[Wiki] Phase B create page slug='{}' done (created)", slug);
citationService.buildCitationsAsync(created.getId(), kbId); citationService.buildCitationsAsync(created.getId(), kbId);
return true; return true;
@ -1469,7 +1480,7 @@ public class WikiProcessingService {
pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId); pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId);
WikiPageEntity raced = pageService.getBySlug(kbId, slug); WikiPageEntity raced = pageService.getBySlug(kbId, slug);
if (raced != null) { 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); log.info("[Wiki] Phase B create page slug='{}' lost INSERT race -> updated existing", slug);
return false; return false;
@ -1488,8 +1499,11 @@ public class WikiProcessingService {
* one outlet means the existing-page-update and race-arbitration paths get * one outlet means the existing-page-update and race-arbitration paths get
* the same metadata and trigger handling as a clean create. * 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); applyValidatedMetadata(pageId, kbId, pageType, metadataNode);
deriveKnowledgeLayer(pageId, kbId, pageType);
applyDependencies(pageId, kbId, pageType, dependsOnNode);
// The page is committed (createPage / updatePageByAi are their own // The page is committed (createPage / updatePageByAi are their own
// transactions), so the count is accurate. Idempotent + dedup-guarded // transactions), so the count is accurate. Idempotent + dedup-guarded
// downstream, so firing on update paths is safe. // 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) { private String emptyOr(String s) {
return (s == null || s.isBlank()) ? "(无)" : s; return (s == null || s.isBlank()) ? "(无)" : s;
} }

View File

@ -52,6 +52,7 @@
- `summary`:一段话简短摘要 - `summary`:一段话简短摘要
- `page_type`:页面类型,从下面"允许的页面类型"列表中选一个;都不合适时选 concept。 - `page_type`:页面类型,从下面"允许的页面类型"列表中选一个;都不合适时选 concept。
- `metadata`(可选):与所选 page_type 对应的结构化字段对象,只输出该类型声明的字段(带"required metadata"标注的字段应尽量补全)。 - `metadata`(可选):与所选 page_type 对应的结构化字段对象,只输出该类型声明的字段(带"required metadata"标注的字段应尽量补全)。
- `depends_on`(可选,仅经验层页面需要):本页所依赖的**事实层页面 slug 数组**。经验层(如 analysis/pattern/regime)必须列出其结论所基于的事实页 slug;事实层页面留空或不输出。
允许的页面类型: 允许的页面类型:
{allowed_page_types} {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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@ -89,6 +90,26 @@ class WikiPageTypeProfileServiceTest {
assertTrue(fragment.contains("- other"), fragment); 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 @Test
void stageInstructionAndTemplate_areResolvedPerType() { void stageInstructionAndTemplate_areResolvedPerType() {
WikiPageTypeProfileEntity row = new WikiPageTypeProfileEntity(); WikiPageTypeProfileEntity row = new WikiPageTypeProfileEntity();