hiwork-knowledge 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/prompt.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 知识库相关的系统提示词片段与工具使用指引。
3
+ *
4
+ * 为什么需要这段:DSH 自带的能力只覆盖工作区(`grep`/`glob`)与公网(`web_search`),
5
+ * 企业文档既不在工作区、也不该走公网。模型不知道有企业知识库时,会直接回答"我查不到"
6
+ * 或者凭常识编。片段的作用是**告诉它先去查**,以及别把「配置问题导致的 0 命中」当成
7
+ * 「企业没有这个资料」。
8
+ *
9
+ * 引用格式是**强制**的(`CITATION_RULE`):知识库的答案是给同事看的,没有出处的答案在
10
+ * 内部场景里等于不可用——用户没法核对,也没法顺着找原文。格式刻意对齐聊天卡片展示的
11
+ * 信息(文档标题 + 段号),两者能直接对上。
12
+ */
13
+ /** 系统提示词段落名(稳定值,便于用户按名字关闭)。 */
14
+ export const KNOWLEDGE_PROMPT_NAME = 'hiwork-knowledge';
15
+ /** 排序权重:排在通用工作区规则之后、具体业务插件之前。 */
16
+ export const KNOWLEDGE_PROMPT_ORDER = 62;
17
+ /** 引用格式:与聊天卡片展示的「文档标题 · 段号」一致,用户据此核对原文。 */
18
+ export const CITATION_RULE = '引用出处时必须写成 `依据:<文档标题> · 第 N 段`(照抄检索结果里的标题与段号,不要改写标题);' +
19
+ '一条结论有多个依据就逐条列在同一行、用「;」分隔;没有检索到依据就不要给结论。';
20
+ export const KNOWLEDGE_PROMPT_TEXT = [
21
+ '企业知识库(WeKnora)已接入桌面端,涉及公司制度、产品资料、内部文档的问题,先查知识库再回答:',
22
+ '- `knowledge_list_bases` 列出可见知识库;不确定范围时先调它;',
23
+ '- `knowledge_search` 返回原文片段(含文档标题与段号),是主要入口;',
24
+ '- `knowledge_read_document` 读某篇文档的上下文;',
25
+ '- `knowledge_ask` 把问题整体交给知识库问答链路,返回带引用的结论(慢,适合综述型问题)。',
26
+ CITATION_RULE,
27
+ '知识库检索「返回 0 条」可能是后端检索配置问题(未绑 rerank 模型,或重排阈值过高把相关片段整体滤掉),',
28
+ '遇到时如实说明并建议检查,不要据此断定公司没有相关规定,也不要凭常识补答案。',
29
+ ].join('\n');
30
+ /** 工具说明里引用的行为约束(测试会断言它出现在工具描述中)。 */
31
+ export const KNOWLEDGE_TOOL_GUIDE = '知识库检索返回 0 条时,如实说明情况,不要凭常识作答;引用结论必须附出处。';
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `/hiwork-knowledge` loopback RPC 的线上契约(Host 与 Web 共用)。
3
+ *
4
+ * 规则:
5
+ * - 客户端只提交"意图",永远不能提交凭据、分数或服务端状态;
6
+ * - 请求一律 `.strict()`:多一个字段就 `bad-request`,避免前端悄悄提交 `apiKey`;
7
+ * - **凭据单向**:`KnowledgeConfigView` 只有 `hasApiKey` 布尔,没有任何 Key 片段;
8
+ * - 命中结果里的 `score` 不进线上契约——实测它在未绑 rerank 时是 RRF 定值(0.0164),
9
+ * 展示出来只会误导用户(见设计文档 §3.5 / D5)。
10
+ */
11
+ import { z } from 'zod';
12
+ import { KnowledgeError } from './types.js';
13
+ /** 频道名;客户端 `runtime.ts` 里有一份字面量副本,由测试锁定一致。 */
14
+ export const KNOWLEDGE_RPC_CHANNEL = '/hiwork-knowledge';
15
+ export const KNOWLEDGE_RPC_ENDPOINTS = ['snapshot', 'config-save', 'config-test', 'docs', 'document', 'search'];
16
+ export function isKnowledgeRpcEndpoint(value) {
17
+ return KNOWLEDGE_RPC_ENDPOINTS.includes(value);
18
+ }
19
+ /** 把 Host 的结构投影成线上视图(剥掉凭据与分数)。 */
20
+ export function toBaseView(base) {
21
+ return {
22
+ id: base.id,
23
+ name: base.name,
24
+ description: base.description,
25
+ documentCount: base.documentCount,
26
+ chunkCount: base.chunkCount,
27
+ updatedAt: base.updatedAt,
28
+ };
29
+ }
30
+ export function toDocView(doc) {
31
+ return {
32
+ id: doc.id,
33
+ title: doc.title,
34
+ fileName: doc.fileName,
35
+ fileType: doc.fileType,
36
+ fileSize: doc.fileSize,
37
+ parseStatus: doc.parseStatus,
38
+ enableStatus: doc.enableStatus,
39
+ summaryStatus: doc.summaryStatus,
40
+ folderPath: doc.folderPath,
41
+ createdAt: doc.createdAt,
42
+ };
43
+ }
44
+ export function toDocumentView(document) {
45
+ return {
46
+ knowledgeId: document.knowledgeId,
47
+ title: document.title,
48
+ chunkTotal: document.chunkTotal,
49
+ page: document.page,
50
+ pageSize: document.pageSize,
51
+ truncated: document.truncated,
52
+ chunks: document.chunks.map(chunk => ({
53
+ index: chunk.index,
54
+ type: chunk.type,
55
+ content: chunk.content,
56
+ truncated: chunk.truncated,
57
+ })),
58
+ };
59
+ }
60
+ export function toHitView(hit, maxChunkChars) {
61
+ const truncated = hit.content.length > maxChunkChars;
62
+ return {
63
+ chunkId: hit.chunkId,
64
+ knowledgeId: hit.knowledgeId,
65
+ knowledgeTitle: hit.knowledgeTitle,
66
+ fileName: hit.fileName,
67
+ chunkIndex: hit.chunkIndex,
68
+ chunkType: hit.chunkType,
69
+ content: truncated ? hit.content.slice(0, maxChunkChars) : hit.content,
70
+ truncated,
71
+ };
72
+ }
73
+ const optionalSessionId = z.string().trim().min(1).optional();
74
+ export const snapshotRequestSchema = z.object({ sessionId: optionalSessionId }).strict();
75
+ /** 设置补丁:字段全部可选,但**不接受 schema 之外任何字段**(尤其是不允许未知键)。 */
76
+ export const configSaveRequestSchema = z
77
+ .object({
78
+ sessionId: optionalSessionId,
79
+ patch: z
80
+ .object({
81
+ baseUrl: z.string().optional(),
82
+ apiKey: z.string().optional(),
83
+ tenantId: z.string().optional(),
84
+ defaultBaseIds: z.array(z.string()).optional(),
85
+ maxResults: z.number().int().optional(),
86
+ maxChunkChars: z.number().int().optional(),
87
+ agentId: z.string().optional(),
88
+ chatModelId: z.string().optional(),
89
+ })
90
+ .strict(),
91
+ })
92
+ .strict();
93
+ export const configTestRequestSchema = z.object({ sessionId: optionalSessionId }).strict();
94
+ /**
95
+ * 页码上限 500:翻到第 500 页还没找到想看的内容,说明该换个检索词而不是继续翻,
96
+ * 而一个不带上限的 `page` 能让 `(page-1)*pageSize` 溢出成负索引。
97
+ */
98
+ export const documentRequestSchema = z
99
+ .object({
100
+ sessionId: optionalSessionId,
101
+ knowledgeId: z.string().trim().min(1),
102
+ page: z.number().int().min(1).max(500).optional(),
103
+ })
104
+ .strict();
105
+ export const docsRequestSchema = z.object({ sessionId: optionalSessionId, baseId: z.string().trim().min(1) }).strict();
106
+ export const searchRequestSchema = z
107
+ .object({ sessionId: optionalSessionId, query: z.string().trim().min(1), baseId: z.string().trim().min(1).optional() })
108
+ .strict();
109
+ /** 任意异常 → 稳定 `{ code, message }`;未知异常降级为 `internal`。 */
110
+ export function toRpcErrorValue(error, aborted = false) {
111
+ if (aborted)
112
+ return { code: 'cancelled', message: '知识库请求已取消。' };
113
+ if (error instanceof KnowledgeError)
114
+ return { code: error.code, message: error.message };
115
+ if (error instanceof z.ZodError) {
116
+ const issue = error.issues[0];
117
+ const path = issue?.path.join('.') ?? '';
118
+ return {
119
+ code: 'bad-request',
120
+ message: path === '' ? (issue?.message ?? '请求参数不合法。') : `${path}: ${issue?.message ?? '不合法'}`,
121
+ details: { issues: error.issues.map(item => ({ path: item.path.join('.'), message: item.message })) },
122
+ };
123
+ }
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ return { code: 'internal', message: message === '' ? '知识库服务暂时不可用。' : message };
126
+ }
127
+ /** 解信封(客户端用;同样只认 `{ ok, value | error }`)。 */
128
+ export function unwrapRpcResult(value) {
129
+ if (typeof value !== 'object' || value === null || !('ok' in value)) {
130
+ throw new Error('知识库主机返回了无效响应。');
131
+ }
132
+ const result = value;
133
+ if (result.ok === true && 'value' in result)
134
+ return result.value;
135
+ if (result.ok === false && 'error' in result) {
136
+ throw new Error(result.error?.message ?? '知识库请求失败。');
137
+ }
138
+ throw new Error('知识库主机返回了无效响应。');
139
+ }
package/lib/rpc.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `/hiwork-knowledge` loopback RPC 的 Host 实现。
3
+ *
4
+ * 规则:
5
+ * - 只注册一个频道,endpoint 白名单来自 `protocol.ts`,未知 endpoint 直接 `bad-request`;
6
+ * - 每个 payload 先过 `.strict()` schema:客户端**无法**通过 RPC 提交凭据或分数;
7
+ * - `snapshot` 永不抛错:列库失败时把原因放进 `lastError`(视图要能把"没配 Key"
8
+ * 和"后端挂了"分开显示),其余端点校验失败即返回稳定错误;
9
+ * - `config-test` 才跑自检(会真实打后端 + 探针检索),避免每次打开页面都打穿后端。
10
+ */
11
+ import { KnowledgeError } from './types.js';
12
+ import { KNOWLEDGE_RPC_CHANNEL, configSaveRequestSchema, configTestRequestSchema, docsRequestSchema, documentRequestSchema, isKnowledgeRpcEndpoint, searchRequestSchema, snapshotRequestSchema, toBaseView, toDocView, toDocumentView, toHitView, toRpcErrorValue, } from './protocol.js';
13
+ function ok(value) {
14
+ return { ok: true, value };
15
+ }
16
+ /** 组装快照:凭据视图 + 知识库列表 + (可选)上次自检结果。 */
17
+ export async function buildSnapshot(service, signal, selfCheck = null) {
18
+ const config = service.getConfigView();
19
+ let bases = [];
20
+ let lastError = null;
21
+ if (!config.hasApiKey) {
22
+ lastError = '尚未配置 WeKnora API Key:请在下方填写后点「测试连接」。';
23
+ }
24
+ else {
25
+ try {
26
+ bases = (await service.listBases(signal)).map(toBaseView);
27
+ }
28
+ catch (error) {
29
+ lastError = error instanceof KnowledgeError ? error.message : String(error);
30
+ }
31
+ }
32
+ return {
33
+ config,
34
+ selfCheck,
35
+ bases,
36
+ lastError,
37
+ serverNow: new Date().toISOString(),
38
+ };
39
+ }
40
+ export function registerKnowledgeRpc(ctx, service) {
41
+ const handler = async (endpoint, rawPayload, signal) => {
42
+ try {
43
+ if (!isKnowledgeRpcEndpoint(endpoint)) {
44
+ throw new KnowledgeError('bad-request', `未知的知识库端点 '${endpoint}'`);
45
+ }
46
+ switch (endpoint) {
47
+ case 'snapshot': {
48
+ snapshotRequestSchema.parse(rawPayload);
49
+ return ok(await buildSnapshot(service, signal));
50
+ }
51
+ case 'config-save': {
52
+ const payload = configSaveRequestSchema.parse(rawPayload);
53
+ const config = await service.saveConfig(payload.patch);
54
+ const value = { config };
55
+ return ok(value);
56
+ }
57
+ case 'config-test': {
58
+ configTestRequestSchema.parse(rawPayload);
59
+ const selfCheck = await service.selfCheck(signal);
60
+ const value = { selfCheck };
61
+ return ok(value);
62
+ }
63
+ case 'docs': {
64
+ const payload = docsRequestSchema.parse(rawPayload);
65
+ const docs = await service.listDocs(payload.baseId, signal);
66
+ const value = { baseId: payload.baseId, docs: docs.map(toDocView) };
67
+ return ok(value);
68
+ }
69
+ case 'document': {
70
+ const payload = documentRequestSchema.parse(rawPayload);
71
+ const document = await service.readDocument(payload.knowledgeId, payload.page === undefined ? {} : { page: payload.page }, signal);
72
+ const value = { document: toDocumentView(document) };
73
+ return ok(value);
74
+ }
75
+ case 'search': {
76
+ const payload = searchRequestSchema.parse(rawPayload);
77
+ const maxChunkChars = service.effectiveConfig().maxChunkChars;
78
+ const hits = await service.search(payload.query, payload.baseId === undefined ? {} : { baseIds: [payload.baseId] }, signal);
79
+ const value = {
80
+ query: payload.query,
81
+ hits: hits.map(hit => toHitView(hit, maxChunkChars)),
82
+ };
83
+ return ok(value);
84
+ }
85
+ }
86
+ throw new KnowledgeError('bad-request', `未知的知识库端点 '${endpoint}'`);
87
+ }
88
+ catch (error) {
89
+ const failure = toRpcErrorValue(error, signal.aborted);
90
+ // DSH Connection 的响应信封要求 error.details 是对象;协议里它是可选的,
91
+ // 这里补空对象,避免客户端 SDK 的 schema 校验拒绝整个响应。
92
+ const value = failure.details === undefined ? { ...failure, details: {} } : failure;
93
+ if (value.code === 'internal') {
94
+ ctx.logger.warn(`hiwork-knowledge: RPC '${endpoint}' 失败:${value.message}`);
95
+ }
96
+ return { ok: false, error: value };
97
+ }
98
+ };
99
+ const remove = ctx.connection.rpc.handle(KNOWLEDGE_RPC_CHANNEL, handler, { authority: 'loopback' });
100
+ return async () => {
101
+ await remove();
102
+ };
103
+ }
package/lib/service.js ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Host 侧知识库服务:凭据解析、WeKnora 调用、自检。
3
+ *
4
+ * 不变量:
5
+ * - **凭据只在这里落地**:读取(存储域 / 环境变量 / bundle 默认)与使用(拼请求头)都在
6
+ * Host 半边,Web 半边只看得到 `hasApiKey` 这类布尔;
7
+ * - 出网客户端按「有效配置」缓存,配置一变(保存设置)立刻重建,避免用旧 Key 打后端;
8
+ * - 自检分两层:**连接**(认证 + 列库)与**检索健康**(探针词是否命中、分数是否像重排分)。
9
+ * 后者是必须的——实测未绑重排模型时后端会"静默给错结果"(任意查询返回同一分块、
10
+ * 分数恒为 RRF 值),HTTP 层完全看不出来;
11
+ * - 所有对外方法都接受 `AbortSignal` 并把它透传到出网调用。
12
+ */
13
+ import { DEFAULT_BASE_URL, DEFAULT_KNOWLEDGE_CONFIG, KNOWLEDGE_DOMAIN_VERSION, KNOWLEDGE_SETTINGS_KEY, KnowledgeError, RRF_SCORE_CEILING, SELF_CHECK_BASE_LIMIT, knowledgeDomainSpec, knowledgeSettingsRecordSchema, normalizeBaseUrl, } from './types.js';
14
+ import { createWeKnoraClient, } from './weknora.js';
15
+ const API_KEY_ENV_NAMES = ['HIWORK_KNOWLEDGE_API_KEY', 'WEKNORA_API_KEY'];
16
+ const BASE_URL_ENV_NAMES = ['HIWORK_KNOWLEDGE_BASE_URL', 'WEKNORA_BASE_URL'];
17
+ /** 读取环境变量中的凭据(开发/CI 便利;存储域里的设置优先级更高)。 */
18
+ function envApiKey(env) {
19
+ for (const name of API_KEY_ENV_NAMES) {
20
+ const value = env[name];
21
+ if (typeof value === 'string' && value.trim() !== '')
22
+ return value.trim();
23
+ }
24
+ return '';
25
+ }
26
+ function envBaseUrl(env) {
27
+ for (const name of BASE_URL_ENV_NAMES) {
28
+ const value = env[name];
29
+ if (typeof value === 'string' && value.trim() !== '')
30
+ return value.trim();
31
+ }
32
+ return '';
33
+ }
34
+ /** 把用户补丁合并进当前配置,并校验(非法输入抛 `bad-request`)。 */
35
+ export function mergeConfig(current, patch) {
36
+ const next = {
37
+ ...current,
38
+ ...patch,
39
+ baseUrl: patch.baseUrl === undefined ? current.baseUrl : normalizeBaseUrl(patch.baseUrl),
40
+ };
41
+ const parsed = knowledgeSettingsRecordSchema.shape.config.safeParse(next);
42
+ if (!parsed.success) {
43
+ const issue = parsed.error.issues[0];
44
+ const path = issue?.path.join('.') ?? '';
45
+ const message = issue?.message ?? '字段不合法';
46
+ throw new KnowledgeError('bad-request', `配置不合法:${path === '' ? message : `${path} ${message}`}`);
47
+ }
48
+ return parsed.data;
49
+ }
50
+ /** 把片段截断到 `maxChunkChars`,并显式报告是否截断(不静默丢内容)。 */
51
+ export function clipContent(content, limit) {
52
+ if (content.length <= limit)
53
+ return { text: content, truncated: false };
54
+ return { text: content.slice(0, limit), truncated: true };
55
+ }
56
+ /** 知识库服务。 */
57
+ export class KnowledgeService {
58
+ ctx;
59
+ domain;
60
+ stored;
61
+ bundleDefault = { ...DEFAULT_KNOWLEDGE_CONFIG };
62
+ cached;
63
+ constructor(ctx) {
64
+ this.ctx = ctx;
65
+ }
66
+ /** 打开存储域并读回已保存的设置。 */
67
+ static async open(ctx, bundleDefault) {
68
+ const service = new KnowledgeService(ctx);
69
+ service.bundleDefault = mergeConfig({ ...DEFAULT_KNOWLEDGE_CONFIG }, bundleDefault ?? {});
70
+ service.domain = await ctx.storageDomain.open(knowledgeDomainSpec);
71
+ const table = service.domain.table('settings');
72
+ const record = table.get(KNOWLEDGE_SETTINGS_KEY);
73
+ if (record !== undefined) {
74
+ const parsed = knowledgeSettingsRecordSchema.safeParse(record);
75
+ if (parsed.success)
76
+ service.stored = parsed.data.config;
77
+ else
78
+ ctx.logger.warn('hiwork-knowledge: 已保存的设置不符合当前 schema,已忽略并回落到默认值。');
79
+ }
80
+ return service;
81
+ }
82
+ async dispose() {
83
+ const domain = this.domain;
84
+ this.domain = undefined;
85
+ this.cached = undefined;
86
+ await domain?.close().catch(() => undefined);
87
+ }
88
+ /** 有效凭据来源(存储域 > 环境变量 > 默认空)。 */
89
+ apiKeySource() {
90
+ if (this.stored !== undefined && this.stored.apiKey !== '')
91
+ return 'settings';
92
+ if (envApiKey(process.env) !== '')
93
+ return 'env';
94
+ return 'none';
95
+ }
96
+ /** 有效配置(含明文 Key,仅 Host 内部使用)。 */
97
+ effectiveConfig() {
98
+ const base = {
99
+ ...DEFAULT_KNOWLEDGE_CONFIG,
100
+ ...this.bundleDefault,
101
+ ...(this.stored ?? {}),
102
+ };
103
+ if (base.apiKey === '') {
104
+ const fromEnv = envApiKey(process.env);
105
+ if (fromEnv !== '')
106
+ base.apiKey = fromEnv;
107
+ }
108
+ if (base.baseUrl === DEFAULT_BASE_URL) {
109
+ const fromEnv = envBaseUrl(process.env);
110
+ if (fromEnv !== '')
111
+ base.baseUrl = normalizeBaseUrl(fromEnv);
112
+ }
113
+ return base;
114
+ }
115
+ /** 设置页视图:剥掉凭据明文。 */
116
+ getConfigView() {
117
+ const config = this.effectiveConfig();
118
+ return {
119
+ baseUrl: config.baseUrl,
120
+ tenantId: config.tenantId,
121
+ hasApiKey: config.apiKey !== '',
122
+ apiKeySource: this.apiKeySource(),
123
+ defaultBaseIds: [...config.defaultBaseIds],
124
+ maxResults: config.maxResults,
125
+ maxChunkChars: config.maxChunkChars,
126
+ agentId: config.agentId,
127
+ chatModelId: config.chatModelId,
128
+ };
129
+ }
130
+ /**
131
+ * 保存设置补丁。
132
+ *
133
+ * 空字符串 = 显式清空;`undefined` = 保持原值。写盘后立刻丢弃缓存的客户端。
134
+ */
135
+ async saveConfig(patch) {
136
+ const domain = this.requireDomain();
137
+ const base = this.effectiveConfig();
138
+ const next = mergeConfig(base, patch);
139
+ await domain.table('settings').put(KNOWLEDGE_SETTINGS_KEY, {
140
+ version: KNOWLEDGE_DOMAIN_VERSION,
141
+ config: next,
142
+ });
143
+ this.stored = next;
144
+ this.cached = undefined;
145
+ return this.getConfigView();
146
+ }
147
+ /** 出网客户端(按有效配置缓存)。 */
148
+ client() {
149
+ const config = this.effectiveConfig();
150
+ const key = JSON.stringify([config.baseUrl, config.apiKey, config.tenantId, config.requestTimeoutMs, config.chatTimeoutMs, config.chatModelId]);
151
+ if (this.cached !== undefined && this.cached.key === key)
152
+ return this.cached.client;
153
+ const client = createWeKnoraClient(config);
154
+ this.cached = { key, client };
155
+ return client;
156
+ }
157
+ /** 需要凭据的调用统一先过这道门;没配 Key 时给出可执行的提示。 */
158
+ requireCredentials() {
159
+ const config = this.effectiveConfig();
160
+ if (config.apiKey === '') {
161
+ throw new KnowledgeError('misconfigured', '尚未配置 WeKnora API Key,请在「设置 → 知识库」里填写后重试。');
162
+ }
163
+ return this.client();
164
+ }
165
+ requireDomain() {
166
+ if (this.domain === undefined)
167
+ throw new KnowledgeError('internal', '知识库存储域尚未打开。');
168
+ return this.domain;
169
+ }
170
+ listBases(signal) {
171
+ return this.requireCredentials().listBases(signal);
172
+ }
173
+ listDocs(baseId, signal) {
174
+ return this.requireCredentials().listDocs(baseId, signal);
175
+ }
176
+ async search(query, scope = {}, signal) {
177
+ const config = this.effectiveConfig();
178
+ const client = this.requireCredentials();
179
+ const trimmed = query.trim();
180
+ if (trimmed === '')
181
+ throw new KnowledgeError('bad-request', '检索词不能为空。');
182
+ const baseIds = scope.baseIds === undefined || scope.baseIds.length === 0 ? config.defaultBaseIds : scope.baseIds;
183
+ const resolved = baseIds.length > 0 ? baseIds : (await client.listBases(signal)).map(base => base.id);
184
+ if (resolved.length === 0) {
185
+ throw new KnowledgeError('bad-request', '没有可检索的知识库:请先在后端建库,或在设置里指定默认范围。');
186
+ }
187
+ return client.search(trimmed, { baseIds: resolved, ...(scope.knowledgeIds === undefined ? {} : { knowledgeIds: scope.knowledgeIds }) }, signal);
188
+ }
189
+ /** 读一篇文档:分块 + 标题,按 `chunkIndex` 顺序拼装并分页。 */
190
+ async readDocument(knowledgeId, options = {}, signal) {
191
+ const client = this.requireCredentials();
192
+ const config = this.effectiveConfig();
193
+ const [doc, chunks] = await Promise.all([
194
+ client.getDoc(knowledgeId, signal),
195
+ client.chunks(knowledgeId, signal),
196
+ ]);
197
+ if (doc === null && chunks.length === 0) {
198
+ throw new KnowledgeError('not-found', `未找到文档 ${knowledgeId},或它不属于当前凭据可见的知识库。`);
199
+ }
200
+ const ordered = [...chunks].sort((left, right) => (left.chunkIndex ?? 0) - (right.chunkIndex ?? 0));
201
+ const pageSize = Math.max(1, options.pageSize ?? 20);
202
+ const page = Math.max(1, options.page ?? 1);
203
+ const start = (page - 1) * pageSize;
204
+ const slice = ordered.slice(start, start + pageSize);
205
+ const items = slice.map(chunk => {
206
+ const clipped = clipContent(chunk.content, config.maxChunkChars);
207
+ return { index: chunk.chunkIndex, type: chunk.chunkType, content: clipped.text, truncated: clipped.truncated };
208
+ });
209
+ return {
210
+ knowledgeId,
211
+ title: doc?.title ?? ordered[0]?.knowledgeTitle ?? knowledgeId,
212
+ chunkTotal: ordered.length,
213
+ page,
214
+ pageSize,
215
+ truncated: ordered.length > start + slice.length || items.some(item => item.truncated),
216
+ chunks: items,
217
+ };
218
+ }
219
+ /** 问答:固定走 `/knowledge-chat` + 自建 Agent(内置 agent 缺 model_id,必失败)。 */
220
+ async ask(query, scope = {}, signal) {
221
+ const config = this.effectiveConfig();
222
+ const client = this.requireCredentials();
223
+ const trimmed = query.trim();
224
+ if (trimmed === '')
225
+ throw new KnowledgeError('bad-request', '问题不能为空。');
226
+ if (config.agentId === '') {
227
+ throw new KnowledgeError('misconfigured', '尚未配置问答 Agent:内置 Agent 的 model_id 为空会被后端拒绝,请先在 WeKnora 里建一个「快速问答」类型的自建 Agent 并把 ID 填进设置。');
228
+ }
229
+ // 实测:不显式给知识库范围时,问答链路常常检索不到任何材料(回答会变成
230
+ // "我没有检索到相关内容")。所以与检索一致:范围为空时自行展开可见知识库列表。
231
+ let baseIds = scope.baseIds === undefined || scope.baseIds.length === 0 ? config.defaultBaseIds : scope.baseIds;
232
+ if (baseIds.length === 0)
233
+ baseIds = (await client.listBases(signal)).map(base => base.id);
234
+ return baseIds.length === 0 ? client.ask(trimmed, {}, signal) : client.ask(trimmed, { baseIds }, signal);
235
+ }
236
+ /**
237
+ * 自检:连接 + 检索健康 + 问答可用性。
238
+ *
239
+ * 检索健康探针用「第一个可见知识库的第一篇文档标题」当查询词——它一定命中自己,
240
+ * 因此「命中 0 条」只可能是配置问题(未绑重排 / 模型 id 失效),不是数据问题。
241
+ */
242
+ async selfCheck(signal) {
243
+ const items = [];
244
+ const config = this.effectiveConfig();
245
+ const checkedAt = new Date().toISOString();
246
+ if (config.apiKey === '') {
247
+ items.push({ code: 'missing-key', ok: false, level: 'error', message: '尚未配置 API Key:请在「设置 → 知识库」填写。' });
248
+ return { ok: false, level: 'error', items, baseCount: 0, checkedAt };
249
+ }
250
+ let bases = [];
251
+ try {
252
+ bases = await this.client().listBases(signal);
253
+ items.push({ code: 'connection', ok: true, level: 'info', message: `已连接 ${normalizeBaseUrl(config.baseUrl)},可见 ${bases.length} 个知识库。` });
254
+ }
255
+ catch (error) {
256
+ const failure = error instanceof KnowledgeError ? error : new KnowledgeError('internal', String(error));
257
+ items.push({ code: failure.code, ok: false, level: 'error', message: failure.message });
258
+ return { ok: false, level: 'error', items, baseCount: 0, checkedAt };
259
+ }
260
+ if (bases.length === 0) {
261
+ items.push({ code: 'empty-bases', ok: false, level: 'warn', message: '当前凭据看不到任何知识库:先在后端建库并上传文档。' });
262
+ return { ok: false, level: 'warn', items, baseCount: 0, checkedAt };
263
+ }
264
+ // 检索健康:用文档标题做探针。
265
+ let probeDone = false;
266
+ for (const base of bases.slice(0, SELF_CHECK_BASE_LIMIT)) {
267
+ let docs = [];
268
+ try {
269
+ docs = await this.client().listDocs(base.id, signal);
270
+ }
271
+ catch {
272
+ continue;
273
+ }
274
+ const title = docs[0]?.title ?? '';
275
+ if (title === '')
276
+ continue;
277
+ try {
278
+ const hits = await this.client().search(title, { baseIds: [base.id] }, signal);
279
+ probeDone = true;
280
+ if (hits.length === 0) {
281
+ items.push({
282
+ code: 'probe-miss',
283
+ ok: false,
284
+ level: 'warn',
285
+ message: `探针检索(知识库「${base.name}」内的文档标题)返回 0 条:请到 WeKnora「设置 → 检索设置」确认已绑定 rerank 模型,且模型未被删除重建(重建会换 ID)。`,
286
+ });
287
+ }
288
+ else {
289
+ const top = Math.max(...hits.map(hit => hit.score ?? 0));
290
+ if (top < RRF_SCORE_CEILING) {
291
+ items.push({
292
+ code: 'rerank-missing',
293
+ ok: false,
294
+ level: 'warn',
295
+ message: `检索最高分 ${top.toFixed(4)} 落在 RRF 区间(<${RRF_SCORE_CEILING}):说明租户检索配置未绑 rerank 模型,结果可能不相关(实测未绑时任意查询都返回同一分块)。`,
296
+ });
297
+ }
298
+ else {
299
+ items.push({
300
+ code: 'retrieval',
301
+ ok: true,
302
+ level: 'info',
303
+ message: `检索探针正常:命中 ${hits.length} 条,最高分 ${top.toFixed(4)}。`,
304
+ });
305
+ }
306
+ }
307
+ }
308
+ catch (error) {
309
+ const failure = error instanceof KnowledgeError ? error : new KnowledgeError('internal', String(error));
310
+ items.push({ code: failure.code, ok: false, level: 'error', message: `检索探针失败:${failure.message}` });
311
+ }
312
+ break;
313
+ }
314
+ if (!probeDone) {
315
+ items.push({
316
+ code: 'probe-skipped',
317
+ ok: false,
318
+ level: 'warn',
319
+ message: '前几个知识库里没有可用的文档,无法验证检索质量:先上传一篇文档再自检。',
320
+ });
321
+ }
322
+ if (config.agentId === '') {
323
+ items.push({
324
+ code: 'ask-unconfigured',
325
+ ok: false,
326
+ level: 'warn',
327
+ message: '未配置问答 Agent:knowledge_ask 暂不可用(内置 Agent 会被后端拒绝)。',
328
+ });
329
+ }
330
+ else {
331
+ items.push({ code: 'ask', ok: true, level: 'info', message: `问答 Agent 已配置:${config.agentId}。` });
332
+ }
333
+ const level = items.some(item => item.level === 'error')
334
+ ? 'error'
335
+ : items.some(item => item.level === 'warn')
336
+ ? 'warn'
337
+ : 'ok';
338
+ return { ok: level === 'ok', level, items, baseCount: bases.length, checkedAt };
339
+ }
340
+ }
341
+ /** 单测/自检用:注入自定义出网的临时客户端工厂。 */
342
+ export function clientForTest(config, transport) {
343
+ return createWeKnoraClient(config, transport);
344
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 知识库工具名(**单一事实源**)。
3
+ *
4
+ * Host 半边用它注册工具,Web 半边用它认领 `tool.call.toolview` 的 keyed 座位——
5
+ * 两边各写一份就会漂移,而 keyed 座位的漂移是**静默**的(拼错不抛错,只是卡片永远不渲染)。
6
+ *
7
+ * 本文件零依赖:client bundle 是自包含的,共享模块不能引任何东西进来。
8
+ */
9
+ /** 4 个只读工具(顺序即注册顺序)。 */
10
+ export const KNOWLEDGE_TOOL_NAMES = [
11
+ 'knowledge_list_bases',
12
+ 'knowledge_search',
13
+ 'knowledge_read_document',
14
+ 'knowledge_ask',
15
+ ];