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 2e28a671..cd225274 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 @@ -275,6 +275,7 @@ public class WikiTool { @ToolParam(description = "Search query") String query, @ToolParam(description = "Mode: keyword|semantic|hybrid (default: hybrid)", required = false) String mode, @ToolParam(description = "Max results (default 5, max 20)", required = false) Integer topK, + @ToolParam(description = "Knowledge layer filter: fact | experience | all (default all). 'fact' = factual pages (and unlayered pages); 'experience' = synthesis/analysis pages.", required = false) String layer, @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) { @@ -289,15 +290,17 @@ public class WikiTool { int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5; List results = hybridRetriever.search(kbId, query, mode, k); - // Drop hits whose page type this agent may not read, so search cannot - // surface pages a direct read would refuse. + // Drop hits this agent may not read (by page type) or that fall outside + // the requested knowledge layer. Both need the page, so fetch it once. WikiPageTypePermissionService.Access access = pageTypeAccess(agentId, kbId); - if (access != null) { + boolean layerFilter = layer != null && !layer.isBlank() && !"all".equalsIgnoreCase(layer.trim()); + if (access != null || layerFilter) { final Long resolvedKbId = kbId; + final String layerWanted = layer; results = results.stream() .filter(r -> { WikiPageEntity p = pageService.getBySlug(resolvedKbId, r.slug()); - return canRead(access, p); + return canRead(access, p) && matchesLayer(p == null ? null : p.getKnowledgeLayer(), layerWanted); }) .toList(); } @@ -1069,6 +1072,20 @@ public class WikiTool { return access == null || access.canRead(pageType); } + /** + * Whether a page's knowledge layer matches a retrieval filter. A null/blank + * or {@code all} filter matches everything; an unlayered page counts as + * {@code fact} (the RFC default), so {@code layer=fact} includes legacy + * pages while {@code layer=experience} returns only experience pages. + */ + static boolean matchesLayer(String pageLayer, String filter) { + if (filter == null || filter.isBlank() || "all".equalsIgnoreCase(filter.trim())) { + return true; + } + String effective = (pageLayer == null || pageLayer.isBlank()) ? "fact" : pageLayer.trim().toLowerCase(); + return effective.equals(filter.trim().toLowerCase()); + } + /** * Gate a write/mutate operation by pageType permission. Returns an error * JSON string to short-circuit the tool when the write is not permitted, or diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolLayerFilterTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolLayerFilterTest.java new file mode 100644 index 00000000..8aa7a9ba --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolLayerFilterTest.java @@ -0,0 +1,41 @@ +package vip.mate.wiki.tool; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the retrieval knowledge-layer filter ({@link WikiTool#matchesLayer}). + */ +class WikiToolLayerFilterTest { + + @Test + void noFilterOrAll_matchesEverything() { + assertTrue(WikiTool.matchesLayer("experience", null)); + assertTrue(WikiTool.matchesLayer("experience", "")); + assertTrue(WikiTool.matchesLayer("experience", "all")); + assertTrue(WikiTool.matchesLayer(null, "all")); + } + + @Test + void factFilter_includesUnlayeredPages() { + assertTrue(WikiTool.matchesLayer(null, "fact")); // legacy / unlayered counts as fact + assertTrue(WikiTool.matchesLayer("", "fact")); + assertTrue(WikiTool.matchesLayer("fact", "fact")); + assertFalse(WikiTool.matchesLayer("experience", "fact")); + } + + @Test + void experienceFilter_excludesFactAndUnlayered() { + assertTrue(WikiTool.matchesLayer("experience", "experience")); + assertFalse(WikiTool.matchesLayer("fact", "experience")); + assertFalse(WikiTool.matchesLayer(null, "experience")); + } + + @Test + void caseInsensitive() { + assertTrue(WikiTool.matchesLayer("Experience", "EXPERIENCE")); + assertTrue(WikiTool.matchesLayer("FACT", "fact")); + } +}