feat(wiki): layered-knowledge schema — layer, dependency table, stale columns

This commit is contained in:
matevip 2026-05-31 07:55:55 +08:00
parent 7a79fe93d0
commit 28284fbcac
7 changed files with 229 additions and 0 deletions

View File

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

View File

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

View File

@ -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<String, WikiFieldSchema> schema = new LinkedHashMap<>();

View File

@ -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<WikiPageDependencyEntity> {
}

View File

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

View File

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

View File

@ -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<WikiPageDependencyEntity> dependents = mapper.selectList(
Wrappers.<WikiPageDependencyEntity>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)));
}
}