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
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:
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
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 }
|
||||
@@ -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(``))
|
||||
assert.ok(text.includes(``))
|
||||
})
|
||||
|
||||
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(``))
|
||||
assert.ok(text.includes(``))
|
||||
})
|
||||
|
||||
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(``))
|
||||
})
|
||||
Reference in New Issue
Block a user