mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
a5e7060045
commit
f6156f6093
@ -975,6 +975,32 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
.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
|
||||
* scope, returning the agent to workspace-wide (unrestricted) access.
|
||||
|
||||
@ -77,6 +77,8 @@ public class WebChatController {
|
||||
private final vip.mate.audit.service.AuditEventService auditService;
|
||||
private final vip.mate.llm.routing.AgentBindingResolver agentBindingResolver;
|
||||
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. */
|
||||
static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L;
|
||||
@ -374,11 +376,168 @@ public class WebChatController {
|
||||
.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出访客可见的 wiki 页面,供下游自建「`[[slug]]` 引用 picker」UI。
|
||||
* <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),不包含正文、embedding、sourceRawIds 等内部字段。
|
||||
*/
|
||||
@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
|
||||
* channel at once. Guards against pathologic clients churning placeholder
|
||||
* sessions without ever sending a message. */
|
||||
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>
|
||||
@ -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. */
|
||||
@lombok.Data
|
||||
public static class WebChatCreateSessionRequest {
|
||||
|
||||
@ -4,8 +4,9 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Read access to an agent's skill / provider bindings, as needed by
|
||||
* {@link ProviderRouter} for capability-aware routing.
|
||||
* Read access to an agent's skill / provider / wiki-kb bindings, as needed by
|
||||
* {@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
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@ -178,6 +178,10 @@ public class WikiTool {
|
||||
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.
|
||||
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(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
|
||||
@ -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 >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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user