mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): PR-2 system pages — overview/log scaffold + locked + filters
This commit is contained in:
parent
9746271ea5
commit
1281153aa8
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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")) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 -->
|
||||
""";
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user