feat(wiki): PR-2 system pages — overview/log scaffold + locked + filters

This commit is contained in:
matevip 2026-04-25 09:56:18 +08:00
parent 9746271ea5
commit 1281153aa8
12 changed files with 217 additions and 5 deletions

View File

@ -2,5 +2,21 @@ package vip.mate.wiki.dto;
/**
* RFC-029: Lightweight page projection without content.
* <p>
* 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);
}
}

View File

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

View File

@ -35,7 +35,7 @@ public interface WikiPageMapper extends BaseMapper<WikiPageEntity> {
/**
* Batch-fetch lightweight page projections by IDs (no content).
*/
@Select("<script>SELECT id, slug, title, summary FROM mate_wiki_page " +
@Select("<script>SELECT id, slug, title, summary, page_type AS pageType FROM mate_wiki_page " +
"WHERE id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND deleted = 0</script>")
List<WikiPageLite> selectBatchLite(@Param("ids") Collection<Long> ids);
@ -43,7 +43,7 @@ public interface WikiPageMapper extends BaseMapper<WikiPageEntity> {
/**
* 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<WikiPageLite> selectAllLite(@Param("kbId") Long kbId);

View File

@ -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")) {

View File

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

View File

@ -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<WikiPageEntity>()
.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<Long> sourceIds = parseSourceRawIds(page.getSourceRawIds());
if (sourceIds.contains(rawId)) {
if (sourceIds.size() == 1) {

View File

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

View File

@ -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(),

View File

@ -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.
* <p>
* A KB always has two system pages once {@link #ensureScaffold(Long)} has run:
* <ul>
* <li>{@code overview} entry point describing scope and recent updates.</li>
* <li>{@code log} append-only ingest / compile / edit record.</li>
* </ul>
* 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}).
* <p>
* 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<WikiPageEntity>()
.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<WikiPageEntity> upd = new LambdaUpdateWrapper<WikiPageEntity>()
.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
<!-- mate:overview:v1:start -->
## 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
<!-- mate:overview:v1:end -->
""";
}

View File

@ -134,18 +134,23 @@ public class WikiTool {
List<WikiPageLite> pages;
if (query != null && !query.isBlank()) {
List<Long> 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);

View File

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

View File

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