mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(wiki): resolve effective pageType profile per KB with built-in default
This commit is contained in:
parent
368797e619
commit
7f4987c30a
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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." }
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user