From 1281153aa818ffa48cb6212cffc106a9e4c1fea8 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 25 Apr 2026 09:56:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(wiki):=20PR-2=20system=20pages=20=E2=80=94?= =?UTF-8?q?=20overview/log=20scaffold=20+=20locked=20+=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/vip/mate/wiki/dto/WikiPageLite.java | 18 ++- .../vip/mate/wiki/model/WikiPageEntity.java | 7 ++ .../mate/wiki/repository/WikiPageMapper.java | 4 +- .../mate/wiki/service/HybridRetriever.java | 2 + .../service/WikiKnowledgeBaseService.java | 14 +++ .../mate/wiki/service/WikiPageService.java | 25 ++++ .../wiki/service/WikiProcessingService.java | 14 +++ .../wiki/service/WikiRelationService.java | 2 + .../wiki/service/WikiScaffoldService.java | 111 ++++++++++++++++++ .../java/vip/mate/wiki/tool/WikiTool.java | 16 ++- .../migration/h2/V40__rfc051_page_locked.sql | 4 + .../mysql/V40__rfc051_page_locked.sql | 5 + 12 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java index 2246c6f8..af892600 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java @@ -2,5 +2,21 @@ package vip.mate.wiki.dto; /** * RFC-029: Lightweight page projection without content. + *

+ * RFC-051 PR-2: {@code pageType} added so callers can default-filter system + * pages from list/search/related results. Backwards-compatible 4-arg + * factory keeps older constructors (e.g. {@code new WikiPageLite(id, slug, + * title, summary)}) compiling — they yield {@code pageType=null}, which the + * filter treats as "not system". */ -public record WikiPageLite(Long id, String slug, String title, String summary) {} +public record WikiPageLite(Long id, String slug, String title, String summary, String pageType) { + + public WikiPageLite(Long id, String slug, String title, String summary) { + this(id, slug, title, summary, null); + } + + /** True when this page should be hidden from default tool/search results. */ + public boolean isSystem() { + return "system".equals(pageType); + } +} 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 785607c4..a6cf03ad 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 @@ -58,6 +58,13 @@ public class WikiPageEntity { /** 最后更新者:ai / manual */ private String lastUpdatedBy; + /** + * RFC-051 PR-2: protection flag. {@code locked=1} blocks AI/tool/UI deletion + * and batch cleanup; combined with {@code pageType="system"} for the + * built-in {@code overview} / {@code log} pages. + */ + private Integer locked; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java index 4d2ef695..727454a1 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java @@ -35,7 +35,7 @@ public interface WikiPageMapper extends BaseMapper { /** * Batch-fetch lightweight page projections by IDs (no content). */ - @Select("") List selectBatchLite(@Param("ids") Collection ids); @@ -43,7 +43,7 @@ public interface WikiPageMapper extends BaseMapper { /** * List all pages as lightweight projections (no content). */ - @Select("SELECT id, slug, title, summary FROM mate_wiki_page " + + @Select("SELECT id, slug, title, summary, page_type AS pageType FROM mate_wiki_page " + "WHERE kb_id = #{kbId} AND deleted = 0 ORDER BY update_time DESC") List selectAllLite(@Param("kbId") Long kbId); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java index c2042632..fd17c30c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java @@ -117,6 +117,8 @@ public class HybridRetriever { for (RankedItem ri : fused.stream().limit(topK).toList()) { WikiPageLite lite = liteMap.get(ri.pageId); if (lite == null) continue; + // RFC-051 PR-2: hide system pages (overview / log) from search results. + if (lite.isSystem()) continue; String snippet = null; if (!ri.matchedBy.contains("relation_boost")) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index 41fcf5b9..6930004d 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -22,6 +22,16 @@ public class WikiKnowledgeBaseService { private final WikiKnowledgeBaseMapper kbMapper; + /** + * RFC-051 PR-2: optional system-page scaffold (overview / log). Marked + * required=false + Lazy so the KB service has no construction dependency + * on a service that needs WikiPageService — handy for the older tests that + * still wire this class manually. + */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + @org.springframework.context.annotation.Lazy + private WikiScaffoldService scaffoldService; + private static final String DEFAULT_CONFIG = """ # Wiki Processing Rules @@ -96,6 +106,10 @@ public class WikiKnowledgeBaseService { entity.setRawCount(0); kbMapper.insert(entity); log.info("[Wiki] Knowledge base created: id={}, name={}, workspaceId={}", entity.getId(), name, workspaceId); + // RFC-051 PR-2: ensure overview / log system pages exist for every new KB. + if (scaffoldService != null) { + scaffoldService.ensureScaffold(entity.getId()); + } return entity; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index 6b296f7a..d1305c8e 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -374,8 +374,30 @@ public class WikiPageService { .collect(Collectors.toList()); } + /** + * RFC-051 PR-2: a page is protected from AI / tool / batch deletion when + * either {@code locked == 1} or {@code pageType == "system"}. The system + * pages ({@code overview} / {@code log}) carry both flags; users may set + * {@code locked} on individual curated pages without making them system. + */ + public static boolean isProtected(WikiPageEntity page) { + if (page == null) return false; + if (page.getLocked() != null && page.getLocked() == 1) return true; + return "system".equals(page.getPageType()); + } + @Transactional public void delete(Long kbId, String slug) { + WikiPageEntity existing = getBySlug(kbId, slug); + if (existing == null) { + // Nothing to delete; preserve idempotent behavior. + return; + } + if (isProtected(existing)) { + log.warn("[Wiki] Refusing to delete protected page kbId={}, slug={}, type={}, locked={}", + kbId, slug, existing.getPageType(), existing.getLocked()); + return; + } pageMapper.delete( new LambdaQueryWrapper() .eq(WikiPageEntity::getKbId, kbId) @@ -409,6 +431,9 @@ public class WikiPageService { int deleted = 0; for (WikiPageEntity page : allPages) { if ("manual".equals(page.getLastUpdatedBy())) continue; + // RFC-051 PR-2: never sweep system / locked pages, even when their + // source raw is being reprocessed. + if (isProtected(page)) continue; List sourceIds = parseSourceRawIds(page.getSourceRawIds()); if (sourceIds.contains(rawId)) { if (sourceIds.size() == 1) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index f2926808..5e67491d 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -70,6 +70,14 @@ public class WikiProcessingService { @org.springframework.beans.factory.annotation.Autowired(required = false) private DocumentPreprocessService preprocessService; + /** + * RFC-051 PR-2: ensures system-page scaffold (overview / log) exists for + * the KB before each ingest. Optional so the older lazy-only unit tests + * don't need to wire it. + */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + private WikiScaffoldService scaffoldService; + /** Parallel chunk / material processing executor (JDK 21 virtual threads) */ public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); @@ -157,6 +165,12 @@ public class WikiProcessingService { kbService.updateStatus(kb.getId(), "processing"); + // RFC-051 PR-2: every ingest path opens with a scaffold check so older + // KBs get their overview / log pages on first use without a manual step. + if (scaffoldService != null) { + scaffoldService.ensureScaffold(kb.getId()); + } + // RFC-051 PR-1b: lazy ingest short-circuit. Per KB config, skip the heavy // pipeline entirely: extract → chunk → embed → completed. 0 pages is the // expected outcome, not a failure. ingestMode==null keeps existing behavior. diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java index 6b0e4a7d..47a5bd0d 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java @@ -68,6 +68,8 @@ public class WikiRelationService { return topIds.stream() .filter(liteMap::containsKey) + // RFC-051 PR-2: hide system pages (overview / log) from related results. + .filter(pid -> !liteMap.get(pid).isSystem()) .map(pid -> new RelatedPageResult( liteMap.get(pid).slug(), liteMap.get(pid).title(), diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java new file mode 100644 index 00000000..70eb1746 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java @@ -0,0 +1,111 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageMapper; + +/** + * RFC-051 PR-2: idempotent system-page scaffold for a knowledge base. + *

+ * A KB always has two system pages once {@link #ensureScaffold(Long)} has run: + *

+ * Both carry {@code page_type='system'} and {@code locked=1} so neither AI + * tools nor batch deletes can remove them. The deterministic-rebuild logic + * for the overview body and the activity-log writer land as follow-ups; this + * skeleton ships only the create-if-missing path. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiScaffoldService { + + public static final String SYSTEM_PAGE_TYPE = "system"; + public static final String OVERVIEW_SLUG = "overview"; + public static final String LOG_SLUG = "log"; + + private final WikiPageService pageService; + private final WikiPageMapper pageMapper; + + /** + * Make sure both system pages exist for {@code kbId}. Re-creates them with + * default content if missing; otherwise refreshes their protection flags + * (in case an earlier version stored them without {@code locked=1}). + *

+ * Safe to call repeatedly: it issues at most two SELECTs and one or two + * INSERT/UPDATEs. Throws nothing; logs on failure. + */ + public void ensureScaffold(Long kbId) { + if (kbId == null) return; + try { + ensureSystemPage(kbId, OVERVIEW_SLUG, "Overview", DEFAULT_OVERVIEW); + ensureSystemPage(kbId, LOG_SLUG, "Log", initialLog()); + } catch (Exception e) { + log.warn("[WikiScaffold] ensureScaffold failed for kbId={}: {}", kbId, e.getMessage()); + } + } + + private void ensureSystemPage(Long kbId, String slug, String title, String content) { + WikiPageEntity existing = pageService.getBySlug(kbId, slug); + if (existing == null) { + WikiPageEntity created = pageService.createPage(kbId, slug, title, content, + summaryOf(content), null, SYSTEM_PAGE_TYPE); + // createPage doesn't set locked; flip it on now via a targeted update. + pageMapper.update(null, + new LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, created.getId()) + .set(WikiPageEntity::getLocked, 1)); + log.info("[WikiScaffold] Created system page slug={} for kbId={}", slug, kbId); + return; + } + // Heal previously-created system pages that lack the locked flag. + boolean needsLockFix = existing.getLocked() == null || existing.getLocked() != 1; + boolean needsTypeFix = !SYSTEM_PAGE_TYPE.equals(existing.getPageType()); + if (needsLockFix || needsTypeFix) { + LambdaUpdateWrapper upd = new LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, existing.getId()); + if (needsLockFix) upd.set(WikiPageEntity::getLocked, 1); + if (needsTypeFix) upd.set(WikiPageEntity::getPageType, SYSTEM_PAGE_TYPE); + pageMapper.update(null, upd); + log.info("[WikiScaffold] Repaired protection flags for slug={} kbId={}", slug, kbId); + } + } + + private String summaryOf(String content) { + if (content == null) return ""; + String oneline = content.replaceAll("\\s+", " ").trim(); + return oneline.length() > 200 ? oneline.substring(0, 200) + "..." : oneline; + } + + private String initialLog() { + return "# Log\n\n## " + java.time.LocalDate.now() + " init\n\n- System pages initialized.\n"; + } + + private static final String DEFAULT_OVERVIEW = """ + # Overview + + + ## Scope + + - Sources: 0 + - Wiki pages: 0 + - Chunks: 0 + - Last ingest: - + + ## Recent Updates + + No updates yet. + + ## Coverage + + - Pages with citations: 0 + - Pages with wikilinks: 0 + - Isolated pages: 0 + + """; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index 29b53b0f..44621d27 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -134,18 +134,23 @@ public class WikiTool { List pages; if (query != null && !query.isBlank()) { List ids = pageService.searchPages(kbId, query).stream() + .filter(p -> !"system".equals(p.getPageType())) .map(WikiPageEntity::getId).limit(30).toList(); if (ids.isEmpty()) { pages = List.of(); } else { pages = pageService.listSummaries(kbId).stream() + .filter(p -> !"system".equals(p.getPageType())) .filter(p -> ids.stream().anyMatch(id -> Objects.equals(id, p.getId()))) - .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary())) + .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType())) .toList(); } } else { + // RFC-051 PR-2: hide system pages (overview / log) from default listings. + // Agents can still wiki_read_page("overview") explicitly. pages = pageService.listSummaries(kbId).stream() - .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary())) + .filter(p -> !"system".equals(p.getPageType())) + .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType())) .toList(); } @@ -382,6 +387,13 @@ public class WikiTool { return error("Cannot delete manually curated page: " + page.getTitle() + ". Please manage via admin UI."); } + // RFC-051 PR-2: refuse to delete system pages (overview/log) or any + // user-locked page, even when the agent has tool access. + if (WikiPageService.isProtected(page)) { + return error("Cannot delete protected page: " + page.getTitle() + + (page.getLocked() != null && page.getLocked() == 1 ? " (locked)" : " (system)")); + } + pageService.delete(kbId, slug); log.info("[WikiTool] Deleted page: {} (slug={}, kbId={})", page.getTitle(), slug, kbId); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql b/mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql new file mode 100644 index 00000000..3b1d81e5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql @@ -0,0 +1,4 @@ +-- V40: RFC-051 PR-2 — page protection flag. +-- locked=1 blocks AI / tool / UI deletion and batch cleanup. Combined with +-- page_type='system' for the built-in overview / log pages. +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS locked TINYINT NOT NULL DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql new file mode 100644 index 00000000..0aaeb394 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql @@ -0,0 +1,5 @@ +-- V40: RFC-051 PR-2 — page protection flag. +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' AND COLUMN_NAME = 'locked'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_page ADD COLUMN locked TINYINT NOT NULL DEFAULT 0', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;