From 1388b6eec8c766f070b642c80f2843a1e9cb5b6c Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 31 May 2026 08:00:19 +0800 Subject: [PATCH] feat(wiki): add wiki_update_page (in-place edit) and wiki_stale_pages tools --- .../java/vip/mate/wiki/tool/WikiTool.java | 84 +++++++++++++++++++ .../wiki/tool/WikiToolPermissionTest.java | 79 +++++++++++++++++ 2 files changed, 163 insertions(+) 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 1b9843e9..cff6f4ee 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 @@ -839,6 +839,90 @@ public class WikiTool { return "Wikilink enrichment queued for: " + slug; } + // ==================== Page update / stale review ==================== + + @Tool(description = """ + Update an existing wiki page's Markdown body IN PLACE, by slug. This + preserves the page's identity, slug, backlinks and version history. + Use this to revise or extend a page — do NOT delete and recreate it + (that drops links and can leave duplicate pages behind). The summary + is re-derived from the new content unless you pass one explicitly. + """) + public String wiki_update_page( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Slug of the page to update (from wiki_list_pages / wiki_read_page)") String slug, + @ToolParam(description = "New full Markdown content for the page body") String content, + @ToolParam(description = "New one-line summary (optional; omit to auto-derive from content)", required = false) String summary, + @ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName, + @ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) { + + if (slug == null || slug.isBlank()) { + return error("slug is required"); + } + if (content == null || content.isBlank()) { + return error("content is required"); + } + KbResolution kbRes = resolveKb(agentId, kbName, kbId); + if (kbRes.hasError()) return kbRes.errorJson(); + kbId = kbRes.kbId(); + + WikiPageEntity page = pageService.getBySlug(kbId, slug); + if (page == null) { + return error("Page not found: '" + slug + "'. Use wiki_list_pages to find the right slug."); + } + String writeErr = checkWrite(agentId, kbId, page.getPageType(), + WikiPageTypePermissionService.WriteOp.UPDATE); + if (writeErr != null) return writeErr; + + // summary == null → service re-derives it from the new content. + WikiPageEntity updated = pageService.updatePageManually( + kbId, slug, content, (summary == null || summary.isBlank()) ? null : summary); + return JSONUtil.createObj() + .set("ok", true) + .set("slug", updated.getSlug()) + .set("title", updated.getTitle()) + .set("version", updated.getVersion()) + .set("message", "Page updated in place (slug and backlinks preserved).") + .toString(); + } + + @Tool(description = """ + List wiki pages currently marked STALE (needing review) in a knowledge + base. A page goes stale when a fact page it depends on was updated, so + its synthesis/analysis content may now be out of date. Returns each + stale page's title, slug, pageType, knowledge layer and the reason it + was flagged. Use this to find what to re-check or re-summarize before + relying on experience/analysis pages. + """) + public String wiki_stale_pages( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName, + @ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) { + + KbResolution kbRes = resolveKb(agentId, kbName, kbId); + if (kbRes.hasError()) return kbRes.errorJson(); + kbId = kbRes.kbId(); + + WikiPageTypePermissionService.Access access = pageTypeAccess(agentId, kbId); + JSONArray arr = new JSONArray(); + for (WikiPageEntity p : pageService.listByKbId(kbId)) { + if (p.getStale() == null || p.getStale() == 0) continue; + if (p.getArchived() != null && p.getArchived() == 1) continue; + if (!canRead(access, p)) continue; // honour pageType read permissions + arr.add(JSONUtil.createObj() + .set("slug", p.getSlug()) + .set("title", p.getTitle()) + .set("pageType", p.getPageType()) + .set("knowledgeLayer", p.getKnowledgeLayer()) + .set("staleReason", p.getStaleReasonJson() == null ? "" : p.getStaleReasonJson())); + } + return JSONUtil.createObj() + .set("kbId", String.valueOf(kbId)) + .set("staleCount", arr.size()) + .set("pages", arr) + .toString(); + } + // ==================== Transformations ==================== @Tool(description = """ diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolPermissionTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolPermissionTest.java index 30425919..84666890 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolPermissionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolPermissionTest.java @@ -150,4 +150,83 @@ class WikiToolPermissionTest { assertTrue(out.contains("Not permitted"), out); verify(h.pageService(), never()).createPage(anyLong(), any(), any(), any(), any(), any()); } + + // ---------- wiki_update_page: in-place update, no delete/recreate ---------- + + @Test + void updatePage_allowed_updatesInPlace() { + Harness h = harness(List.of(row("concept", 1, 1, 1, 1, "allow"))); + when(h.pageService().getBySlug(KB, "p")).thenReturn(page("p", "concept")); + WikiPageEntity updated = page("p", "concept"); + updated.setVersion(2); + when(h.pageService().updatePageManually(eq(KB), eq("p"), any(), any())).thenReturn(updated); + + String out = h.tool().wiki_update_page(AGENT, "p", "new body", null, null, KB); + + assertTrue(out.contains("\"ok\":true"), out); + assertTrue(out.contains("updated in place"), out); + verify(h.pageService(), times(1)).updatePageManually(eq(KB), eq("p"), eq("new body"), any()); + // crucially, it must NOT delete or recreate (the duplicate-page bug) + verify(h.pageService(), never()).delete(anyLong(), any()); + verify(h.pageService(), never()).createPage(anyLong(), any(), any(), any(), any(), any()); + } + + @Test + void updatePage_updateDenied_doesNotUpdate() { + // can read + create, but update flag off → DENY + Harness h = harness(List.of(row("concept", 1, 1, 0, 0, "allow"))); + when(h.pageService().getBySlug(KB, "p")).thenReturn(page("p", "concept")); + + String out = h.tool().wiki_update_page(AGENT, "p", "new body", null, null, KB); + + assertTrue(out.contains("Not permitted"), out); + verify(h.pageService(), never()).updatePageManually(anyLong(), any(), any(), any()); + } + + @Test + void updatePage_missingPage_reportsNotFound() { + Harness h = harness(List.of(row("*", 1, 1, 1, 1, "allow"))); + when(h.pageService().getBySlug(KB, "ghost")).thenReturn(null); + + String out = h.tool().wiki_update_page(AGENT, "ghost", "body", null, null, KB); + + assertTrue(out.contains("Page not found"), out); + verify(h.pageService(), never()).updatePageManually(anyLong(), any(), any(), any()); + } + + // ---------- wiki_stale_pages: lists stale, honours read filter ---------- + + private WikiPageEntity stalePage(String slug, String type, String reason) { + WikiPageEntity p = page(slug, type); + p.setStale(1); + p.setStaleReasonJson(reason); + return p; + } + + @Test + void stalePages_listsOnlyStaleReadablePages() { + // wildcard allows reading 'concept' but a specific 'secret' row denies read + Harness h = harness(List.of(row("*", 1, 0, 0, 0, "deny"), row("secret", 0, 0, 0, 0, "deny"))); + WikiPageEntity fresh = page("fresh", "concept"); // not stale → excluded + WikiPageEntity staleOk = stalePage("aged", "concept", "{\"reason\":\"fact updated\"}"); + WikiPageEntity staleHidden = stalePage("classified", "secret", "{\"reason\":\"x\"}"); + when(h.pageService().listByKbId(KB)).thenReturn(List.of(fresh, staleOk, staleHidden)); + + String out = h.tool().wiki_stale_pages(AGENT, null, KB); + + assertTrue(out.contains("\"staleCount\":1"), out); + assertTrue(out.contains("aged"), out); + assertFalse(out.contains("classified"), out); // unreadable type filtered out + assertFalse(out.contains("fresh"), out); // non-stale excluded + } + + @Test + void stalePages_noneStale_returnsZero() { + Harness h = harness(List.of()); + when(h.pageService().listByKbId(KB)).thenReturn(List.of(page("a", "concept"), page("b", "episode"))); + + String out = h.tool().wiki_stale_pages(AGENT, null, KB); + + assertTrue(out.contains("\"staleCount\":0"), out); + } }