WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict'
import { after, test } from 'node:test'
import { WeknoraApiError, WeknoraClient } from '../dist/client.js'
import { resolveConfig } from '../dist/config.js'
import { startMockWeknora } from './helpers/mock-weknora.mjs'
const never = new AbortController().signal
/** Start a mock backend and a client wired to it, closed after the test file. */
async function harness(overrides = {}, mockOptions = {}) {
const mock = await startMockWeknora(mockOptions)
after(() => mock.close())
const config = resolveConfig({ baseUrl: mock.url, ...overrides })
return { mock, client: new WeknoraClient(config), config }
}
test('lists knowledge bases and sends the API key header', async () => {
const { mock, client } = await harness({ apiKey: 'secret-key' }, { apiKey: 'secret-key' })
const bases = await client.listKnowledgeBases(never)
assert.deepEqual(bases.map(base => base.id), ['kb-product', 'kb-ops'])
assert.equal(mock.requests.at(-1).headers['x-api-key'], 'secret-key')
})
test('a rejected credential surfaces the status and the server reason', async () => {
const { client } = await harness({ apiKey: 'wrong-key' }, { apiKey: 'right-key' })
await assert.rejects(client.listKnowledgeBases(never), error => {
assert.ok(error instanceof WeknoraApiError)
assert.equal(error.status, 401)
assert.match(error.message, /HTTP 401: invalid api key/)
return true
})
})
test('search posts the documented body and returns ranked hits', async () => {
const { mock, client } = await harness()
const results = await client.search(
{ query: '混合检索 的 阈值 是多少', knowledgeBaseIds: ['kb-product'], knowledgeIds: [] },
never,
)
assert.ok(results.length > 0)
assert.equal(mock.requests.at(-1).path, '/api/v1/knowledge-search')
assert.deepEqual(mock.requests.at(-1).body.knowledge_base_ids, ['kb-product'])
assert.ok(results[0].score >= results.at(-1).score)
})
test('resourceUrls=public is forwarded as a query parameter', async () => {
const { mock, client } = await harness({ resourceUrls: 'public' })
await client.listKnowledgeBases(never)
assert.equal(mock.requests.at(-1).query.resource_urls, 'public')
})
test('resourceUrls defaults to public so figures are loadable', async () => {
const { mock, client } = await harness()
await client.listKnowledgeBases(never)
assert.equal(mock.requests.at(-1).query.resource_urls, 'public')
})
test('handle mode omits the resource_urls query parameter', async () => {
const { mock, client } = await harness({ resourceUrls: 'handle' })
await client.listKnowledgeBases(never)
assert.equal(mock.requests.at(-1).query.resource_urls, undefined)
})
test('a knowledge-base-restricted key falls back from public URLs to handles', async () => {
const { mock, client } = await harness({}, { forbidPublicResourceUrls: true })
const bases = await client.listKnowledgeBases(never)
assert.equal(bases.length, 2)
assert.equal(mock.requests[0].query.resource_urls, 'public')
assert.equal(mock.requests[1].query.resource_urls, undefined)
await client.listKnowledgeBases(never)
assert.equal(mock.requests.at(-1).query.resource_urls, undefined, 'later calls must not retry public mode')
})
test('chunk pages carry their pagination envelope', async () => {
const { client } = await harness()
const page = await client.listChunks({ knowledgeId: 'doc-retrieval-pipeline', page: 1, pageSize: 2 }, never)
assert.equal(page.total, 3)
assert.equal(page.chunks.length, 2)
assert.equal(page.pageSize, 2)
})
test('ask creates a session, streams the answer, and collects citations', async () => {
const { mock, client } = await harness()
const sessionId = await client.createSession('dsh: 阈值', never)
const answer = await client.ask(
{ sessionId, query: '默认的检索阈值是多少', knowledgeBaseIds: ['kb-product'], agentId: undefined, webSearch: false },
never,
)
assert.equal(answer.sessionId, 'session-mock-1')
assert.match(answer.answer, /vector_threshold|向量/)
assert.ok(answer.references.length > 0)
assert.deepEqual(answer.toolCalls, [])
assert.equal(mock.requests.at(-1).path, `/api/v1/knowledge-chat/${sessionId}`)
assert.equal(mock.requests.at(-1).body.channel, 'api')
})
test('an agent id routes to agent-chat and reports the server-side tools', async () => {
const { mock, client } = await harness()
const answer = await client.ask(
{ sessionId: 's1', query: '部署方式有哪些', knowledgeBaseIds: [], agentId: 'agent-9', webSearch: true },
never,
)
assert.equal(mock.requests.at(-1).path, '/api/v1/agent-chat/s1')
assert.equal(mock.requests.at(-1).body.agent_id, 'agent-9')
assert.equal(mock.requests.at(-1).body.agent_enabled, true)
assert.equal(mock.requests.at(-1).body.web_search_enabled, true)
assert.deepEqual(answer.toolCalls, ['knowledge_search'])
})
test('a streamed error event becomes a failed call, not an empty answer', async () => {
const { client } = await harness({}, { streamError: true })
await assert.rejects(
client.ask({ sessionId: 's1', query: 'x', knowledgeBaseIds: [], agentId: undefined, webSearch: false }, never),
/streamed an error: model provider unavailable/,
)
})
// A stream that stops before `complete` was cut short. Returning what arrived
// would present a truncated answer to the model as a whole one.
test('a stream that ends before completing becomes a failed call', async () => {
const { client } = await harness({}, { streamTruncated: true })
await assert.rejects(
client.ask(
{ sessionId: 's1', query: '默认的检索阈值是多少', knowledgeBaseIds: ['kb-product'], agentId: undefined, webSearch: false },
never,
),
/ended before WeKnora completed the answer/,
)
})
test('caller cancellation aborts an in-flight call', async () => {
const { client } = await harness()
const controller = new AbortController()
controller.abort()
await assert.rejects(client.listKnowledgeBases(controller.signal), /the call was cancelled/)
})
test('an unreachable backend reports a transport failure', async () => {
const client = new WeknoraClient(resolveConfig({ baseUrl: 'http://127.0.0.1:1/api/v1', requestTimeoutMs: 1000 }))
await assert.rejects(client.listKnowledgeBases(never), WeknoraApiError)
})
+53
View File
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { ConfigError, normalizeBaseUrl, resolveConfig } from '../dist/config.js'
test('base URLs are normalized to an API root', () => {
assert.equal(normalizeBaseUrl('https://kb.example.com'), 'https://kb.example.com/api/v1')
assert.equal(normalizeBaseUrl('https://kb.example.com/'), 'https://kb.example.com/api/v1')
assert.equal(normalizeBaseUrl('https://kb.example.com/api/v1'), 'https://kb.example.com/api/v1')
assert.equal(normalizeBaseUrl('https://kb.example.com/api/v2/'), 'https://kb.example.com/api/v2')
})
test('an empty config resolves to documented defaults', () => {
const config = resolveConfig(undefined)
assert.equal(config.baseUrl, 'http://localhost:8080/api/v1')
assert.equal(config.maxResults, 8)
assert.equal(config.toolPrefix, 'weknora')
assert.equal(config.resourceUrls, 'public')
assert.deepEqual(config.knowledgeBaseIds, [])
assert.deepEqual(config.tools, { listKnowledgeBases: true, search: true, readDocument: true, ask: true })
})
test('resourceUrls handle remains an explicit opt-out', () => {
assert.equal(resolveConfig({ resourceUrls: 'handle' }).resourceUrls, 'handle')
})
test('a bad row fails the load and names every violation', () => {
assert.throws(
() => resolveConfig({ baseUrl: 'not-a-url', maxResults: -1, resourceUrls: 'nope', toolPrefix: '9bad' }),
error => {
assert.ok(error instanceof ConfigError)
assert.match(error.message, /baseUrl must be an absolute URL/)
assert.match(error.message, /maxResults must be a positive number/)
assert.match(error.message, /resourceUrls must be "handle" or "public"/)
assert.match(error.message, /toolPrefix must match/)
return true
},
)
})
test('disabling every tool is refused', () => {
assert.throws(
() => resolveConfig({ tools: { listKnowledgeBases: false, search: false, readDocument: false, ask: false } }),
/at least one tool must stay enabled/,
)
})
test('blank strings collapse to unset rather than empty headers', () => {
const config = resolveConfig({ apiKey: ' ', tenantId: '', agentId: 'agent-7' })
assert.equal(config.apiKey, undefined)
assert.equal(config.tenantId, undefined)
assert.equal(config.agentId, 'agent-7')
})
+136
View File
@@ -0,0 +1,136 @@
/**
* The plugin side of the API contract: the calls this package makes must stay
* exactly the ones recorded in test/fixtures/api-contract.json, which
* contract/contract_test.go checks against WeKnora's real Go request and
* response types. Change a request body and both sides tell you.
*/
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { after, test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { WeknoraClient } from '../dist/client.js'
import { resolveConfig } from '../dist/config.js'
import { createTools } from '../dist/tools.js'
import { startMockWeknora } from './helpers/mock-weknora.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const fixture = JSON.parse(await readFile(join(here, 'fixtures', 'api-contract.json'), 'utf8'))
const exec = { signal: new AbortController().signal }
/** Replay every documented call against the mock and record what went out. */
async function replayCalls() {
const mock = await startMockWeknora()
after(() => mock.close())
const ragConfig = resolveConfig({ baseUrl: mock.url, knowledgeBaseIds: ['kb-product'] })
const ragTools = new Map(createTools(new WeknoraClient(ragConfig), ragConfig).map(tool => [tool.name, tool]))
await ragTools.get('weknora_list_knowledge_bases').execute({}, exec)
await ragTools.get('weknora_search').execute({ query: '默认的检索阈值是多少' }, exec)
await ragTools.get('weknora_read_document').execute(
{ knowledge_id: 'doc-retrieval-pipeline', page: 1, page_size: 5 },
exec,
)
await ragTools.get('weknora_ask').execute({ query: '默认的检索阈值是多少' }, exec)
const agentConfig = resolveConfig({ baseUrl: mock.url, agentId: 'agent-42' })
const agentAsk = createTools(new WeknoraClient(agentConfig), agentConfig)
.find(tool => tool.name === 'weknora_ask')
await agentAsk.execute({ query: '部署方式', session_id: 's1', web_search: true }, exec)
return mock.requests.map(request => ({
method: request.method,
path: request.path,
query: request.query,
body: Object.keys(request.body).length === 0 ? null : request.body,
}))
}
const observed = await replayCalls()
/** Order-insensitive key: a tool that fires two calls at once may land either way round. */
const callKey = call => `${call.method} ${call.path} ${JSON.stringify(call.query)} ${JSON.stringify(call.body)}`
test('the plugin makes exactly the documented WeKnora calls', () => {
const expected = fixture.calls.map(call => ({
method: call.method,
path: call.path,
query: call.query,
body: call.body,
}))
assert.deepEqual(
observed.map(callKey).sort(),
expected.map(callKey).sort(),
)
})
test('the fixture covers every endpoint the plugin touches', () => {
const endpoints = new Set(observed.map(call => call.path.replace(/\/(session-mock-1|s1|doc-[\w-]+)$/, '/:id')))
assert.deepEqual([...endpoints].sort(), [
'/api/v1/agent-chat/:id',
'/api/v1/chunks/:id',
'/api/v1/knowledge-bases',
'/api/v1/knowledge-search',
'/api/v1/knowledge-chat/:id',
'/api/v1/knowledge/search',
'/api/v1/knowledge/:id',
'/api/v1/sessions',
].sort())
})
test('every stream event the plugin handles is declared in the fixture', async () => {
const mock = await startMockWeknora()
after(() => mock.close())
const config = resolveConfig({ baseUrl: mock.url, agentId: 'agent-42' })
const client = new WeknoraClient(config)
const answer = await client.ask(
{ sessionId: 's1', query: '默认的检索阈值是多少', knowledgeBaseIds: [], agentId: 'agent-42', webSearch: false },
exec.signal,
)
// The mock streams tool_call, references, answer and complete; error is
// covered in client.test.mjs. All four must be in the declared set.
for (const event of ['answer', 'references', 'tool_call', 'complete', 'error']) {
assert.ok(fixture.streamResponseTypes.includes(event), `${event} must be declared in the fixture`)
}
assert.notEqual(answer.answer, '')
assert.notEqual(answer.references.length, 0)
assert.deepEqual(answer.toolCalls, ['knowledge_search'])
})
test('the plugin only reads response fields the fixture declares', async () => {
const mock = await startMockWeknora()
after(() => mock.close())
const config = resolveConfig({ baseUrl: mock.url })
const client = new WeknoraClient(config)
// A backend that serves nothing but the declared fields must still produce a
// complete tool result, which is what "these are the fields we depend on" means.
const declared = new Set(fixture.responseFieldsRead['types.SearchResult'])
const results = await client.search({ query: '混合检索', knowledgeBaseIds: ['kb-product'], knowledgeIds: [] }, exec.signal)
const trimmed = results.map(result => Object.fromEntries(
Object.entries(result).filter(([key]) => declared.has(key)),
))
const tools = createTools(new WeknoraClient(config), config)
const search = tools.find(tool => tool.name === 'weknora_search')
// Feed the trimmed shape back through the projection the tool uses.
const projected = search.output.render({ query: '混合检索' }, {
query: '混合检索',
knowledge_base_ids: [],
documents: [],
count: trimmed.length,
results: trimmed.map((result, index) => ({
rank: index + 1,
chunk_id: result.id ?? '',
knowledge_id: result.knowledge_id ?? '',
document: result.knowledge_title ?? result.knowledge_filename ?? '(untitled)',
chunk_index: result.chunk_index ?? -1,
score: result.score ?? 0,
content: result.content ?? '',
truncated: false,
})),
documents: [],
})
assert.match(projected[0].text, /knowledge_id: doc-/)
assert.match(projected[0].text, /score \d\.\d{3}/)
})
@@ -0,0 +1,141 @@
/**
* A deterministic OpenAI-compatible model server for the end-to-end run. dsh's
* `llm-pi-ai` adapter talks to it as an ordinary `openai-completions` gateway,
* which lets the real harness agent loop drive real tool calls with no API key
* and no nondeterminism.
*
* The scripted policy is: search the knowledge base, read the document a hit
* points at, then answer using only what the tools returned. Every request is
* recorded so the test can assert which tool schemas the model was offered.
*/
import { createServer } from 'node:http'
/** Emit one OpenAI streaming chunk. */
function chunk(response, model, delta, finishReason = null) {
const payload = {
id: 'chatcmpl-weknora-e2e',
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta, finish_reason: finishReason }],
}
response.write(`data: ${JSON.stringify(payload)}\n\n`)
}
function streamToolCall(response, model, name, args) {
chunk(response, model, {
role: 'assistant',
content: null,
tool_calls: [{ index: 0, id: `call_${name}`, type: 'function', function: { name, arguments: '' } }],
})
chunk(response, model, {
tool_calls: [{ index: 0, function: { arguments: JSON.stringify(args) } }],
})
chunk(response, model, {}, 'tool_calls')
response.write('data: [DONE]\n\n')
response.end()
}
function streamText(response, model, text) {
chunk(response, model, { role: 'assistant', content: '' })
for (const piece of text.match(/.{1,40}/gs) ?? []) chunk(response, model, { content: piece })
chunk(response, model, {}, 'stop')
response.write('data: [DONE]\n\n')
response.end()
}
/** The user's question, taken from the first user message in the request. */
function userQuestion(messages) {
const first = messages.find(message => message.role === 'user')
if (first === undefined) return ''
return typeof first.content === 'string'
? first.content
: (first.content ?? []).map(part => part.text ?? '').join(' ')
}
function toolMessages(messages) {
return messages.filter(message => message.role === 'tool')
.map(message => typeof message.content === 'string'
? message.content
: (message.content ?? []).map(part => part.text ?? '').join('\n'))
}
/** Which of our tools the assistant already called, in order. */
function calledTools(messages) {
return messages
.filter(message => message.role === 'assistant' && Array.isArray(message.tool_calls))
.flatMap(message => message.tool_calls.map(call => call.function?.name))
.filter(name => typeof name === 'string')
}
/**
* Start the fake model.
* @param options.searchTool - tool name to call first.
* @param options.readTool - tool name to call with a knowledge_id from the first result.
* @returns the base URL (with `/v1`), the recorded requests, and a close function.
*/
export async function startFakeModel(options = {}) {
const searchTool = options.searchTool ?? 'weknora_search'
const readTool = options.readTool ?? 'weknora_read_document'
const requests = []
const server = createServer((request, response) => {
const chunks = []
request.on('data', part => chunks.push(part))
request.on('end', () => {
const url = new URL(request.url, 'http://fake-model.invalid')
if (!url.pathname.endsWith('/chat/completions')) {
response.writeHead(404, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ error: { message: `no route for ${url.pathname}` } }))
return
}
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
const messages = body.messages ?? []
const offeredTools = (body.tools ?? []).map(tool => tool.function?.name).filter(Boolean)
requests.push({
model: body.model,
offeredTools,
roles: messages.map(message => message.role),
toolResults: toolMessages(messages),
})
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
const already = calledTools(messages)
const results = toolMessages(messages)
if (!already.includes(searchTool)) {
streamToolCall(response, body.model, searchTool, { query: userQuestion(messages) })
return
}
if (!already.includes(readTool)) {
const knowledgeId = /knowledge_id: (\S+)/.exec(results.join('\n'))?.[1]
if (knowledgeId !== undefined) {
streamToolCall(response, body.model, readTool, { knowledge_id: knowledgeId, page: 1, page_size: 5 })
return
}
}
// Answer strictly from what the tools returned, so the transcript proves
// the retrieved bytes reached the model rather than a canned string.
const grounding = results.join('\n').replace(/\s+/g, ' ').trim().slice(0, 400)
streamText(response, body.model, `根据 WeKnora 知识库检索结果回答:${grounding}`)
})
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address()
return {
url: `http://127.0.0.1:${port}/v1`,
requests,
async close() {
await new Promise(resolve => server.close(resolve))
},
}
}
@@ -0,0 +1,270 @@
#!/usr/bin/env node
/**
* End-to-end check inside a real DeepSeek Harness install: install this package
* into a throwaway dsh profile with `dsh plugin add`, boot the headless profile
* against a fake OpenAI-compatible model and a mock WeKnora backend, and assert
* that the harness agent loop actually called the tools and answered from what
* they returned.
*
* Three scenarios run, because all three are documented paths:
* A. the bundle's shipped `cordis.patch.yml`, configured only by environment
* variables — what a user gets straight after `dsh plugin add`.
* B. a profile patch that overrides the row, including a renamed tool prefix,
* which also proves configuration reaches the plugin.
* C. a row with no knowledge base scope, where the plugin resolves the full
* visible set itself — WeKnora refuses an unscoped search.
*
* Usage:
* node test/e2e/run-in-dsh.mjs # installs dsh into a cache dir
* DSH_BIN=/path/to/dsh node test/e2e/run-in-dsh.mjs
* DSH_E2E_LOG=/tmp/transcript.log node test/e2e/run-in-dsh.mjs
*
* Environment:
* DSH_PACKAGE_SPEC npm spec for the harness (default `@deepseek-ai/dsh@latest`)
* DSH_INSTALL_DIR where to keep that install (default `<tmp>/dsh-e2e-install`)
* DSH_E2E_KEEP_HOME keep the throwaway `DSH_HOME` for inspection
*/
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { startFakeModel } from './fake-model.mjs'
import { startMockWeknora } from '../helpers/mock-weknora.mjs'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const installDir = process.env.DSH_INSTALL_DIR ?? join(tmpdir(), 'dsh-e2e-install')
const dshSpec = process.env.DSH_PACKAGE_SPEC ?? '@deepseek-ai/dsh@latest'
const API_KEY = 'e2e-api-key'
const QUESTION = 'WeKnora 的默认检索阈值是多少?请查知识库后回答。'
const transcript = []
function log(line) {
transcript.push(line)
process.stdout.write(`${line}\n`)
}
/** Run a command to completion, capturing both streams. */
function run(command, args, options = {}) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, {
cwd: options.cwd ?? packageDir,
env: { ...process.env, ...options.env },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.on('data', part => {
stdout += part
})
child.stderr.on('data', part => {
stderr += part
})
child.on('error', rejectRun)
child.on('close', code => resolveRun({ code, stdout, stderr }))
})
}
/** Resolve a dsh launcher, installing one from npm when none was supplied. */
async function resolveDsh() {
if (process.env.DSH_BIN !== undefined) return process.env.DSH_BIN
const binary = join(installDir, 'node_modules', '.bin', 'dsh')
if (existsSync(binary)) {
log(`# reusing the dsh install at ${installDir}`)
return binary
}
log(`# installing ${dshSpec} into ${installDir} (first run only)`)
await mkdir(installDir, { recursive: true })
await writeFile(join(installDir, 'package.json'), JSON.stringify({ name: 'dsh-e2e-install', private: true }, null, 2))
const install = await run('npm', ['install', '--no-audit', '--no-fund', dshSpec], { cwd: installDir })
if (install.code !== 0) throw new Error(`npm install ${dshSpec} failed:\n${install.stderr}`)
return binary
}
/** The two rows that make dsh talk to the fake model instead of DeepSeek. */
function modelPatchRows(modelUrl) {
return [
{
id: 'agent-default-model',
config: { provider: 'weknora-e2e', model: 'weknora-e2e-model' },
},
{
id: 'llm-pi-ai',
config: {
providers: {
'weknora-e2e': {
displayName: 'WeKnora e2e gateway',
apiKeyEnv: 'WEKNORA_E2E_MODEL_KEY',
api: 'openai-completions',
baseURL: modelUrl,
models: [{
id: 'weknora-e2e-model',
name: 'WeKnora e2e model',
contextWindow: 65536,
maxTokens: 4096,
}],
},
},
},
},
]
}
/**
* Boot the headless profile once and assert the whole path: the harness offered
* the tools, the tools reached WeKnora, the retrieved passages reached the
* model, and the printed answer is grounded in them.
*/
async function scenario(options) {
const { label, dsh, home, env, patch, prefix, weknora, autoScope = false } = options
const model = await startFakeModel({ searchTool: `${prefix}_search`, readTool: `${prefix}_read_document` })
const requestsBefore = weknora.requests.length
try {
await writeFile(
join(home, 'profiles', 'headless', 'cordis.patch.yml'),
JSON.stringify([...patch, ...modelPatchRows(model.url)], null, 2),
)
const dump = await run(dsh, ['--profile', 'headless', '--dump-config'], { env })
assert.equal(dump.code, 0, `--dump-config must succeed:\n${dump.stderr}`)
assert.match(dump.stdout, /name: ["']?@wxg-prc-cpg\/dsh-weknora/, 'the composed tree must mount the plugin')
log(`\n=== ${label} ===`)
log(`$ dsh --profile headless "${QUESTION}"`)
const boot = await run(dsh, ['--profile', 'headless', QUESTION], { env })
log(`--- stdout ---\n${boot.stdout.trim()}`)
if (boot.stderr.trim() !== '') log(`--- stderr ---\n${boot.stderr.trim()}`)
assert.equal(boot.code, 0, 'the headless run must exit 0')
const offered = (model.requests.at(0)?.offeredTools ?? []).filter(name => name.startsWith(`${prefix}_`))
log(`# tools offered to the model: ${offered.join(', ')}`)
for (const suffix of ['search', 'read_document', 'ask', 'list_knowledge_bases']) {
assert.ok(offered.includes(`${prefix}_${suffix}`), `${prefix}_${suffix} must appear in the model's tool schemas`)
}
const calls = weknora.requests.slice(requestsBefore)
const paths = calls.map(request => request.path)
log(`# WeKnora received: ${paths.join(', ')}`)
assert.ok(paths.includes('/api/v1/knowledge-search'), 'search must reach the retrieval endpoint')
if (autoScope) {
// WeKnora rejects an unscoped retrieval, so with nothing configured the
// plugin has to resolve the full visible set itself — the model asked a
// plain question and never named a knowledge base.
assert.ok(
paths.indexOf('/api/v1/knowledge-bases') >= 0
&& paths.indexOf('/api/v1/knowledge-bases') < paths.indexOf('/api/v1/knowledge-search'),
'the plugin must resolve the visible knowledge bases before retrieving',
)
const search = calls.find(request => request.path === '/api/v1/knowledge-search')
assert.deepEqual(
search.body.knowledge_base_ids,
['kb-product', 'kb-ops'],
'an unconfigured deployment must search every visible knowledge base',
)
}
assert.ok(paths.some(path => path.startsWith('/api/v1/chunks/')), 'read_document must reach the chunk endpoint')
assert.equal(
weknora.requests.slice(requestsBefore).every(request => request.headers['x-api-key'] === API_KEY),
true,
'every call must carry the configured API key',
)
const toolResults = model.requests.flatMap(request => request.toolResults).join('\n')
assert.match(toolResults, /vector_threshold 是 0\.5/, 'the model must see the retrieved passage verbatim')
assert.match(toolResults, /knowledge_id: doc-retrieval-pipeline/, 'search hits must carry a usable knowledge_id')
assert.match(boot.stdout, /0\.5/, 'the printed answer must carry the retrieved threshold')
log(`# PASS (${label}): ${prefix}_search and ${prefix}_read_document ran inside dsh against WeKnora, `
+ 'and the answer came from the retrieved passages.')
} finally {
await model.close()
}
}
async function main() {
if (!existsSync(join(packageDir, 'dist', 'index.js'))) {
throw new Error('build the package first: npm run build')
}
const weknora = await startMockWeknora({ apiKey: API_KEY })
const home = await mkdtemp(join(tmpdir(), 'dsh-weknora-home-'))
const dsh = await resolveDsh()
const env = {
DSH_HOME: home,
WEKNORA_E2E_MODEL_KEY: 'not-a-real-key',
WEKNORA_BASE_URL: weknora.url,
WEKNORA_API_KEY: API_KEY,
WEKNORA_KNOWLEDGE_BASE_IDS: 'kb-product',
}
log(`# mock WeKnora ${weknora.url}`)
log(`# DSH_HOME ${home}`)
log(`# dsh launcher ${dsh}`)
try {
// Install this package into a fresh profile exactly as a user would.
const install = await run(dsh, ['plugin', '--profile', 'headless', 'add', packageDir], { env })
log(`\n$ dsh plugin --profile headless add ${packageDir}\n${install.stdout.trim()}\n${install.stderr.trim()}`)
assert.equal(install.code, 0, 'dsh plugin add must succeed')
const manifest = JSON.parse(await readFile(join(home, 'profiles', 'headless', 'package.json'), 'utf8'))
log(`# profile bundles: ${JSON.stringify(manifest.dsh.profile.bundles)}`)
assert.ok(
manifest.dsh.profile.bundles.includes('@wxg-prc-cpg/dsh-weknora'),
'the bundle must join the profile layer list',
)
// A. The shipped bundle patch, configured only by environment variables.
await scenario({ label: 'A · shipped bundle defaults from env', dsh, home, env, weknora, patch: [], prefix: 'weknora' })
// B. A profile override, which also renames the tools.
await scenario({
label: 'B · profile override with a renamed tool prefix',
dsh,
home,
env,
weknora,
prefix: 'kb',
patch: [{
id: 'weknora',
config: {
baseUrl: weknora.url,
apiKey: API_KEY,
knowledgeBaseIds: ['kb-product'],
toolPrefix: 'kb',
maxResults: 3,
maxChunkChars: 400,
},
}],
})
// C. No configured scope at all, which is what the quickstart's optional
// WEKNORA_KNOWLEDGE_BASE_IDS leaves behind.
await scenario({
label: 'C · no configured scope, resolved by the plugin',
dsh,
home,
env,
weknora,
prefix: 'weknora',
autoScope: true,
patch: [{
id: 'weknora',
config: { baseUrl: weknora.url, apiKey: API_KEY },
}],
})
log('\n# ALL SCENARIOS PASSED')
} finally {
await weknora.close()
const logPath = process.env.DSH_E2E_LOG
if (logPath !== undefined) await writeFile(logPath, `${transcript.join('\n')}\n`)
if (process.env.DSH_E2E_KEEP_HOME === undefined) await rm(home, { recursive: true, force: true })
}
}
await main()
+119
View File
@@ -0,0 +1,119 @@
{
"$comment": [
"The exact WeKnora API calls this plugin makes, and the response fields it reads.",
"test/contract.test.mjs asserts the plugin still emits these calls;",
"contract/contract_test.go asserts the WeKnora server still accepts them and still",
"serves those response fields, so a rename on either side fails CI instead of a user's agent."
],
"calls": [
{
"tool": "weknora_list_knowledge_bases",
"method": "GET",
"path": "/api/v1/knowledge-bases",
"query": { "resource_urls": "public" },
"body": null,
"goRequestType": null
},
{
"tool": "weknora_search",
"method": "POST",
"path": "/api/v1/knowledge-search",
"query": { "resource_urls": "public" },
"body": {
"query": "默认的检索阈值是多少",
"knowledge_base_ids": ["kb-product"]
},
"goRequestType": "session.SearchKnowledgeRequest"
},
{
"tool": "weknora_search",
"method": "GET",
"path": "/api/v1/knowledge/search",
"query": { "keyword": "默认的检索阈值是多少", "limit": "8", "resource_urls": "public" },
"body": null,
"goRequestType": null
},
{
"tool": "weknora_read_document",
"method": "GET",
"path": "/api/v1/chunks/doc-retrieval-pipeline",
"query": { "page": "1", "page_size": "5", "resource_urls": "public" },
"body": null,
"goRequestType": "types.Pagination"
},
{
"tool": "weknora_read_document",
"method": "GET",
"path": "/api/v1/knowledge/doc-retrieval-pipeline",
"query": { "resource_urls": "public" },
"body": null,
"goRequestType": null
},
{
"tool": "weknora_ask",
"method": "POST",
"path": "/api/v1/sessions",
"query": { "resource_urls": "public" },
"body": {
"title": "dsh: 默认的检索阈值是多少"
},
"goRequestType": "session.CreateSessionRequest"
},
{
"tool": "weknora_ask",
"method": "POST",
"path": "/api/v1/knowledge-chat/session-mock-1",
"query": { "resource_urls": "public" },
"body": {
"query": "默认的检索阈值是多少",
"channel": "api",
"knowledge_base_ids": ["kb-product"]
},
"goRequestType": "session.CreateKnowledgeQARequest"
},
{
"tool": "weknora_ask",
"method": "POST",
"path": "/api/v1/agent-chat/s1",
"query": { "resource_urls": "public" },
"body": {
"query": "部署方式",
"channel": "api",
"agent_id": "agent-42",
"agent_enabled": true,
"web_search_enabled": true
},
"goRequestType": "session.CreateKnowledgeQARequest"
}
],
"responseFieldsRead": {
"types.SearchResult": [
"id",
"content",
"knowledge_id",
"chunk_index",
"knowledge_title",
"knowledge_filename",
"score"
],
"types.Chunk": ["id", "content", "chunk_index", "knowledge_id"],
"types.Knowledge": [
"id",
"title",
"file_name",
"description",
"knowledge_base_id",
"knowledge_base_name"
],
"types.StreamResponse": [
"response_type",
"content",
"knowledge_references",
"session_id",
"tool_calls"
],
"types.LLMToolCall": ["function"],
"types.FunctionCall": ["name"]
},
"streamResponseTypes": ["answer", "references", "tool_call", "error", "complete"]
}
@@ -0,0 +1,147 @@
/**
* A validator for the JSON Schema subset the harness tool registry enforces
* (see packages/core/tools/src/json-schema.ts in DeepSeek Harness). The tests
* run every canonical value through it, so a tool whose body drifts from its
* declared `output.schema` fails here rather than inside dsh at runtime.
*/
const SUPPORTED_KEYS = new Set([
'type', 'oneOf', 'properties', 'required', 'additionalProperties',
'items', 'enum', 'const', 'description', 'title', 'default', 'examples',
])
const TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'])
/** Reject a schema the harness would refuse at registration time. */
export function assertSupportedSchema(schema, path = '#') {
const violations = []
const walk = (node, at) => {
if (typeof node !== 'object' || node === null || Array.isArray(node)) {
violations.push(`${at}: schema node must be an object`)
return
}
for (const key of Object.keys(node)) {
if (!SUPPORTED_KEYS.has(key)) violations.push(`${at}: unsupported keyword "${key}"`)
}
if (node.type !== undefined && !TYPES.has(node.type)) violations.push(`${at}: unsupported type "${node.type}"`)
for (const name of node.required ?? []) {
if (node.properties?.[name] === undefined) {
violations.push(`${at}: required property "${name}" is not declared in properties`)
}
}
for (const [name, child] of Object.entries(node.properties ?? {})) walk(child, `${at}/${name}`)
if (node.items !== undefined) walk(node.items, `${at}/items`)
for (const [index, branch] of (node.oneOf ?? []).entries()) walk(branch, `${at}/oneOf/${index}`)
}
walk(schema, path)
if (violations.length > 0) throw new Error(`unsupported JSON schema: ${violations.join('; ')}`)
}
/** Return every way `value` fails `schema`; an empty array means it validates. */
export function validate(schema, value, path = '#') {
const violations = []
const walk = (node, candidate, at) => {
if (node.const !== undefined && candidate !== node.const) {
violations.push(`${at}: expected const ${JSON.stringify(node.const)}`)
return
}
if (node.enum !== undefined && !node.enum.includes(candidate)) {
violations.push(`${at}: ${JSON.stringify(candidate)} is not one of ${JSON.stringify(node.enum)}`)
return
}
if (node.oneOf !== undefined) {
const matches = node.oneOf.filter(branch => validate(branch, candidate, at).length === 0)
if (matches.length !== 1) violations.push(`${at}: expected exactly one oneOf branch to match, ${matches.length} did`)
return
}
switch (node.type) {
case undefined:
return
case 'object': {
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) {
violations.push(`${at}: expected object, got ${describe(candidate)}`)
return
}
for (const name of node.required ?? []) {
if (!(name in candidate)) violations.push(`${at}: missing required property "${name}"`)
}
if (node.additionalProperties === false) {
for (const name of Object.keys(candidate)) {
if (node.properties?.[name] === undefined) violations.push(`${at}: undeclared property "${name}"`)
}
}
for (const [name, child] of Object.entries(node.properties ?? {})) {
if (name in candidate) walk(child, candidate[name], `${at}/${name}`)
}
return
}
case 'array': {
if (!Array.isArray(candidate)) {
violations.push(`${at}: expected array, got ${describe(candidate)}`)
return
}
if (node.items !== undefined) {
candidate.forEach((entry, index) => walk(node.items, entry, `${at}/${index}`))
}
return
}
case 'integer': {
if (typeof candidate !== 'number' || !Number.isInteger(candidate)) {
violations.push(`${at}: expected integer, got ${describe(candidate)}`)
}
return
}
case 'number': {
if (typeof candidate !== 'number' || !Number.isFinite(candidate)) {
violations.push(`${at}: expected finite number, got ${describe(candidate)}`)
}
return
}
case 'string': {
if (typeof candidate !== 'string') violations.push(`${at}: expected string, got ${describe(candidate)}`)
return
}
case 'boolean': {
if (typeof candidate !== 'boolean') violations.push(`${at}: expected boolean, got ${describe(candidate)}`)
return
}
case 'null': {
if (candidate !== null) violations.push(`${at}: expected null, got ${describe(candidate)}`)
return
}
default:
violations.push(`${at}: unsupported type "${node.type}"`)
}
}
walk(schema, value, path)
return violations
}
/** Confirm the value is representable as lossless JSON, as the registry requires. */
export function assertLosslessJson(value, label) {
const seen = new WeakSet()
const walk = (candidate, at) => {
if (candidate === null || typeof candidate === 'string' || typeof candidate === 'boolean') return
if (typeof candidate === 'number') {
if (!Number.isFinite(candidate)) throw new Error(`${label} ${at}: ${String(candidate)} is not lossless JSON`)
return
}
if (typeof candidate !== 'object') throw new Error(`${label} ${at}: ${typeof candidate} is not lossless JSON`)
if (seen.has(candidate)) throw new Error(`${label} ${at}: circular reference`)
seen.add(candidate)
if (Array.isArray(candidate)) {
candidate.forEach((entry, index) => walk(entry, `${at}/${index}`))
return
}
for (const [key, entry] of Object.entries(candidate)) {
if (entry === undefined) throw new Error(`${label} ${at}/${key}: undefined is not lossless JSON`)
walk(entry, `${at}/${key}`)
}
}
walk(value, '#')
}
function describe(value) {
if (value === null) return 'null'
return Array.isArray(value) ? 'array' : typeof value
}
@@ -0,0 +1,368 @@
/**
* A stand-in WeKnora backend used by the unit tests and by the end-to-end run
* inside dsh. It speaks the same routes, envelopes and SSE event types as the
* real server (see internal/handler/session/qa.go and internal/types/chat.go),
* so a test failure here means the plugin, not the fixture, drifted.
*/
import { createServer } from 'node:http'
const DOCUMENTS = [
{
knowledge_id: 'doc-retrieval-pipeline',
knowledge_title: 'WeKnora 检索流程.md',
knowledge_base_id: 'kb-product',
// WeKnora stores the generated summary in `description`, not `summary`.
description: '讲解 WeKnora 混合检索的召回、阈值与父子分块回溯。',
chunks: [
'WeKnora 的混合检索先做向量召回,再做关键词召回,最后交给 rerank 模型统一排序。',
'默认的 vector_threshold 是 0.5,keyword_threshold 是 0.3,可以在知识库配置里按库调整。',
'父子分块开启后,命中子块会回溯父块,把更完整的上下文交给生成模型。',
],
},
{
knowledge_id: 'doc-architecture',
knowledge_title: '系统架构图.md',
knowledge_base_id: 'kb-product',
description: '检索与生成两层的系统架构示意图。',
chunks: [
'系统由检索与生成两层组成,整体结构见架构图。\n\n![系统架构](resource://AbCdEfGhIjKlMnOpQrStUv)',
],
},
{
knowledge_id: 'doc-deployment',
knowledge_title: 'WeKnora 部署手册.md',
knowledge_base_id: 'kb-ops',
description: '单机与 Kubernetes 部署方式,以及可选的向量库后端。',
chunks: [
'WeKnora 支持 docker compose 单机部署,也提供 Helm chart 用于 Kubernetes。',
'向量库可以选择 pgvector、Milvus、Qdrant、Weaviate、OpenSearch 等后端。',
],
},
{
// Title and body share no wording on purpose: retrieving this document by
// name is the only way to reach it, which is what the by-name search is for.
knowledge_id: 'doc-release-checklist',
knowledge_title: '灰度发布检查单.md',
knowledge_base_id: 'kb-ops',
description: '上线前的确认项与回滚准备。',
chunks: [
'上线前需确认监控告警、回滚预案与数据库变更脚本均已就绪,并由值班同学复核。',
],
},
]
// Titled so a by-name query matches it while its text shares nothing with that
// query — the case passage retrieval alone cannot serve.
DOCUMENTS.push({
knowledge_id: 'doc-release-checklist',
knowledge_title: '灰度发布检查单.md',
knowledge_base_id: 'kb-ops',
description: '上线前的确认项与回滚方案。',
chunks: ['本文列出上线前需要确认的配置项与回滚方案。'],
})
const KNOWLEDGE_BASES = [
{ id: 'kb-product', name: 'Product docs', description: 'WeKnora 产品与检索文档' },
{ id: 'kb-ops', name: 'Ops runbooks', description: '部署与运维手册' },
]
const ARCH_HANDLE = 'resource://AbCdEfGhIjKlMnOpQrStUv'
const ARCH_PUBLIC = 'https://cdn.example.com/architecture.png'
/** Mirror WeKnora's `resource_urls=public` rewrite of `resource://` handles. */
function rewriteResources(value, publicMode) {
if (!publicMode) return value
return JSON.parse(JSON.stringify(value).replaceAll(ARCH_HANDLE, ARCH_PUBLIC))
}
/**
* Score a passage by character-bigram overlap. The real backend scores with
* embeddings plus keyword match; bigrams are enough to make the fixture rank
* plausibly for both Chinese and English queries.
*/
function scoreOf(query, content) {
const normalized = query.replace(/[\s,,。??、!!::;;“”"']+/g, '')
const shingles = new Set()
for (let index = 0; index + 2 <= normalized.length; index += 1) {
shingles.add(normalized.slice(index, index + 2))
}
if (shingles.size === 0) return 0
const hits = [...shingles].filter(shingle => content.includes(shingle)).length
return Math.min(0.99, 0.4 + (hits / shingles.size) * 0.5)
}
function searchResults(query, knowledgeBaseIds, knowledgeIds) {
const documents = DOCUMENTS.filter(document =>
(knowledgeIds.length === 0 || knowledgeIds.includes(document.knowledge_id))
&& (knowledgeBaseIds.length === 0 || knowledgeBaseIds.includes(document.knowledge_base_id)))
return documents
.flatMap(document => document.chunks.map((content, index) => ({
id: `${document.knowledge_id}-chunk-${index}`,
content,
knowledge_id: document.knowledge_id,
knowledge_title: document.knowledge_title,
chunk_index: index,
score: scoreOf(query, content),
match_type: 2,
start_at: 0,
end_at: content.length,
metadata: {},
})))
.filter(result => result.score > 0.4)
.sort((left, right) => right.score - left.score)
}
/** The document shape the by-name search and `GET /knowledge/:id` both return. */
function documentRecord(document) {
return {
id: document.knowledge_id,
title: document.knowledge_title,
file_name: document.knowledge_title,
file_type: 'md',
description: document.description,
knowledge_base_id: document.knowledge_base_id,
knowledge_base_name: KNOWLEDGE_BASES.find(kb => kb.id === document.knowledge_base_id)?.name ?? '',
parse_status: 'completed',
summary_status: 'completed',
}
}
/** Assemble the answer the fake RAG/agent pipeline streams back. */
function answerFor(query, results) {
if (results.length === 0) return `没有检索到与「${query}」相关的内容。`
const cited = results.slice(0, 2).map(result => result.content).join(' ')
return `根据知识库内容:${cited}(问题:${query})`
}
/**
* Start the mock backend.
* @param options.apiKey - when set, requests must carry it as `X-API-Key`.
* @param options.streamError - make the chat routes stream an `error` event.
* @param options.streamTruncated - end the chat stream mid-answer, with no `complete`.
* @param options.forbidPublicResourceUrls - reject `resource_urls=public` with the
* 403 WeKnora returns for a knowledge-base-restricted API key.
* @returns the base URL, the recorded requests, and a close function.
*/
export async function startMockWeknora(options = {}) {
const requests = []
// Paths forced to fail, so a test can prove the plugin degrades rather than
// breaks when a deployment does not serve one of the optional routes.
const failures = new Map()
const server = createServer((request, response) => {
const url = new URL(request.url, 'http://mock.invalid')
const chunks = []
request.on('data', chunk => chunks.push(chunk))
request.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8')
let body = {}
if (rawBody !== '') {
try {
body = JSON.parse(rawBody)
} catch {
body = { _unparsed: rawBody }
}
}
requests.push({
method: request.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams),
headers: request.headers,
body,
})
const json = (status, payload) => {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(payload))
}
if (options.apiKey !== undefined && request.headers['x-api-key'] !== options.apiKey) {
json(401, { success: false, error: { message: 'invalid api key' } })
return
}
if (options.forbidPublicResourceUrls === true && url.searchParams.get('resource_urls') === 'public') {
json(403, {
success: false,
error: { message: 'resource_urls=public is not available for a knowledge-base-restricted API key' },
})
return
}
const publicMode = url.searchParams.get('resource_urls') === 'public'
const forced = failures.get(url.pathname)
if (forced !== undefined) {
json(forced, { success: false, error: { message: `forced failure for ${url.pathname}` } })
return
}
if (request.method === 'GET' && url.pathname === '/api/v1/knowledge-bases') {
json(200, { success: true, data: KNOWLEDGE_BASES })
return
}
// By-name document search. Spans every knowledge base and takes no
// scope, matching the real /knowledge/search route.
if (request.method === 'GET' && url.pathname === '/api/v1/knowledge/search') {
const keyword = (url.searchParams.get('keyword') ?? url.searchParams.get('query') ?? '').trim()
if (keyword === '') {
json(400, { success: false, error: { message: 'missing search keyword: pass ?keyword=... or ?query=...' } })
return
}
const limit = Number(url.searchParams.get('limit') ?? '20')
json(200, {
success: true,
data: DOCUMENTS
.filter(document => document.knowledge_title.toLowerCase().includes(keyword.toLowerCase()))
.slice(0, limit)
.map(document => documentRecord(document)),
})
return
}
if (request.method === 'GET' && url.pathname.startsWith('/api/v1/knowledge/')) {
const knowledgeId = decodeURIComponent(url.pathname.slice('/api/v1/knowledge/'.length))
const document = DOCUMENTS.find(entry => entry.knowledge_id === knowledgeId)
if (document === undefined) {
json(404, { success: false, error: { message: 'knowledge not found' } })
return
}
json(200, { success: true, data: documentRecord(document) })
return
}
if (request.method === 'POST' && url.pathname === '/api/v1/knowledge-search') {
if (typeof body.query !== 'string' || body.query === '') {
json(400, { success: false, error: { message: 'Query content cannot be empty' } })
return
}
// Mirror the real handler, which refuses an unscoped retrieval
// (internal/handler/session/qa.go). Answering one here would let the
// plugin ship a call every deployment rejects.
if ((body.knowledge_base_ids ?? []).length === 0
&& (body.knowledge_ids ?? []).length === 0
&& (body.tag_ids ?? []).length === 0) {
json(400, {
success: false,
error: {
message: 'At least one knowledge_base_id, knowledge_base_ids, knowledge_ids, or scoped tag must be provided',
},
})
return
}
json(200, {
success: true,
data: rewriteResources(
searchResults(body.query, body.knowledge_base_ids ?? [], body.knowledge_ids ?? []),
publicMode,
),
})
return
}
if (request.method === 'GET' && url.pathname.startsWith('/api/v1/chunks/')) {
const knowledgeId = decodeURIComponent(url.pathname.slice('/api/v1/chunks/'.length))
const document = DOCUMENTS.find(entry => entry.knowledge_id === knowledgeId)
if (document === undefined) {
json(404, { success: false, error: { message: 'knowledge not found' } })
return
}
const page = Number(url.searchParams.get('page') ?? '1')
const pageSize = Number(url.searchParams.get('page_size') ?? '10')
const start = (page - 1) * pageSize
json(200, {
success: true,
data: document.chunks.slice(start, start + pageSize).map((content, index) => ({
id: `${knowledgeId}-chunk-${start + index}`,
knowledge_id: knowledgeId,
knowledge_base_id: 'kb-product',
content,
chunk_index: start + index,
is_enabled: true,
})),
total: document.chunks.length,
page,
page_size: pageSize,
})
return
}
if (request.method === 'POST' && url.pathname === '/api/v1/sessions') {
json(201, { success: true, data: { id: 'session-mock-1', title: body.title ?? '' } })
return
}
const chatMatch = /^\/api\/v1\/(knowledge-chat|agent-chat)\/(.+)$/.exec(url.pathname)
if (request.method === 'POST' && chatMatch !== null) {
const [, route, sessionId] = chatMatch
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
const send = payload => response.write(`event:message\ndata:${JSON.stringify(payload)}\n\n`)
if (options.streamError === true) {
send({ id: 'e1', response_type: 'error', content: 'model provider unavailable', done: true })
response.end()
return
}
// The RAG route retrieves only what the request scopes: a session holds
// no knowledge base of its own (CreateSessionRequest carries none), so
// WeKnora has no default to fall back on and answers from nothing.
// Retrieving here anyway would hide an unscoped ask from the tests. The
// agent route does have a server-side default, its KBSelectionMode.
const scoped = (body.knowledge_base_ids ?? []).length > 0 || (body.knowledge_ids ?? []).length > 0
const results = rewriteResources(
route === 'agent-chat' || scoped
? searchResults(body.query ?? '', body.knowledge_base_ids ?? [], [])
: [],
publicMode,
)
if (route === 'agent-chat') {
send({
id: 't1',
response_type: 'tool_call',
content: '',
done: false,
session_id: sessionId,
tool_calls: [{ id: 'call-1', function: { name: 'knowledge_search', arguments: '{}' } }],
})
}
send({ id: 'r1', response_type: 'references', content: '', done: false, knowledge_references: results })
const pieces = answerFor(body.query ?? '', results).match(/.{1,24}/gs) ?? []
// A stream cut off mid-answer, which is what a dropped connection or a
// killed backend looks like: answer deltas but no `complete`.
if (options.streamTruncated === true) {
send({ id: 'a1', response_type: 'answer', content: pieces[0] ?? '', done: false, session_id: sessionId })
response.end()
return
}
for (const piece of pieces) {
send({ id: 'a1', response_type: 'answer', content: piece, done: false, session_id: sessionId })
}
send({ id: 'c1', response_type: 'complete', content: '', done: true, session_id: sessionId })
response.end()
return
}
json(404, { success: false, error: { message: `no route for ${request.method} ${url.pathname}` } })
})
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address()
return {
url: `http://127.0.0.1:${port}/api/v1`,
requests,
/** Force `path` to answer `status` from now on. */
fail(path, status) {
failures.set(path, status)
},
async close() {
await new Promise(resolve => server.close(resolve))
},
}
}
export { ARCH_HANDLE, ARCH_PUBLIC, DOCUMENTS, KNOWLEDGE_BASES }
+361
View File
@@ -0,0 +1,361 @@
import assert from 'node:assert/strict'
import { after, test } from 'node:test'
import { WeknoraClient } from '../dist/client.js'
import { resolveConfig } from '../dist/config.js'
import { apply } from '../dist/index.js'
import { createTools } from '../dist/tools.js'
import { assertLosslessJson, assertSupportedSchema, validate } from './helpers/json-schema.mjs'
import { startMockWeknora, ARCH_HANDLE, ARCH_PUBLIC } from './helpers/mock-weknora.mjs'
const never = new AbortController().signal
const exec = { signal: never }
/** Build the tools against a live mock backend. */
async function toolset(overrides = {}) {
const mock = await startMockWeknora()
after(() => mock.close())
const config = resolveConfig({ baseUrl: mock.url, ...overrides })
const tools = createTools(new WeknoraClient(config), config)
return { mock, config, byName: new Map(tools.map(tool => [tool.name, tool])), tools }
}
/** Run a tool the way the registry does: execute, then validate and render. */
async function call(tool, args) {
const value = await tool.execute(args, exec)
assertLosslessJson(value, tool.name)
const violations = validate(tool.output.schema, value)
assert.deepEqual(violations, [], `${tool.name} returned a value its output schema rejects`)
const content = tool.output.render(args, value)
assert.ok(Array.isArray(content) && content.length > 0 && content[0].type === 'text')
return { value, text: content.map(block => block.text).join('\n') }
}
test('every declared schema stays inside the supported subset', async () => {
const { tools } = await toolset()
for (const tool of tools) {
assertSupportedSchema(tool.parameters, `${tool.name}/parameters`)
assertSupportedSchema(tool.output.schema, `${tool.name}/output`)
assert.match(tool.name, /^[a-z][a-z0-9_]*$/)
assert.ok(tool.description.length > 40, `${tool.name} needs a description the model can act on`)
}
})
test('the default composition registers exactly the four documented tools', async () => {
const { tools } = await toolset()
assert.deepEqual(tools.map(tool => tool.name).sort(), [
'weknora_ask',
'weknora_list_knowledge_bases',
'weknora_read_document',
'weknora_search',
])
})
test('list_knowledge_bases renders ids the model can pass back', async () => {
const { byName } = await toolset()
const { value, text } = await call(byName.get('weknora_list_knowledge_bases'), {})
assert.equal(value.count, 2)
assert.match(text, /Product docs \(id: kb-product\)/)
})
test('search returns ranked passages carrying their knowledge_id', async () => {
const { byName } = await toolset({ knowledgeBaseIds: ['kb-product'] })
const { value, text } = await call(byName.get('weknora_search'), { query: '默认的检索阈值是多少' })
assert.ok(value.count > 0)
assert.equal(value.results[0].rank, 1)
assert.ok(value.results[0].knowledge_id.startsWith('doc-'))
assert.match(text, /passage\(s\) for "默认的检索阈值是多少"/)
assert.match(text, /knowledge_id: doc-retrieval-pipeline/)
})
test('search respects max_results and the configured ceiling', async () => {
const { byName } = await toolset({ maxResults: 2, knowledgeBaseIds: ['kb-product'] })
const wide = await call(byName.get('weknora_search'), { query: '检索 部署 向量 阈值' })
assert.ok(wide.value.count <= 2)
const narrow = await call(byName.get('weknora_search'), { query: '检索 部署 向量 阈值', max_results: 1 })
assert.equal(narrow.value.count, 1)
})
test('search falls back to the configured knowledge base scope', async () => {
const { byName, mock } = await toolset({ knowledgeBaseIds: ['kb-ops'] })
await call(byName.get('weknora_search'), { query: '部署 方式' })
assert.deepEqual(mock.requests.at(-1).body.knowledge_base_ids, ['kb-ops'])
})
test('an empty result set tells the model what to try next', async () => {
const { byName } = await toolset({ knowledgeBaseIds: ['kb-product'] })
const { value, text } = await call(byName.get('weknora_search'), { query: '量子纠缠咖啡机保修期' })
assert.equal(value.count, 0)
assert.match(text, /Nothing in WeKnora matched/)
})
test('search rejects a missing query before touching the network', async () => {
const { byName } = await toolset()
await assert.rejects(byName.get('weknora_search').execute({}, exec), /"query" is required/)
})
// WeKnora answers 400 when a retrieval names no knowledge base, document or
// tag. Rather than make the model choose a scope it has no basis to choose,
// an unconfigured deployment searches everything the credential can see.
test('search spans every visible knowledge base when none is configured', async () => {
const { byName, mock } = await toolset()
const { value } = await call(byName.get('weknora_search'), { query: '默认的检索阈值是多少' })
assert.deepEqual(value.knowledge_base_ids, ['kb-product', 'kb-ops'])
const retrieval = mock.requests.find(request => request.path === '/api/v1/knowledge-search')
assert.deepEqual(retrieval.body.knowledge_base_ids, ['kb-product', 'kb-ops'])
assert.match(byName.get('weknora_search').description, /every knowledge base this credential can see/)
})
// A deployment can hold dozens of knowledge bases; spelling every id out on
// every search would cost more context than the passages themselves.
test('a wide scope is named by count rather than spelled out', async () => {
const { byName } = await toolset({ knowledgeBaseIds: ['kb-a', 'kb-b', 'kb-c', 'kb-d'] })
const { value, text } = await call(byName.get('weknora_search'), { query: '默认的检索阈值是多少' })
assert.match(text, /searched: 4 knowledge bases/)
assert.doesNotMatch(text, /kb-a/)
assert.deepEqual(value.knowledge_base_ids, ['kb-a', 'kb-b', 'kb-c', 'kb-d'], 'the ids stay in the canonical value')
})
test('the resolved full scope is fetched once and reused', async () => {
const { byName, mock } = await toolset()
await call(byName.get('weknora_search'), { query: '混合检索 向量' })
await call(byName.get('weknora_search'), { query: '部署 方式' })
const listings = mock.requests.filter(request => request.path === '/api/v1/knowledge-bases')
assert.equal(listings.length, 1, 'resolving the scope must not re-list on every search')
})
test('knowledge_ids alone is a scope WeKnora accepts', async () => {
const { byName, mock } = await toolset()
await call(byName.get('weknora_search'), {
query: '默认的检索阈值是多少',
knowledge_ids: ['doc-retrieval-pipeline'],
})
const retrieval = mock.requests.find(request => request.path === '/api/v1/knowledge-search')
assert.deepEqual(retrieval.body.knowledge_ids, ['doc-retrieval-pipeline'])
assert.equal(retrieval.body.knowledge_base_ids, undefined, 'a document-scoped call must not widen to every base')
})
test('a configured default scope is used verbatim', async () => {
const { byName, mock } = await toolset({ knowledgeBaseIds: ['kb-product'] })
await call(byName.get('weknora_search'), { query: '默认的检索阈值是多少' })
assert.equal(mock.requests.some(request => request.path === '/api/v1/knowledge-bases'), false)
assert.match(byName.get('weknora_search').description, /Searches knowledge base\(s\) kb-product/)
})
// A document nobody quotes is still the document the user asked for by name,
// which is exactly what passage retrieval on its own cannot find.
test('a query naming a document finds it even with no passage match', async () => {
const { byName } = await toolset({ knowledgeBaseIds: ['kb-ops'] })
const { value, text } = await call(byName.get('weknora_search'), { query: '灰度发布检查单' })
assert.equal(value.count, 0)
assert.deepEqual(value.documents.map(document => document.knowledge_id), ['doc-release-checklist'])
assert.match(text, /No passage matched "灰度发布检查单", but its name matches document/)
assert.match(text, /knowledge_id: doc-release-checklist · in Ops runbooks/)
})
test('by-name matches stay inside the scope the call asked for', async () => {
const { byName } = await toolset({ knowledgeBaseIds: ['kb-product'] })
const { value } = await call(byName.get('weknora_search'), { query: '灰度发布检查单' })
assert.deepEqual(value.documents, [], 'a document in kb-ops must not surface in a kb-product search')
})
test('a passage hit is not repeated in the by-name section', async () => {
const { byName } = await toolset({ knowledgeBaseIds: ['kb-product', 'kb-ops'] })
const { value } = await call(byName.get('weknora_search'), { query: 'WeKnora 检索流程' })
const passages = new Set(value.results.map(hit => hit.knowledge_id))
assert.equal(value.documents.some(document => passages.has(document.knowledge_id)), false)
})
test('long passages are clipped and marked truncated', async () => {
const { byName } = await toolset({ maxChunkChars: 20, knowledgeBaseIds: ['kb-product'] })
const { value, text } = await call(byName.get('weknora_search'), { query: '混合检索 向量 关键词' })
assert.ok(value.results[0].truncated)
assert.ok(value.results[0].content.length <= 21)
assert.match(text, /passage truncated/)
})
test('read_document reassembles passages in order and reports paging', async () => {
const { byName } = await toolset({ maxChunkChars: 4000 })
const first = await call(byName.get('weknora_read_document'), {
knowledge_id: 'doc-retrieval-pipeline',
page: 1,
page_size: 2,
})
assert.equal(first.value.total_chunks, 3)
assert.equal(first.value.returned_chunks, 2)
assert.equal(first.value.has_more, true)
assert.match(first.text, /request page 2/)
const second = await call(byName.get('weknora_read_document'), {
knowledge_id: 'doc-retrieval-pipeline',
page: 2,
page_size: 2,
})
assert.equal(second.value.has_more, false)
})
// A long document costs many pages to identify from its passages alone, so
// page 1 carries the title and WeKnora's generated summary.
test('read_document leads with the document title and summary', async () => {
const { byName } = await toolset()
const { value, text } = await call(byName.get('weknora_read_document'), {
knowledge_id: 'doc-retrieval-pipeline',
page: 1,
page_size: 2,
})
assert.equal(value.title, 'WeKnora 检索流程.md')
assert.match(value.summary, /混合检索/)
assert.match(text, /Document WeKnora 检索流程\.md \(doc-retrieval-pipeline\)/)
assert.match(text, /Summary: /)
})
test('read_document still returns text when the metadata call fails', async () => {
const { byName, mock } = await toolset()
mock.fail('/api/v1/knowledge/doc-retrieval-pipeline', 500)
const { value } = await call(byName.get('weknora_read_document'), { knowledge_id: 'doc-retrieval-pipeline' })
assert.equal(value.title, '')
assert.equal(value.summary, '')
assert.ok(value.content.length > 0, 'losing the metadata must not cost the model the document')
})
test('read_document leads with the title and summary, and only on page 1', async () => {
const { byName, mock } = await toolset({ maxChunkChars: 4000 })
const first = await call(byName.get('weknora_read_document'), {
knowledge_id: 'doc-retrieval-pipeline',
page: 1,
page_size: 2,
})
assert.equal(first.value.title, 'WeKnora 检索流程.md')
assert.match(first.text, /^Document WeKnora 检索流程\.md \(doc-retrieval-pipeline\)/)
assert.match(first.text, /Summary: 讲解 WeKnora 混合检索/)
const before = mock.requests.length
const second = await call(byName.get('weknora_read_document'), {
knowledge_id: 'doc-retrieval-pipeline',
page: 2,
page_size: 2,
})
assert.equal(second.value.summary, '')
assert.doesNotMatch(second.text, /Summary:/)
assert.equal(
mock.requests.slice(before).some(request => request.path === '/api/v1/knowledge/doc-retrieval-pipeline'),
false,
'a later page must not re-fetch metadata the model already has',
)
})
test('read_document surfaces a backend 404 as a failed call', async () => {
const { byName } = await toolset()
await assert.rejects(
byName.get('weknora_read_document').execute({ knowledge_id: 'missing-doc' }, exec),
/HTTP 404/,
)
})
test('ask returns the composed answer, citations and a resumable session', async () => {
const { byName, mock } = await toolset()
const { value, text } = await call(byName.get('weknora_ask'), { query: '默认的检索阈值是多少' })
assert.equal(value.pipeline, 'rag')
assert.equal(value.session_id, 'session-mock-1')
assert.ok(value.references.length > 0)
assert.match(text, /Citations:/)
assert.match(text, /pass session_id to ask a follow-up/)
assert.equal(mock.requests.some(request => request.path === '/api/v1/sessions'), true)
})
test('ask reuses a given session instead of creating one', async () => {
const { byName, mock } = await toolset()
await call(byName.get('weknora_ask'), { query: '部署方式', session_id: 'session-existing' })
assert.equal(mock.requests.some(request => request.path === '/api/v1/sessions'), false)
assert.equal(mock.requests.at(-1).path, '/api/v1/knowledge-chat/session-existing')
})
// The RAG pipeline retrieves only what the request scopes, so an unconfigured
// deployment must resolve the visible set for ask exactly as it does for search.
// Otherwise the quickstart's optional WEKNORA_KNOWLEDGE_BASE_IDS leaves ask
// answering from nothing while search works.
test('ask resolves its own scope when none is configured', async () => {
const { byName, mock } = await toolset()
const { value, text } = await call(byName.get('weknora_ask'), { query: '默认的检索阈值是多少' })
const chat = mock.requests.find(request => request.path.startsWith('/api/v1/knowledge-chat/'))
assert.deepEqual(chat.body.knowledge_base_ids, ['kb-product', 'kb-ops'])
assert.ok(value.references.length > 0, 'an unconfigured ask must still retrieve')
assert.doesNotMatch(text, /没有检索到/)
})
test('ask reuses the scope search already resolved', async () => {
const { byName, mock } = await toolset()
await call(byName.get('weknora_search'), { query: '混合检索 向量' })
await call(byName.get('weknora_ask'), { query: '默认的检索阈值是多少' })
const listings = mock.requests.filter(request => request.path === '/api/v1/knowledge-bases')
assert.equal(listings.length, 1, 'search and ask must share the resolved scope')
})
test('a configured scope reaches ask without a listing call', async () => {
const { byName, mock } = await toolset({ knowledgeBaseIds: ['kb-product'] })
await call(byName.get('weknora_ask'), { query: '默认的检索阈值是多少' })
assert.equal(mock.requests.some(request => request.path === '/api/v1/knowledge-bases'), false)
const chat = mock.requests.find(request => request.path.startsWith('/api/v1/knowledge-chat/'))
assert.deepEqual(chat.body.knowledge_base_ids, ['kb-product'])
})
// A custom agent resolves its scope server-side from its KBSelectionMode, and
// ids sent from here would override that as an explicit @mention.
test('an agent-pipeline ask leaves the scope to the server', async () => {
const { byName, mock } = await toolset({ agentId: 'agent-42' })
await call(byName.get('weknora_ask'), { query: '部署方式有哪些' })
assert.equal(mock.requests.some(request => request.path === '/api/v1/knowledge-bases'), false)
const chat = mock.requests.find(request => request.path.startsWith('/api/v1/agent-chat/'))
assert.equal(chat.body.knowledge_base_ids, undefined)
})
test('a configured agent id switches ask to the agent pipeline', async () => {
const { byName } = await toolset({ agentId: 'agent-42' })
const { value, text } = await call(byName.get('weknora_ask'), { query: '部署方式有哪些' })
assert.equal(value.pipeline, 'agent')
assert.deepEqual(value.tool_calls, ['knowledge_search'])
assert.match(text, /WeKnora tools used: knowledge_search/)
})
test('apply registers into ctx.tools and honours the prefix and toggles', () => {
const registered = []
const disposers = []
const ctx = {
tools: {
register(definition) {
registered.push(definition.name)
const disposer = () => disposers.push(definition.name)
return disposer
},
},
}
apply(ctx, { baseUrl: 'https://kb.example.com', toolPrefix: 'kb', tools: { ask: false, readDocument: false } })
assert.deepEqual(registered.sort(), ['kb_list_knowledge_bases', 'kb_search'])
})
test('apply fails the plugin load on an invalid row', () => {
const ctx = { tools: { register: () => () => undefined } }
assert.throws(() => apply(ctx, { baseUrl: 'ftp://kb.example.com' }), /dsh-weknora configuration is invalid/)
})
test('search rewrites cited figures to public URLs in passage content', async () => {
const { byName } = await toolset({ knowledgeBaseIds: ['kb-product'] })
const { value, text } = await call(byName.get('weknora_search'), { query: '系统架构图' })
const hit = value.results.find(result => result.knowledge_id === 'doc-architecture')
assert.ok(hit, 'the architecture document must be in the ranked hits')
assert.ok(hit.content.includes(`![系统架构](${ARCH_PUBLIC})`))
assert.ok(text.includes(`![系统架构](${ARCH_PUBLIC})`))
})
test('ask copies public figure URLs from the retrieved passages', async () => {
const { byName } = await toolset({ knowledgeBaseIds: ['kb-product'] })
const { value, text } = await call(byName.get('weknora_ask'), { query: '系统架构图长什么样' })
assert.ok(value.answer.includes(`![系统架构](${ARCH_PUBLIC})`))
assert.ok(text.includes(`![系统架构](${ARCH_PUBLIC})`))
})
test('handle mode keeps internal resource handles instead of public URLs', async () => {
const { byName } = await toolset({ knowledgeBaseIds: ['kb-product'], resourceUrls: 'handle' })
const { value } = await call(byName.get('weknora_search'), { query: '系统架构图' })
const hit = value.results.find(result => result.knowledge_id === 'doc-architecture')
assert.ok(hit.content.includes(`![系统架构](${ARCH_HANDLE})`))
})