feat(wiki): resolve effective pageType profile per KB with built-in default

This commit is contained in:
matevip 2026-05-31 07:54:13 +08:00
parent 368797e619
commit 7f4987c30a
6 changed files with 334 additions and 0 deletions

View File

@ -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<String> values;
}

View File

@ -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<String, WikiFieldSchema> 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;
}
}

View File

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

View File

@ -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.
*
* <p>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<WikiPageTypeProfileEntity>()
.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<String> 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();
}
}

View File

@ -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." }
}
}

View File

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