diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java index b286be29..a0592eaa 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java @@ -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 diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index 5b4ffade..9645c0ba 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -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() + .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) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index eaae6985..978ca110 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -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 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 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; } diff --git a/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt index 6d0a1612..fd674d15 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt @@ -52,6 +52,7 @@ - `summary`:一段话简短摘要 - `page_type`:页面类型,从下面"允许的页面类型"列表中选一个;都不合适时选 concept。 - `metadata`(可选):与所选 page_type 对应的结构化字段对象,只输出该类型声明的字段(带"required metadata"标注的字段应尽量补全)。 +- `depends_on`(可选,仅经验层页面需要):本页所依赖的**事实层页面 slug 数组**。经验层(如 analysis/pattern/regime)必须列出其结论所基于的事实页 slug;事实层页面留空或不输出。 允许的页面类型: {allowed_page_types} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java index 9a0c72f8..2bebde45 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java @@ -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();