From 28284fbcacb6882cabca8c831891090aa7d5bc47 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 31 May 2026 07:55:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(wiki):=20layered-knowledge=20schema=20?= =?UTF-8?q?=E2=80=94=20layer,=20dependency=20table,=20stale=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wiki/model/WikiPageDependencyEntity.java | 48 ++++++++++++ .../vip/mate/wiki/model/WikiPageEntity.java | 14 ++++ .../mate/wiki/profile/WikiPageTypeDef.java | 7 ++ .../repository/WikiPageDependencyMapper.java | 14 ++++ .../h2/V135__wiki_layered_knowledge.sql | 29 ++++++++ .../mysql/V135__wiki_layered_knowledge.sql | 44 +++++++++++ .../WikiPageDependencyMapperE2ETest.java | 73 +++++++++++++++++++ 7 files changed, 229 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageDependencyEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageDependencyMapper.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V135__wiki_layered_knowledge.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V135__wiki_layered_knowledge.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageDependencyMapperE2ETest.java diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageDependencyEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageDependencyEntity.java new file mode 100644 index 00000000..5453d753 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageDependencyEntity.java @@ -0,0 +1,48 @@ +package vip.mate.wiki.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * A dependency edge from an experience page to a fact page it relies on. + * The reverse index ({@code depends_on_page_id}) drives stale propagation: + * when a fact page changes, the experience pages depending on it are marked + * stale. Stored by page id (never slug) so renames cannot break the edge. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_wiki_page_dependency") +public class WikiPageDependencyEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** Knowledge base both pages belong to (cross-KB dependencies are rejected). */ + private Long kbId; + + /** The dependent (experience) page. */ + private Long pageId; + + /** The fact page being depended on. */ + private Long dependsOnPageId; + + /** Dependency kind; {@code fact} for the fact→experience relation. */ + private String dependencyType; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java index cc92549b..31df23be 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java @@ -71,6 +71,20 @@ public class WikiPageEntity { /** Profile version in effect when the page was generated or last validated. */ private Integer profileVersion; + /** Knowledge layer derived from the pageType profile: {@code fact} / {@code experience}. */ + private String knowledgeLayer; + + /** Fact page ids this (experience) page depends on, as a JSON array. Source of truth is the dependency table. */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String dependsOnJson; + + /** {@code 1} when an upstream fact page changed and this page may be out of date. */ + private Integer stale; + + /** Why the page is stale (fact page id, time, reason) as JSON. */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String staleReasonJson; + /** Purpose hint for LLM ingest routing */ private String purposeHint; 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 index 01c39f07..15b03b53 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java @@ -23,6 +23,13 @@ public class WikiPageTypeDef { /** Short description of what this page type represents. */ private String description; + /** + * Knowledge layer this page type belongs to: {@code fact} ("what is") or + * {@code experience} ("what it means"). Extensible — MVP recognises these + * two; {@code null} means unspecified (treated as fact for retrieval). + */ + private String layer; + /** Field name → schema. Insertion order preserved for prompt rendering. */ private Map schema = new LinkedHashMap<>(); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageDependencyMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageDependencyMapper.java new file mode 100644 index 00000000..ac9b1863 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageDependencyMapper.java @@ -0,0 +1,14 @@ +package vip.mate.wiki.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.wiki.model.WikiPageDependencyEntity; + +/** + * Mapper for {@link WikiPageDependencyEntity}. + * + * @author MateClaw Team + */ +@Mapper +public interface WikiPageDependencyMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V135__wiki_layered_knowledge.sql b/mateclaw-server/src/main/resources/db/migration/h2/V135__wiki_layered_knowledge.sql new file mode 100644 index 00000000..e8d81b5b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V135__wiki_layered_knowledge.sql @@ -0,0 +1,29 @@ +-- V135: Layered knowledge (fact / experience) + page dependency graph. +-- +-- knowledge_layer is derived from the pageType profile (fact = "what is", +-- experience = "what it means"). Experience pages depend on fact pages; the +-- dependency table is the source of truth for stale propagation (reverse +-- lookup by depends_on_page_id), with the page-local depends_on_json kept as +-- a redundant copy. Stored by page id, never slug, so renames cannot break a +-- dependency. + +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS knowledge_layer VARCHAR(16); +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS depends_on_json CLOB; +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS stale TINYINT NOT NULL DEFAULT 0; +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS stale_reason_json CLOB; + +CREATE TABLE IF NOT EXISTS mate_wiki_page_dependency ( + id BIGINT NOT NULL PRIMARY KEY, + kb_id BIGINT NOT NULL, + page_id BIGINT NOT NULL, + depends_on_page_id BIGINT NOT NULL, + dependency_type VARCHAR(32) NOT NULL DEFAULT 'fact', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wiki_page_dep + ON mate_wiki_page_dependency (page_id, depends_on_page_id, dependency_type, deleted); +-- Reverse lookup for stale propagation: "who depends on this fact page". +CREATE INDEX IF NOT EXISTS idx_wiki_page_dep_reverse + ON mate_wiki_page_dependency (kb_id, depends_on_page_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V135__wiki_layered_knowledge.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V135__wiki_layered_knowledge.sql new file mode 100644 index 00000000..6c7ffb3c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V135__wiki_layered_knowledge.sql @@ -0,0 +1,44 @@ +-- V135: Layered knowledge (fact / experience) + page dependency graph. +-- See the H2 file for rationale. MySQL uses INFORMATION_SCHEMA guards for the +-- idempotent column adds. + +SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'knowledge_layer'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN knowledge_layer VARCHAR(16)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'depends_on_json'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN depends_on_json LONGTEXT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'stale'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN stale TINYINT NOT NULL DEFAULT 0', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'stale_reason_json'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN stale_reason_json LONGTEXT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS mate_wiki_page_dependency ( + id BIGINT NOT NULL PRIMARY KEY, + kb_id BIGINT NOT NULL, + page_id BIGINT NOT NULL, + depends_on_page_id BIGINT NOT NULL, + dependency_type VARCHAR(32) NOT NULL DEFAULT 'fact', + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_wiki_page_dep (page_id, depends_on_page_id, dependency_type, deleted), + KEY idx_wiki_page_dep_reverse (kb_id, depends_on_page_id, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageDependencyMapperE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageDependencyMapperE2ETest.java new file mode 100644 index 00000000..3f4e5ab6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageDependencyMapperE2ETest.java @@ -0,0 +1,73 @@ +package vip.mate.wiki.repository; + +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 com.baomidou.mybatisplus.core.toolkit.Wrappers; +import vip.mate.wiki.model.WikiPageDependencyEntity; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Validates the page dependency table against H2: reverse lookup by + * depends_on_page_id finds dependents, and the unique key prevents duplicate + * edges. + */ +@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 WikiPageDependencyMapperE2ETest { + + @Autowired + private WikiPageDependencyMapper mapper; + + private static final java.util.concurrent.atomic.AtomicLong SEQ = + new java.util.concurrent.atomic.AtomicLong(System.nanoTime()); + + private WikiPageDependencyEntity edge(long kb, long page, long dependsOn) { + WikiPageDependencyEntity e = new WikiPageDependencyEntity(); + e.setKbId(kb); + e.setPageId(page); + e.setDependsOnPageId(dependsOn); + e.setDependencyType("fact"); + e.setCreateTime(LocalDateTime.now()); + e.setUpdateTime(LocalDateTime.now()); + return e; + } + + @Test + void reverseLookupFindsDependents() { + long kb = SEQ.incrementAndGet(); + long fact = SEQ.incrementAndGet(); + long expA = SEQ.incrementAndGet(); + long expB = SEQ.incrementAndGet(); + mapper.insert(edge(kb, expA, fact)); + mapper.insert(edge(kb, expB, fact)); + + List dependents = mapper.selectList( + Wrappers.lambdaQuery() + .eq(WikiPageDependencyEntity::getKbId, kb) + .eq(WikiPageDependencyEntity::getDependsOnPageId, fact)); + assertEquals(2, dependents.size()); + } + + @Test + void duplicateEdgeRejected() { + long kb = SEQ.incrementAndGet(); + long page = SEQ.incrementAndGet(); + long fact = SEQ.incrementAndGet(); + mapper.insert(edge(kb, page, fact)); + assertThrows(Exception.class, () -> mapper.insert(edge(kb, page, fact))); + } +}