From 7f4987c30a8b57e30838970add780056bc0b2f6f Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 31 May 2026 07:54:13 +0800 Subject: [PATCH] feat(wiki): resolve effective pageType profile per KB with built-in default --- .../mate/wiki/profile/WikiFieldSchema.java | 25 +++++ .../mate/wiki/profile/WikiPageTypeDef.java | 51 +++++++++ .../wiki/profile/WikiPageTypeProfile.java | 54 +++++++++ .../profile/WikiPageTypeProfileService.java | 105 ++++++++++++++++++ .../wiki/default-page-type-profile.json | 17 +++ .../WikiPageTypeProfileServiceTest.java | 82 ++++++++++++++ 6 files changed, 334 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiFieldSchema.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfile.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java create mode 100644 mateclaw-server/src/main/resources/prompts/wiki/default-page-type-profile.json create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiFieldSchema.java b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiFieldSchema.java new file mode 100644 index 00000000..cc7957ac --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiFieldSchema.java @@ -0,0 +1,25 @@ +package vip.mate.wiki.profile; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +/** + * Schema for one pageType metadata field within a {@link WikiPageTypeDef}. + * + * @author MateClaw Team + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class WikiFieldSchema { + + /** Field type: string / number / boolean / date / enum / string_array. */ + private String type; + + /** Whether the field must be present and non-empty. */ + private boolean required; + + /** Allowed values when {@link #type} is {@code enum}. */ + private List values; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java new file mode 100644 index 00000000..01c39f07 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java @@ -0,0 +1,51 @@ +package vip.mate.wiki.profile; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Definition of a single pageType within a {@link WikiPageTypeProfile}: + * a human label, optional description, the metadata field schema, the + * per-stage LLM instructions and an optional Markdown template. + * + * @author MateClaw Team + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class WikiPageTypeDef { + + /** Human-readable label, e.g. "Episode". */ + private String label; + + /** Short description of what this page type represents. */ + private String description; + + /** Field name → schema. Insertion order preserved for prompt rendering. */ + private Map schema = new LinkedHashMap<>(); + + /** Optional stage instructions for route / create / merge. */ + private StageInstructions route; + private StageInstructions create; + private StageInstructions merge; + + /** Optional Markdown template metadata. */ + private Template template; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class StageInstructions { + private String instructions; + /** Optional template key referenced by the create stage. */ + private String template; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Template { + private String key; + private String markdown; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfile.java b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfile.java new file mode 100644 index 00000000..32de2be7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfile.java @@ -0,0 +1,54 @@ +package vip.mate.wiki.profile; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A parsed KB pageType profile: the page types a KB recognises plus + * profile-wide options. Deserialized from a profile row's {@code config_json} + * or supplied as the built-in default by + * {@link WikiPageTypeProfileService}. + * + * @author MateClaw Team + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class WikiPageTypeProfile { + + /** Config schema version. */ + private int version = 1; + + /** pageType name (lowercase) → definition. Insertion order preserved. */ + private Map pageTypes = new LinkedHashMap<>(); + + /** + * When a routed/created page declares a type absent from {@link #pageTypes}, + * it is downgraded to this type. Defaults to {@code concept}. + */ + private String fallbackType = "concept"; + + /** + * When {@code true}, metadata fields not declared in a type's schema are + * kept; otherwise they are dropped (with a validation warning). + */ + private boolean allowAdditionalFields = false; + + /** Whether this profile declares the given pageType (case-insensitive). */ + public boolean hasPageType(String pageType) { + if (pageType == null) { + return false; + } + return pageTypes.containsKey(pageType.trim().toLowerCase()); + } + + /** Lookup a definition by name (case-insensitive), or {@code null}. */ + public WikiPageTypeDef get(String pageType) { + if (pageType == null) { + return null; + } + return pageTypes.get(pageType.trim().toLowerCase()); + } +} 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 new file mode 100644 index 00000000..8ce2e6cc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java @@ -0,0 +1,105 @@ +package vip.mate.wiki.profile; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import vip.mate.wiki.model.WikiPageTypeProfileEntity; +import vip.mate.wiki.repository.WikiPageTypeProfileMapper; + +import java.io.InputStream; +import java.util.Set; + +/** + * Resolves the effective pageType profile for a knowledge base. + * + *

Resolution: the KB's single enabled {@code mate_wiki_page_type_profile} + * row (parsed from {@code config_json}); when absent or unparseable, the + * built-in default profile loaded from + * {@code classpath:prompts/wiki/default-page-type-profile.json}. The default + * is never stored as a row — existing KBs keep working with zero migration. + * + * @author MateClaw Team + */ +@Slf4j +@Service +public class WikiPageTypeProfileService { + + private static final String DEFAULT_RESOURCE = "prompts/wiki/default-page-type-profile.json"; + + private final WikiPageTypeProfileMapper profileMapper; + private final ObjectMapper objectMapper; + + /** Parsed once at startup; immutable thereafter. */ + private WikiPageTypeProfile defaultProfile; + + public WikiPageTypeProfileService(WikiPageTypeProfileMapper profileMapper, ObjectMapper objectMapper) { + this.profileMapper = profileMapper; + this.objectMapper = objectMapper; + } + + @PostConstruct + void loadDefault() { + try (InputStream in = new ClassPathResource(DEFAULT_RESOURCE).getInputStream()) { + this.defaultProfile = objectMapper.readValue(in, WikiPageTypeProfile.class); + } catch (Exception e) { + log.error("[WikiProfile] Failed to load default pageType profile from {} — " + + "falling back to an empty profile", DEFAULT_RESOURCE, e); + this.defaultProfile = new WikiPageTypeProfile(); + } + } + + /** The built-in default profile (shared, do not mutate). */ + public WikiPageTypeProfile getDefaultProfile() { + return defaultProfile; + } + + /** + * The effective profile for a KB: its enabled profile row, or the built-in + * default when none is configured (or the stored config fails to parse). + */ + public WikiPageTypeProfile resolveProfile(Long kbId) { + if (kbId == null) { + return defaultProfile; + } + WikiPageTypeProfileEntity row = profileMapper.selectOne( + new LambdaQueryWrapper() + .eq(WikiPageTypeProfileEntity::getKbId, kbId) + .eq(WikiPageTypeProfileEntity::getEnabled, 1) + .last("LIMIT 1")); + if (row == null || row.getConfigJson() == null || row.getConfigJson().isBlank()) { + return defaultProfile; + } + try { + WikiPageTypeProfile parsed = objectMapper.readValue(row.getConfigJson(), WikiPageTypeProfile.class); + // Carry the stored row version so callers can stamp page.profile_version. + parsed.setVersion(row.getVersion() != null ? row.getVersion() : parsed.getVersion()); + return parsed; + } catch (Exception e) { + log.warn("[WikiProfile] KB {} has an unparseable profile config — using default. {}", + kbId, e.getMessage()); + return defaultProfile; + } + } + + /** The set of pageType names allowed for a KB (lowercase). */ + public Set allowedPageTypes(Long kbId) { + return resolveProfile(kbId).getPageTypes().keySet(); + } + + /** + * Normalise a routed/created pageType against the KB profile: a declared + * type is returned as-is (lowercase); an unknown type is downgraded to the + * profile's {@code fallbackType}. Never returns null. + */ + public String normalizePageType(Long kbId, String pageType) { + WikiPageTypeProfile profile = resolveProfile(kbId); + if (pageType != null && profile.hasPageType(pageType)) { + return pageType.trim().toLowerCase(); + } + String fallback = profile.getFallbackType(); + return fallback == null ? "concept" : fallback.trim().toLowerCase(); + } +} diff --git a/mateclaw-server/src/main/resources/prompts/wiki/default-page-type-profile.json b/mateclaw-server/src/main/resources/prompts/wiki/default-page-type-profile.json new file mode 100644 index 00000000..423fe5ff --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/default-page-type-profile.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "fallbackType": "concept", + "allowAdditionalFields": true, + "pageTypes": { + "concept": { "label": "Concept", "description": "An abstract idea, theory, method or definition." }, + "person": { "label": "Person", "description": "An individual." }, + "place": { "label": "Place", "description": "A geographic location." }, + "event": { "label": "Event", "description": "A dated occurrence." }, + "technology": { "label": "Technology", "description": "A tool, system, framework or technique." }, + "organization": { "label": "Organization", "description": "A company, institution or group." }, + "product": { "label": "Product", "description": "A named product or offering." }, + "term": { "label": "Term", "description": "A glossary term or piece of terminology." }, + "process": { "label": "Process", "description": "A procedure, workflow or sequence of steps." }, + "other": { "label": "Other", "description": "Anything that does not fit the other 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 new file mode 100644 index 00000000..6f9a15b2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java @@ -0,0 +1,82 @@ +package vip.mate.wiki.profile; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.model.WikiPageTypeProfileEntity; +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link WikiPageTypeProfileService}: default profile loading, + * KB profile resolution and fallback behaviour. The default profile is loaded + * from the real classpath resource; the mapper is mocked. + */ +class WikiPageTypeProfileServiceTest { + + private WikiPageTypeProfileMapper mapper; + private WikiPageTypeProfileService service; + + @BeforeEach + void setUp() { + mapper = mock(WikiPageTypeProfileMapper.class); + service = new WikiPageTypeProfileService(mapper, new ObjectMapper()); + service.loadDefault(); + } + + @Test + void defaultProfileReproducesBuiltInPageTypes() { + WikiPageTypeProfile def = service.getDefaultProfile(); + assertTrue(def.hasPageType("concept")); + assertTrue(def.hasPageType("person")); + assertTrue(def.hasPageType("process")); + assertTrue(def.hasPageType("other")); + assertEquals(10, def.getPageTypes().size()); + } + + @Test + void noConfiguredProfile_resolvesToDefault() { + when(mapper.selectOne(any())).thenReturn(null); + assertTrue(service.allowedPageTypes(42L).contains("concept")); + } + + @Test + void configuredProfile_isParsedAndVersionStamped() { + WikiPageTypeProfileEntity row = new WikiPageTypeProfileEntity(); + row.setKbId(42L); + row.setVersion(5); + row.setEnabled(1); + row.setConfigJson("{\"version\":1,\"pageTypes\":{\"episode\":{\"label\":\"Episode\"}}}"); + when(mapper.selectOne(any())).thenReturn(row); + + WikiPageTypeProfile resolved = service.resolveProfile(42L); + assertTrue(resolved.hasPageType("episode")); + assertFalse(resolved.hasPageType("concept")); + assertEquals(5, resolved.getVersion()); // stamped from the row + } + + @Test + void unparseableConfig_fallsBackToDefault() { + WikiPageTypeProfileEntity row = new WikiPageTypeProfileEntity(); + row.setKbId(42L); + row.setEnabled(1); + row.setConfigJson("{ not valid json"); + when(mapper.selectOne(any())).thenReturn(row); + + assertTrue(service.resolveProfile(42L).hasPageType("concept")); + } + + @Test + void normalizePageType_keepsKnown_downgradesUnknown() { + when(mapper.selectOne(any())).thenReturn(null); // default profile + assertEquals("person", service.normalizePageType(1L, "Person")); + assertEquals("concept", service.normalizePageType(1L, "made-up-type")); + assertEquals("concept", service.normalizePageType(1L, null)); + } +}