feat(wiki): validate and persist structured page metadata on ingest

This commit is contained in:
matevip 2026-05-31 07:54:41 +08:00
parent b50e384e0d
commit eb63ce4865
3 changed files with 134 additions and 2 deletions

View File

@ -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<WikiPageEntity>()
.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.

View File

@ -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<String, Object> 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 文本合并进一个已有页面
* <p>

View File

@ -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());
}
}