feat(wiki): add wiki_update_page (in-place edit) and wiki_stale_pages tools

This commit is contained in:
matevip 2026-05-31 08:00:19 +08:00
parent 3fe1f65bef
commit 1388b6eec8
2 changed files with 163 additions and 0 deletions

View File

@ -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 = """

View File

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