feat(webchat): expose agent-bound wiki pages to API-Key callers

Add GET /api/v1/channels/webchat/wiki/pages mirroring /skills, so
downstream integrators can build a [[slug]] picker UI that points the
LLM at specific wiki pages. The picker token format is the universal
Obsidian/Wikipedia wikilink convention; the LLM consumes [[slug]] via
the existing wiki_read_page(slug=...) tool, so no agent-runtime changes
are needed.

- AgentBindingResolver.getBoundKbIds(agentId): three-state mirror of
  getBoundSkillIds. null = no rows (fall through to workspace-wide KBs),
  Set.of() = explicitly scoped to zero KBs, non-empty = explicit scope.
- WebChatController.listWikiPages: API Key + visitorToken auth chain,
  agentId workspace anti-escalation, visibility excludes pageType=
  synthesis (LLM intermediate artifacts), 100-page cap forces keyword
  filter, response carries only display-level metadata.
- WebChatWikiPageView DTO: kbId/kbName/slug/title/summary/pageType;
  content/embedding/sourceRawIds deliberately stay admin-console-only.
- WikiTool.wiki_read_page @Tool description: document the [[slug]]
  convention so the LLM treats each token as a wiki-page reference.
- WebChatWikiPageListTest: 8 cases covering happy path, keyword filter,
  synthesis exclusion, anti-escalation, auth failures, cap behavior,
  and the no-binding → workspace-wide fallback.

Closes #381.
This commit is contained in:
倪程伟 2026-06-19 11:30:25 +08:00 committed by matevip
parent a5e7060045
commit f6156f6093
5 changed files with 485 additions and 2 deletions

View File

@ -975,6 +975,32 @@ public class AgentBindingService implements AgentBindingResolver {
.orderByAsc(AgentWikiKbBinding::getCreateTime)); .orderByAsc(AgentWikiKbBinding::getCreateTime));
} }
/**
* Effective KB ids the agent may see. Three states (mirror
* {@link #getBoundSkillIds}):
*
* <ul>
* <li>{@code null} no binding rows. Caller treats this as "no
* agent-level restriction; inherit every KB in the agent's
* workspace" (the default wiki-tool behavior).</li>
* <li>{@code Set.of()} rows exist but none are {@code enabled=true}.
* Caller treats this as "explicitly scoped to zero KBs".</li>
* <li>non-empty set the explicit allowlist.</li>
* </ul>
*/
@Override
public Set<Long> getBoundKbIds(Long agentId) {
List<AgentWikiKbBinding> bindings = listKbBindings(agentId);
if (bindings.isEmpty()) {
return null;
}
return bindings.stream()
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
.map(AgentWikiKbBinding::getKbId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}
/** /**
* Replace the agent's KB access scope. An empty / null list clears the * Replace the agent's KB access scope. An empty / null list clears the
* scope, returning the agent to workspace-wide (unrestricted) access. * scope, returning the agent to workspace-wide (unrestricted) access.

View File

@ -77,6 +77,8 @@ public class WebChatController {
private final vip.mate.audit.service.AuditEventService auditService; private final vip.mate.audit.service.AuditEventService auditService;
private final vip.mate.llm.routing.AgentBindingResolver agentBindingResolver; private final vip.mate.llm.routing.AgentBindingResolver agentBindingResolver;
private final vip.mate.skill.repository.SkillMapper skillMapper; private final vip.mate.skill.repository.SkillMapper skillMapper;
private final vip.mate.wiki.repository.WikiPageMapper wikiPageMapper;
private final vip.mate.wiki.repository.WikiKnowledgeBaseMapper wikiKbMapper;
/** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */ /** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */
static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L; static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L;
@ -374,11 +376,168 @@ public class WebChatController {
.toList()); .toList());
} }
/**
* 列出访客可见的 wiki 页面供下游自建`[[slug]]` 引用 pickerUI
* <p>
* 鉴权链跟 {@link #listSkills} 一致API Key 解析 channel + visitorToken
* HMAC 校验{@code agentId} 可选缺省回落到 channel 绑定的 agent
* 必须属于该 channel workspace沿用 {@code /stream} 的反越权路径
* <p>
* 可见范围 = agent 绑定的 KB无显式绑定时回落到 workspace 内全部 KB
* 下的所有 page排除 {@code pageType=synthesis}LLM 中间产物
* 上限 {@value WEBCHAT_WIKI_PICKER_MAX_PAGES}超出时强制要求 {@code keyword}
* <p>
* 出参仅暴露展示级元数据slug / title / summary / pageType / kbId /
* kbName不包含正文embeddingsourceRawIds 等内部字段
*/
@Operation(summary = "列出访客可见 wiki 页面(供 [[slug]] picker UI")
@GetMapping("/wiki/pages")
public R<List<WebChatWikiPageView>> listWikiPages(
@RequestHeader("X-MC-Key") String apiKey,
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
@RequestParam(required = false) Long agentId,
@RequestParam String visitorId,
@RequestParam(required = false) String keyword) {
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
return R.fail(401, "Invalid API Key");
}
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
return R.fail(401, "Invalid or missing visitor token");
}
// Resolve the target agent same anti-escalation rule as /stream and
// /skills: an explicit agentId must belong to the channel's workspace.
Long resolvedAgentId = channel.getAgentId();
if (agentId != null) {
var requested = agentService.getAgent(agentId);
if (requested == null) {
return R.fail(404, "Requested agent not found");
}
if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null
&& !channel.getWorkspaceId().equals(requested.getWorkspaceId())) {
return R.fail(403, "Requested agent does not belong to this channel's workspace");
}
resolvedAgentId = agentId;
}
if (resolvedAgentId == null) {
return R.ok(List.of());
}
// Resolve the KB scope: null = workspace-wide (every KB in the agent's
// workspace); non-empty set = explicit allowlist. Set.of() (rows exist
// but none enabled) means "agent is explicitly scoped to zero KBs"
// surface as empty so the picker shows nothing rather than falling
// through to workspace-wide.
java.util.Set<Long> boundKbIds = agentBindingResolver.getBoundKbIds(resolvedAgentId);
Long workspaceId = channel.getWorkspaceId();
java.util.Set<Long> effectiveKbIds;
if (boundKbIds != null) {
if (boundKbIds.isEmpty()) {
return R.ok(List.of());
}
effectiveKbIds = boundKbIds;
} else {
// No explicit binding fall back to every KB in the channel's
// workspace. Matches the wiki-tool behavior (an unscoped agent
// sees workspace-wide KBs).
effectiveKbIds = wikiKbMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<vip.mate.wiki.model.WikiKnowledgeBaseEntity>()
.eq(vip.mate.wiki.model.WikiKnowledgeBaseEntity::getWorkspaceId,
workspaceId == null ? 1L : workspaceId))
.stream()
.map(vip.mate.wiki.model.WikiKnowledgeBaseEntity::getId)
.filter(java.util.Objects::nonNull)
.collect(java.util.stream.Collectors.toSet());
if (effectiveKbIds.isEmpty()) {
return R.ok(List.of());
}
}
// Build the page query: KB scope + exclude hidden pageTypes + optional
// keyword filter on slug/title. Use a single LIKE with OR so a visitor
// typing "auth" matches either "auth-design" (slug) or "Auth Design" (title).
String trimmedKeyword = keyword == null ? null : keyword.trim();
boolean hasKeyword = trimmedKeyword != null && !trimmedKeyword.isEmpty();
// Cap check: if no keyword and total candidate count exceeds the cap,
// refuse the caller must narrow with a keyword. Counting before
// selecting avoids materializing a huge list into memory.
// NOTE: the count wrapper is built WITHOUT ORDER BY H2 in MySQL mode
// rejects "ORDER BY slug" on a COUNT(*) query (column must appear in
// GROUP BY). The select wrapper below adds ORDER BY slug.
if (!hasKeyword) {
long total = wikiPageMapper.selectCount(buildWikiPageFilterWrapper(effectiveKbIds, null));
if (total > WEBCHAT_WIKI_PICKER_MAX_PAGES) {
return R.fail(422, "Wiki page count (" + total
+ ") exceeds picker cap (" + WEBCHAT_WIKI_PICKER_MAX_PAGES
+ "); please provide a 'keyword' query parameter to narrow.");
}
}
List<vip.mate.wiki.model.WikiPageEntity> pages = wikiPageMapper.selectList(
buildWikiPageFilterWrapper(effectiveKbIds, hasKeyword ? trimmedKeyword : null)
.orderByAsc(vip.mate.wiki.model.WikiPageEntity::getSlug));
if (pages.isEmpty()) {
return R.ok(List.of());
}
// Hydrate KB names so the picker UI can show "<kb>: <page title>" and
// the LLM can disambiguate when two KBs share a slug.
java.util.Map<Long, String> kbNames = wikiKbMapper.selectBatchIds(effectiveKbIds).stream()
.collect(java.util.stream.Collectors.toMap(
vip.mate.wiki.model.WikiKnowledgeBaseEntity::getId,
kb -> kb.getName() != null ? kb.getName() : "",
(a, b) -> a));
return R.ok(pages.stream()
.map(p -> WebChatWikiPageView.from(p, kbNames.get(p.getKbId())))
.toList());
}
/**
* Build the WHERE-clause portion of the wiki-page picker query: KB scope +
* pageType-not-in-hidden + optional keyword LIKE on slug / title.
* Returned without ORDER BY so the caller can layer sorting (select) or
* nothing (count) on top H2 in MySQL mode rejects ORDER BY on COUNT(*).
*/
private com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<vip.mate.wiki.model.WikiPageEntity>
buildWikiPageFilterWrapper(java.util.Set<Long> kbIds, String keyword) {
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<vip.mate.wiki.model.WikiPageEntity> w =
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<vip.mate.wiki.model.WikiPageEntity>()
.in(vip.mate.wiki.model.WikiPageEntity::getKbId, kbIds)
.notIn(vip.mate.wiki.model.WikiPageEntity::getPageType, WEBCHAT_WIKI_HIDDEN_PAGE_TYPES);
if (keyword != null && !keyword.isEmpty()) {
String like = "%" + keyword + "%";
w.and(qq -> qq.like(vip.mate.wiki.model.WikiPageEntity::getSlug, like)
.or().like(vip.mate.wiki.model.WikiPageEntity::getTitle, like));
}
return w;
}
/** Cap on how many empty (message_count = 0) threads one visitor may hold on a /** Cap on how many empty (message_count = 0) threads one visitor may hold on a
* channel at once. Guards against pathologic clients churning placeholder * channel at once. Guards against pathologic clients churning placeholder
* sessions without ever sending a message. */ * sessions without ever sending a message. */
private static final int MAX_EMPTY_SESSIONS_PER_VISITOR = 5; private static final int MAX_EMPTY_SESSIONS_PER_VISITOR = 5;
/**
* Upper bound on the page count the wiki-page picker will return without a
* keyword filter. Beyond this the caller MUST supply {@code keyword}
* returning 500 pages to a visitor picker is both bandwidth-wasteful and
* unusable as a UI. Mirrors the slash-skill picker's "small list, search
* when too big" stance.
*/
private static final int WEBCHAT_WIKI_PICKER_MAX_PAGES = 100;
/**
* Page types hidden from the visitor picker. {@code synthesis} pages are
* LLM-generated intermediate artifacts (compiled on demand by
* {@code wiki_compile_page}); they aren't curated source material and
* surfacing them to a downstream visitor is noise. Entity / concept /
* source pages are human-readable references the visitor can meaningfully
* point the LLM at.
*/
private static final java.util.Set<String> WEBCHAT_WIKI_HIDDEN_PAGE_TYPES = java.util.Set.of("synthesis");
/** /**
* 显式创建一条访客会话线程空会话 * 显式创建一条访客会话线程空会话
* <p> * <p>
@ -1478,6 +1637,38 @@ public class WebChatController {
} }
} }
/**
* Display-level projection of a wiki page for the visitor-facing
* {@code [[slug]]} picker. Carries the slug the LLM consumes (via
* {@code wiki_read_page(slug=)}), the human-readable title/summary for
* picker UI, and the KB id + name for disambiguation when an agent is
* scoped to multiple KBs that may share a slug. Deliberately omits
* content / embedding / sourceRawIds / outgoingLinks those stay
* admin-console-only.
*/
@lombok.Data
public static class WebChatWikiPageView {
private Long kbId;
private String kbName;
/** Immutable slug — what the picker splices into the {@code [[slug]]} token; the LLM consumes it as {@code wiki_read_page(slug=…)}. */
private String slug;
private String title;
private String summary;
/** {@code entity} / {@code concept} / {@code source} / ... — never {@code synthesis} (filtered out upstream). Useful for picker grouping/icons. */
private String pageType;
static WebChatWikiPageView from(vip.mate.wiki.model.WikiPageEntity p, String kbName) {
WebChatWikiPageView v = new WebChatWikiPageView();
v.kbId = p.getKbId();
v.kbName = kbName;
v.slug = p.getSlug();
v.title = p.getTitle();
v.summary = p.getSummary();
v.pageType = p.getPageType();
return v;
}
}
/** Body for {@code POST /sessions} — explicitly create an empty thread. */ /** Body for {@code POST /sessions} — explicitly create an empty thread. */
@lombok.Data @lombok.Data
public static class WebChatCreateSessionRequest { public static class WebChatCreateSessionRequest {

View File

@ -4,8 +4,9 @@ import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
* Read access to an agent's skill / provider bindings, as needed by * Read access to an agent's skill / provider / wiki-kb bindings, as needed by
* {@link ProviderRouter} for capability-aware routing. * {@link ProviderRouter} for capability-aware routing and by webchat
* endpoints that need to enumerate an agent's visible catalog.
* *
* <p>Declared in the {@code llm} layer so the routing code depends only on * <p>Declared in the {@code llm} layer so the routing code depends only on
* this abstraction. The {@code agent} layer supplies the implementation, * this abstraction. The {@code agent} layer supplies the implementation,
@ -23,4 +24,14 @@ public interface AgentBindingResolver {
* Provider ids the agent prefers, in priority order; empty when none. * Provider ids the agent prefers, in priority order; empty when none.
*/ */
List<String> getPreferredProviderIds(Long agentId); List<String> getPreferredProviderIds(Long agentId);
/**
* Wiki knowledge-base ids bound to the agent, or {@code null} when the
* agent has no explicit KB scope (meaning "workspace-wide — every KB
* in the agent's workspace is visible"). Mirrors the three-state
* contract of {@link #getBoundSkillIds}: {@code null} = inherit
* default, {@code Set.of()} = explicitly scoped to nothing, non-empty
* = the explicit allowlist.
*/
Set<Long> getBoundKbIds(Long agentId);
} }

View File

@ -178,6 +178,10 @@ public class WikiTool {
Use sectionHeading to read only one section by its heading text. Use sectionHeading to read only one section by its heading text.
The result includes a "sourceFiles" field listing the source documents this page was derived from. The result includes a "sourceFiles" field listing the source documents this page was derived from.
When using content from this page in your answer, cite the page title and source files. When using content from this page in your answer, cite the page title and source files.
Convention: a user message containing `[[<slug>]]` (e.g. `参考知识库页面 [[auth-design]]: ...`)
is a wiki-page reference inserted by the chat picker. Treat each `[[slug]]` as a request to
consult that page first call this tool with the bare slug before answering.
""") """)
public String wiki_read_page( public String wiki_read_page(
@ToolParam(description = "Agent ID") Long agentId, @ToolParam(description = "Agent ID") Long agentId,

View File

@ -0,0 +1,251 @@
package vip.mate.channel.webchat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import vip.mate.channel.webchat.WebChatController.WebChatWikiPageView;
import vip.mate.common.result.R;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end coverage for {@code GET /api/v1/channels/webchat/wiki/pages}
* the visitor-facing wiki page catalogue that downstream integrators use to
* build a {@code [[slug]]} picker UI. Mirrors {@link WebChatSkillListTest}:
* verifies the auth chain (API Key + visitorToken HMAC), the agent workspace
* anti-escalation guard, the bound-KB scope, the {@code synthesis} exclusion,
* and the &gt;100-page cap that forces a keyword filter.
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:webchat_wiki_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789"
})
class WebChatWikiPageListTest {
private static final String SECRET = "webchat-it-secret-0123456789";
private static final String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_400_001L;
private static final long AGENT_ID = 9_400_011L;
private static final long OTHER_WORKSPACE_AGENT_ID = 9_400_012L;
private static final long KB_ID = 9_400_101L;
private static final long OTHER_KB_ID = 9_400_102L;
@Autowired private WebChatController controller;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void setUp() {
// Wipe bindings + pages + KBs + channel + agents in dependency-safe order.
jdbc.update("DELETE FROM mate_agent_wiki_kb WHERE agent_id IN (?, ?, ?)",
AGENT_ID, OTHER_WORKSPACE_AGENT_ID, 9_400_099L);
jdbc.update("DELETE FROM mate_wiki_page WHERE kb_id IN (?, ?)", KB_ID, OTHER_KB_ID);
jdbc.update("DELETE FROM mate_wiki_knowledge_base WHERE id IN (?, ?)", KB_ID, OTHER_KB_ID);
jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID);
jdbc.update("DELETE FROM mate_agent WHERE id IN (?, ?, ?)",
AGENT_ID, OTHER_WORKSPACE_AGENT_ID, 9_400_099L);
jdbc.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'wc-wiki-agent', 'react', '', 10, TRUE, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
AGENT_ID);
jdbc.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'wc-wiki-other-ws-agent', 'react', '', 10, TRUE, 999, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
OTHER_WORKSPACE_AGENT_ID);
jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}");
// Two KBs in workspace 1 (the channel's workspace). Bind the agent to
// KB_ID only, so OTHER_KB_ID stays out of scope unless the agent's
// binding set is cleared.
jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " +
"page_count, raw_count, workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'Main KB', 'main', 'active', 0, 0, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
KB_ID);
jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " +
"page_count, raw_count, workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'Other KB', 'other', 'active', 0, 0, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
OTHER_KB_ID);
// Bind AGENT_ID to KB_ID only. Single enabled row effective scope = {KB_ID}.
jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " +
"create_time, update_time, deleted) " +
"KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
9_400_201L, AGENT_ID, KB_ID);
// Three pages in KB_ID: entity / concept / synthesis. The synthesis
// one must be filtered out by the picker; the other two surface sorted
// by slug (beta < zeta by slug asc... actually we use 'alpha' and
// 'beta' to make the sort obvious).
insertPage(9_400_301L, KB_ID, "alpha-page", "Alpha Page", "entity");
insertPage(9_400_302L, KB_ID, "beta-page", "Beta Page", "concept");
insertPage(9_400_303L, KB_ID, "hidden-synthesis", "Synthesis (hidden)", "synthesis");
// One page in OTHER_KB_ID must NOT surface (agent bound to KB_ID only).
insertPage(9_400_304L, OTHER_KB_ID, "other-kb-page", "Other KB Page", "entity");
}
private void insertPage(long id, long kbId, String slug, String title, String pageType) {
jdbc.update("MERGE INTO mate_wiki_page (id, kb_id, slug, title, content, summary, " +
"page_type, version, last_updated_by, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, ?, ?, ?, '', ?, ?, 1, 'test', " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
id, kbId, slug, title, title + " summary", pageType);
}
private String tokenFor(String visitorId) {
return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId);
}
@Test
@DisplayName("happy path: bound-KB pages surface sorted by slug; synthesis filtered out; other-KB excluded")
void listReturnsBoundKbPagesSortedExcludingSynthesis() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, tokenFor("v1"), null, "v1", null);
assertThat(r.getCode()).isEqualTo(200);
List<WebChatWikiPageView> data = r.getData();
assertThat(data).hasSize(2);
assertThat(data.get(0).getSlug()).isEqualTo("alpha-page");
assertThat(data.get(0).getTitle()).isEqualTo("Alpha Page");
assertThat(data.get(0).getKbId()).isEqualTo(KB_ID);
assertThat(data.get(0).getKbName()).isEqualTo("Main KB");
assertThat(data.get(0).getPageType()).isEqualTo("entity");
assertThat(data.get(1).getSlug()).isEqualTo("beta-page");
// synthesis must NOT surface
assertThat(data).noneMatch(p -> "synthesis".equals(p.getPageType()));
// other-KB page must NOT surface (out of scope)
assertThat(data).noneMatch(p -> "other-kb-page".equals(p.getSlug()));
}
@Test
@DisplayName("keyword filters by slug OR title (case-insensitive LIKE)")
void keywordFilterMatchesSlugOrTitle() {
R<List<WebChatWikiPageView>> bySlug = controller.listWikiPages(
API_KEY, tokenFor("v2"), null, "v2", "alpha");
assertThat(bySlug.getCode()).isEqualTo(200);
assertThat(bySlug.getData()).hasSize(1);
assertThat(bySlug.getData().get(0).getSlug()).isEqualTo("alpha-page");
R<List<WebChatWikiPageView>> byTitle = controller.listWikiPages(
API_KEY, tokenFor("v3"), null, "v3", "Beta Page");
assertThat(byTitle.getCode()).isEqualTo(200);
assertThat(byTitle.getData()).hasSize(1);
assertThat(byTitle.getData().get(0).getSlug()).isEqualTo("beta-page");
R<List<WebChatWikiPageView>> noMatch = controller.listWikiPages(
API_KEY, tokenFor("v4"), null, "v4", "nomatch");
assertThat(noMatch.getCode()).isEqualTo(200);
assertThat(noMatch.getData()).isEmpty();
}
@Test
@DisplayName("explicit agentId matching channel workspace works")
void explicitAgentIdSameWorkspace() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, tokenFor("v5"), AGENT_ID, "v5", null);
assertThat(r.getCode()).isEqualTo(200);
assertThat(r.getData()).hasSize(2);
}
@Test
@DisplayName("explicit agentId in a different workspace → 403 (anti-escalation)")
void explicitAgentIdDifferentWorkspace() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, tokenFor("v6"), OTHER_WORKSPACE_AGENT_ID, "v6", null);
assertThat(r.getCode()).isEqualTo(403);
assertThat(r.getData()).isNull();
}
@Test
@DisplayName("invalid API Key → 401")
void invalidApiKey() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
"garbagekeyxyz12", tokenFor("v7"), null, "v7", null);
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("missing/invalid visitor token → 401")
void invalidVisitorToken() {
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, "not-a-valid-token", null, "v8", null);
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("no KB bindings → fall back to workspace-wide KB set (legacy behavior)")
void noKbBindingsFallsBackToWorkspaceWide() {
// Use a fresh agent with no mate_agent_wiki_kb rows. The channel is
// repointed to it so the default-agent path resolves it.
long lonelyAgent = 9_400_099L;
jdbc.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'wc-wiki-lonely', 'react', '', 10, TRUE, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
lonelyAgent);
jdbc.update("UPDATE mate_channel SET agent_id = ? WHERE id = ?", lonelyAgent, CHANNEL_ID);
R<List<WebChatWikiPageView>> r = controller.listWikiPages(
API_KEY, tokenFor("v9"), null, "v9", null);
assertThat(r.getCode()).isEqualTo(200);
// Both workspace KBs visible: alpha/beta from KB_ID + other-kb-page
// from OTHER_KB_ID. Synthesis still filtered.
assertThat(r.getData()).hasSize(3);
assertThat(r.getData()).extracting(WebChatWikiPageView::getSlug)
.containsExactlyInAnyOrder("alpha-page", "beta-page", "other-kb-page");
}
@Test
@DisplayName(">100 pages without keyword → 422 (force narrow with keyword)")
void capWithoutKeywordReturns422() {
// Seed 101 synthetic pages (slug = cap-001 ... cap-101) into KB_ID.
// These are extra to the 3 set up in @BeforeEach; synthesis-type rows
// still get filtered, so use 'entity' to make sure they all count.
for (int i = 1; i <= 101; i++) {
insertPage(9_401_000L + i, KB_ID,
String.format("cap-%03d", i),
"Cap Page " + i,
"entity");
}
R<List<WebChatWikiPageView>> noKeyword = controller.listWikiPages(
API_KEY, tokenFor("v10"), null, "v10", null);
assertThat(noKeyword.getCode()).isEqualTo(422);
assertThat(noKeyword.getData()).isNull();
// With a keyword the cap is bypassed caller gets the filtered subset
// (here: 3 of the 101 seeded rows match "cap-001" / "cap-010" / "cap-100").
R<List<WebChatWikiPageView>> withKeyword = controller.listWikiPages(
API_KEY, tokenFor("v11"), null, "v11", "cap-001");
assertThat(withKeyword.getCode()).isEqualTo(200);
assertThat(withKeyword.getData()).isNotEmpty();
}
}