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 84929af9..750138c8 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 @@ -349,6 +349,25 @@ public class WikiPageService { return entity; } + /** + * Apply schema-validated structured metadata to an existing page via a + * partial column update — only the metadata columns are written, so this + * never disturbs content / summary / links set by the ingest pipeline. + * Null arguments are written as-is (e.g. clearing a prior validation set). + */ + public void applyMetadata(Long pageId, String metadataJson, String validationStatus, + String validationJson, Integer profileVersion) { + if (pageId == null) { + return; + } + pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, pageId) + .set(WikiPageEntity::getMetadataJson, metadataJson) + .set(WikiPageEntity::getMetadataValidationStatus, validationStatus) + .set(WikiPageEntity::getMetadataValidationJson, validationJson) + .set(WikiPageEntity::getProfileVersion, profileVersion)); + } + /** * List pages derived from a specific raw material (for UI sidebar filtering). * Uses a LIKE search on sourceRawIds JSON field — cheap and dialect-agnostic. 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 8650d3a7..286abbf2 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 @@ -70,6 +70,10 @@ public class WikiProcessingService { @org.springframework.beans.factory.annotation.Autowired(required = false) private vip.mate.wiki.profile.WikiPageTypeProfileService pageTypeProfileService; + /** Optional metadata validator, paired with {@link #pageTypeProfileService}. */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + private vip.mate.wiki.profile.WikiMetadataValidator metadataValidator; + /** * 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 @@ -1156,6 +1160,12 @@ public class WikiProcessingService { String content = pageJson.path("content").asText(""); String pageSummary = pageJson.path("summary").asText(""); String pageType = pageJson.path("page_type").asText(""); + // Downgrade an unrecognised type to the profile fallback so the + // stored page_type always belongs to the KB's profile. + if (pageTypeProfileService != null && !pageType.isBlank()) { + pageType = pageTypeProfileService.normalizePageType(kbId, pageType); + } + JsonNode metadataNode = pageJson.path("metadata"); if (content.isBlank()) { log.info("[Wiki] BatchCreate: blank content for slug='{}', retrying individually", slug); final String blankSlug = slug; @@ -1184,7 +1194,7 @@ public class WikiProcessingService { boolean wasCreated = false; boolean ok = false; try { - wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType); + wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType, metadataNode); if (wasCreated) { created.incrementAndGet(); totalCreated++; @@ -1376,12 +1386,18 @@ 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); + return savePageContent(kb, raw, slug, title, content, pageSummary, 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); + } + + private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, + String slug, String title, String content, String pageSummary, + String pageType, JsonNode metadataNode) { Long kbId = kb.getId(); Long rawId = raw.getId(); @@ -1434,6 +1450,7 @@ public class WikiProcessingService { String sourceRawIds = "[" + rawId + "]"; try { WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds, pageType); + applyValidatedMetadata(created, kbId, pageType, metadataNode); pageService.mergeSourceLineage(created.getId(), rawId, raw.getTitle()); log.info("[Wiki] Phase B create page slug='{}' done (created)", slug); citationService.buildCitationsAsync(created.getId(), kbId); @@ -1446,6 +1463,38 @@ public class WikiProcessingService { } } + /** + * Validate the LLM-supplied metadata for a freshly created page against the + * KB profile's pageType schema and persist the cleaned result plus its + * validation outcome. No-op when the profile/validator beans are absent or + * no metadata was supplied — so default-profile KBs are unaffected. + */ + private void applyValidatedMetadata(WikiPageEntity created, Long kbId, String pageType, + JsonNode metadataNode) { + if (created == null || pageTypeProfileService == null || metadataValidator == null) { + return; + } + if (metadataNode == null || metadataNode.isMissingNode() || metadataNode.isNull() + || !metadataNode.isObject() || metadataNode.isEmpty()) { + return; + } + try { + vip.mate.wiki.profile.WikiPageTypeProfile profile = pageTypeProfileService.resolveProfile(kbId); + vip.mate.wiki.profile.WikiPageTypeDef def = profile.get(pageType); + @SuppressWarnings("unchecked") + java.util.Map raw = objectMapper.convertValue(metadataNode, java.util.Map.class); + vip.mate.wiki.profile.WikiMetadataValidator.ValidationResult result = + metadataValidator.validate(def, raw, profile.isAllowAdditionalFields(), "create"); + String metadataJson = objectMapper.writeValueAsString(result.getCleaned()); + String validationJson = result.getWarnings().isEmpty() + ? null : objectMapper.writeValueAsString(result.getWarnings()); + pageService.applyMetadata(created.getId(), metadataJson, result.getStatus(), + validationJson, profile.getVersion()); + } catch (Exception e) { + log.warn("[Wiki] metadata validation failed for page {}: {}", created.getId(), e.getMessage()); + } + } + /** * RFC-012 M2 v2 — 阶段 B 单页 merge:把 chunk 文本合并进一个已有页面。 *

diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageMetadataE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageMetadataE2ETest.java new file mode 100644 index 00000000..7334c007 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageMetadataE2ETest.java @@ -0,0 +1,64 @@ +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Verifies the structured-metadata persistence path against H2: applyMetadata + * writes the metadata columns without disturbing the page content (partial + * column update). + */ +@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 WikiPageMetadataE2ETest { + + @Autowired + private WikiPageService pageService; + + @Test + void applyMetadata_persistsColumns_withoutTouchingContent() { + long kb = 6001L; + WikiPageEntity page = pageService.createPage(kb, "episode-x", "Episode X", + "## Body\n\noriginal content", "summary", "[1]", "episode"); + assertNotNull(page.getId()); + + pageService.applyMetadata(page.getId(), + "{\"event_date\":\"2024-09-18\"}", "ok", null, 3); + + WikiPageEntity loaded = pageService.getBySlug(kb, "episode-x"); + assertEquals("{\"event_date\":\"2024-09-18\"}", loaded.getMetadataJson()); + assertEquals("ok", loaded.getMetadataValidationStatus()); + assertEquals(3, loaded.getProfileVersion()); + // Partial update must not have wiped content / summary. + assertEquals("## Body\n\noriginal content", loaded.getContent()); + assertEquals("summary", loaded.getSummary()); + } + + @Test + void applyMetadata_warningStatusAndJson() { + long kb = 6002L; + WikiPageEntity page = pageService.createPage(kb, "episode-y", "Episode Y", + "body", "summary", "[1]", "episode"); + + pageService.applyMetadata(page.getId(), + "{\"event_date\":\"bad\"}", "warning", + "[{\"field\":\"event_date\",\"reason\":\"expected ISO date YYYY-MM-DD\"}]", 1); + + WikiPageEntity loaded = pageService.getBySlug(kb, "episode-y"); + assertEquals("warning", loaded.getMetadataValidationStatus()); + assertNotNull(loaded.getMetadataValidationJson()); + } +}