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
+2
View File
@@ -0,0 +1,2 @@
dist/
node_modules/
+177
View File
@@ -0,0 +1,177 @@
# @wxg-prc-cpg/dsh-weknora
English | [简体中文](./README_CN.md)
[npm](https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora)
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) plugin that gives the harness agent
first-class access to a [WeKnora](https://github.com/Tencent/WeKnora) knowledge base: hybrid retrieval, full document
reading, and WeKnora's own composed answers with citations.
dsh ships no retrieval, embedding or knowledge-base capability of its own — its search tools (`grep`, `glob`) read the
workspace, and `web_search` reads the internet. This plugin fills that gap with your organization's documents.
## Install
```sh
# from npm (recommended: installs prebuilt code, no build permission needed)
dsh plugin --profile web add @wxg-prc-cpg/dsh-weknora
# or from a checkout of this repository
dsh plugin --profile web add ./packages/dsh-weknora
```
Then point it at your deployment. The shipped configuration layer reads environment variables, so the quickest start is:
```sh
export WEKNORA_BASE_URL=https://weknora.example.com # or http://localhost:8080
export WEKNORA_API_KEY=sk-... # a WeKnora API key with `retrieve` (+ `chat` for ask)
export WEKNORA_KNOWLEDGE_BASE_IDS=kb-123,kb-456 # optional default scope
export WEKNORA_RESOURCE_URLS=public # default; `handle` keeps internal file refs
dsh web
```
For anything beyond that, override the row from your profile's `cordis.patch.yml`
(`$DSH_HOME/profiles/<name>/cordis.patch.yml`):
```yaml
- id: weknora
config:
baseUrl: https://weknora.example.com
apiKey: !!js process.env.WEKNORA_API_KEY
knowledgeBaseIds:
- kb-product-docs
agentId: '' # a WeKnora custom agent id makes `weknora_ask` use the ReAct pipeline
maxResults: 8
maxChunkChars: 1200
requestTimeoutMs: 30000
chatTimeoutMs: 300000
resourceUrls: handle # default is `public` so cited images are directly loadable; `handle` keeps internal refs
toolPrefix: weknora # rename the tools, e.g. to mount two deployments side by side
tools:
listKnowledgeBases: true
search: true
readDocument: true
ask: true
```
A patch replaces the row's whole `config`, so restate every field you keep. Invalid configuration fails the plugin load
with a message naming each violation, rather than failing inside the first tool call.
Mounting two deployments is two rows with two prefixes:
```yaml
- insert:
- id: weknora-internal
name: "@wxg-prc-cpg/dsh-weknora"
config: { baseUrl: https://kb.internal.example.com, toolPrefix: internal_kb }
- id: weknora-public
name: "@wxg-prc-cpg/dsh-weknora"
config: { baseUrl: https://kb.public.example.com, toolPrefix: public_kb }
```
## Tools
| Tool | WeKnora endpoint | What the model gets |
|---|---|---|
| `weknora_list_knowledge_bases` | `GET /knowledge-bases` | Knowledge base names and ids, to report what exists or to narrow a later search |
| `weknora_search` | `POST /knowledge-search` + `GET /knowledge/search` | Ranked passages verbatim, each with a `knowledge_id`, score and chunk index, plus any document the query names |
| `weknora_read_document` | `GET /chunks/:knowledge_id` + `GET /knowledge/:id` | One document's passages reassembled in order, led by its title and summary, with paging |
| `weknora_ask` | `POST /sessions` + `POST /knowledge-chat/:id` or `POST /agent-chat/:id` | WeKnora's own answer, its citations, the server-side tools it used, and a resumable `session_id` |
`weknora_search` is the workhorse: it returns the source text for the agent to reason over, which keeps the agent's own
reasoning auditable. It answers two questions the model cannot always tell apart — *where is this discussed* and *where
is the document called X* — by matching passage content and document names in the same call. A query that reads like a
title additionally reports the documents it names, so a document whose wording differs from its own title is still
reachable.
Scope works without configuration. An unscoped call is refused by the retrieval endpoint and answered from nothing by
the RAG one, so when `knowledgeBaseIds` is empty the plugin resolves every knowledge base the credential can see and
uses all of them, resolving that list once per process and sharing it between `weknora_search` and `weknora_ask`.
Making the model choose first would be worse: knowledge bases are frequently named too poorly to choose between.
`weknora_ask` delegates the whole question to WeKnora. Reserve it for broad or synthesis questions spanning many
documents, where retrieving passages yourself would take several rounds — it runs another model server-side, so it is
slow, and it returns a conclusion rather than the evidence behind it. With `agentId` set it leaves the scope alone: a
custom agent resolves its own from its KB selection mode, and ids sent from here would override that.
`weknora_read_document` exists because retrieval returns fragments: once a passage looks right, the agent usually needs
its neighbours. Every search hit carries the `knowledge_id` that call needs, and page 1 leads with the document's title
and WeKnora's generated summary so a long document can be judged without paging through it.
In [Code Mode](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/tools/README.md) the same tools
are available as `await tools.weknora_search({ query })`, which is a good fit for multi-hop retrieval: one program can
fan out across ten sub-questions without ten model round trips.
## Permissions and data flow
The plugin only reads. It never writes to WeKnora: no ingestion, no chunk edits, no deletion. A WeKnora API key scoped
to `retrieve` is enough for three of the four tools; `weknora_ask` additionally needs `chat`, because it creates a
session and streams an answer.
The key is sent as `X-API-Key`. Set `tenantId` as well if you use a platform-scoped key, which needs `X-Tenant-ID` to
select a workspace. Errors are reported with the HTTP status and WeKnora's own reason, never with the key.
Passage content is clipped to `maxChunkChars` before it reaches the model, so one oversized document cannot eat the
context window. The clip is reported to the model (`truncated: true`) instead of silently hiding text.
With the default `resourceUrls: public`, WeKnora rewrites cited `resource://` figures into directly loadable `http(s)`
URLs. Copy those Markdown images into the assistant reply and the dsh web UI will render the picture. A
knowledge-base-restricted API key cannot receive public URLs (WeKnora answers 403); the plugin retries that call in
handle mode and keeps using handles afterwards.
The shipped configuration layer also reads `WEKNORA_RESOURCE_URLS` (`public` or `handle`).
## Configuration reference
| Field | Default | Notes |
|---|---|---|
| `baseUrl` | `http://localhost:8080/api/v1` | `/api/v1` is appended when missing |
| `apiKey` | unset | `X-API-Key`; unset means an unauthenticated deployment |
| `tenantId` | unset | `X-Tenant-ID`, required for platform-scoped keys |
| `knowledgeBaseIds` | `[]` | Default scope when a call names none; empty means every knowledge base the credential can see |
| `agentId` | unset | Sends `weknora_ask` to the ReAct pipeline |
| `maxResults` | `8` | Also the ceiling for `max_results` and for cited references |
| `maxChunkChars` | `1200` | Per-passage character budget |
| `requestTimeoutMs` | `30000` | Retrieval and document reads |
| `chatTimeoutMs` | `300000` | `weknora_ask`, which waits on WeKnora's own model calls |
| `resourceUrls` | `public` | `public` returns directly loadable file URLs; `handle` keeps internal `resource://` refs. A knowledge-base-restricted API key is retried as `handle`. Override with `WEKNORA_RESOURCE_URLS`. |
| `toolPrefix` | `weknora` | Tool-name prefix, `^[a-z][a-z0-9_]*$` |
| `tools.*` | all `true` | Register fewer tools to spend fewer prompt tokens |
## Development
```sh
npm install
npm test # builds, then runs the unit and contract tests
npm run typecheck
```
The end-to-end check installs the package into a throwaway dsh profile, boots the headless profile against a mock
WeKnora backend and a deterministic OpenAI-compatible model, and asserts that the harness really called the tools and
answered from what they returned:
```sh
npm run build
node test/e2e/run-in-dsh.mjs # installs dsh from npm on first run
DSH_BIN=/path/to/dsh node test/e2e/run-in-dsh.mjs # or reuse an existing install
```
dsh needs a Node build with zstd support (Node ≥ 22.15 or ≥ 24) for its session persistence, and pnpm ≥ 10 on `PATH`,
because `dsh plugin add` installs into the profile through pnpm and the profile it writes keeps its settings in
`pnpm-workspace.yaml`. The plugin itself runs on Node ≥ 20.11 and needs neither.
`test/fixtures/api-contract.json` records every WeKnora call this plugin makes. `test/contract.test.mjs` asserts the
plugin still emits exactly those calls, and `contract/contract_test.go` asserts WeKnora's real Go request and response
types still accept and serve them — so a rename on either side fails CI instead of a user's agent.
## Compatibility
Verified against dsh `0.1.0-rc.8` and WeKnora `0.8.0`. dsh is in developer preview and states that breaking changes
will happen; this package deliberately has **no runtime dependencies** and hands `ctx.tools.register()` a plain object,
so it does not pin any harness package version. If a harness release changes the tool-definition contract, open an issue
against this repository.
## License
MIT, same as WeKnora and dsh.
+164
View File
@@ -0,0 +1,164 @@
# @wxg-prc-cpg/dsh-weknora
[English](./README.md) | 简体中文
[npm](https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora)
一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)插件,让 harness 里的 Agent 直接用上
[WeKnora](https://github.com/Tencent/WeKnora) 知识库:混合检索、按文档通读、以及 WeKnora 自己带引用的成稿答案。
dsh 本身没有任何检索、向量或知识库能力——它的 `grep`、`glob` 只看工作区文件,`web_search` 只看互联网。这个插件把缺的这一
块换成你自己的文档。
## 安装
```sh
# 从 npm 安装(推荐:装到的是构建产物,用户无需授权执行构建脚本)
dsh plugin --profile web add @wxg-prc-cpg/dsh-weknora
# 或者从本仓库的检出目录安装
dsh plugin --profile web add ./packages/dsh-weknora
```
然后把它指向你的部署。插件自带的配置层会读环境变量,所以最快的起步方式是:
```sh
export WEKNORA_BASE_URL=https://weknora.example.com # 或 http://localhost:8080
export WEKNORA_API_KEY=sk-... # 需要 `retrieve` 能力(用 ask 还需要 `chat`)
export WEKNORA_KNOWLEDGE_BASE_IDS=kb-123,kb-456 # 可选的默认知识库范围
export WEKNORA_RESOURCE_URLS=public # 默认;改成 `handle` 则保留内部文件引用
dsh web
```
超出这个范围的配置,在 profile 自己的 `cordis.patch.yml`(`$DSH_HOME/profiles/<name>/cordis.patch.yml`)里覆盖这一行:
```yaml
- id: weknora
config:
baseUrl: https://weknora.example.com
apiKey: !!js process.env.WEKNORA_API_KEY
knowledgeBaseIds:
- kb-product-docs
agentId: '' # 填 WeKnora 自定义 Agent id,`weknora_ask` 就走 ReAct 流水线
maxResults: 8
maxChunkChars: 1200
requestTimeoutMs: 30000
chatTimeoutMs: 300000
resourceUrls: handle # 默认是 `public`,引用里的图片给可直接加载的直链;`handle` 保留内部引用
toolPrefix: weknora # 重命名工具,例如同时挂两个部署时
tools:
listKnowledgeBases: true
search: true
readDocument: true
ask: true
```
patch 是整块替换该行的 `config`,所以要保留的字段需要一并写出。配置不合法会在插件加载阶段失败,并逐条列出问题,而不是等到
模型第一次调用工具时才报错。
同时挂两个部署,就是两行加两个前缀:
```yaml
- insert:
- id: weknora-internal
name: "@wxg-prc-cpg/dsh-weknora"
config: { baseUrl: https://kb.internal.example.com, toolPrefix: internal_kb }
- id: weknora-public
name: "@wxg-prc-cpg/dsh-weknora"
config: { baseUrl: https://kb.public.example.com, toolPrefix: public_kb }
```
## 工具
| 工具 | WeKnora 接口 | 模型拿到什么 |
|---|---|---|
| `weknora_list_knowledge_bases` | `GET /knowledge-bases` | 知识库名称与 id,用于说明有哪些库,或为后续检索缩小范围 |
| `weknora_search` | `POST /knowledge-search` + `GET /knowledge/search` | 原文片段与排序,每条带 `knowledge_id`、得分、分块序号,外加查询点名的文档 |
| `weknora_read_document` | `GET /chunks/:knowledge_id` + `GET /knowledge/:id` | 单个文档按序拼回的正文,开头给出标题与摘要,支持翻页 |
| `weknora_ask` | `POST /sessions` + `POST /knowledge-chat/:id` 或 `POST /agent-chat/:id` | WeKnora 自己的答案、引用、服务端用过的工具,以及可续聊的 `session_id` |
`weknora_search` 是主力:它把原文交给 Agent 自己推理,Agent 的结论因此是可审计的。它在同一次调用里同时匹配片段内容和
文档名,因为模型常常分不清自己要找的是「哪里讲了这件事」还是「那份叫 X 的文档在哪」。当查询读起来像个标题时,结果里会额外
列出被点名的文档——这样即便某份文档的正文用词与标题完全不同,也依然够得着。
范围不需要配置。没有指定知识库的调用,检索接口会直接拒绝,RAG 接口则会在什么都没检索到的情况下作答,因此当
`knowledgeBaseIds` 为空时,插件会自己解析出该凭据可见的全部知识库并一并使用;这份清单每个进程只解析一次,且由
`weknora_search` 与 `weknora_ask` 共用。让模型先去挑反而更糟:知识库的命名往往糟糕到无从选择。
`weknora_ask` 把整个问题委派给 WeKnora。它适合跨多篇文档、需要综合的宽泛问题,也就是自己检索要来回好几轮的场景;它在服务端
再跑一个模型,所以慢,而且返回的是结论而非支撑结论的证据。配了 `agentId` 时它不会自己填范围:自定义 Agent 会按自己的知识库
选择模式在服务端解析范围,从这里传 id 反而会把它覆盖掉。
`weknora_read_document` 的存在是因为检索返回的是碎片:一旦某个片段看起来对,Agent 通常还需要它的上下文。每条检索结果都带
着这次调用需要的 `knowledge_id`;第一页会先给出文档标题和 WeKnora 生成的摘要,长文档不必翻完才知道自己拿到的是什么。
在 [Code Mode](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/tools/README.md) 下,同样的工具
可以写成 `await tools.weknora_search({ query })`,特别适合多跳检索:一段程序里扇出十个子问题,而不是十次模型往返。
## 权限与数据流
插件只读。它不会写 WeKnora:不入库、不改分块、不删除。四个工具里有三个只需要 `retrieve` 能力的 API Key;`weknora_ask`
额外需要 `chat`,因为它要创建会话并流式取回答案。
Key 通过 `X-API-Key` 发送。如果用的是平台级 API Key,还需要配 `tenantId`,它会作为 `X-Tenant-ID` 选择工作空间。报错信息
只包含 HTTP 状态码和 WeKnora 自己给出的原因,不会带上 Key。
片段内容在进入模型前会按 `maxChunkChars` 截断,避免一份超大文档吃掉整个上下文窗口;截断这件事会明确告知模型
(`truncated: true`),而不是悄悄丢文本。
默认 `resourceUrls: public` 时,WeKnora 会把片段里的 `resource://` 图改写成可直接加载的 http(s) 直链——把这些 Markdown
图片写进回复,dsh 网页端就会把图画出来。限定知识库的 API Key 拿不到直链(WeKnora 会 403),插件会把这次调用改回
handle 模式,之后也一直用 handle。
配置层还会读 `WEKNORA_RESOURCE_URLS`(`public` 或 `handle`)。
## 配置项
| 字段 | 默认值 | 说明 |
|---|---|---|
| `baseUrl` | `http://localhost:8080/api/v1` | 缺少 `/api/v1` 时自动补全 |
| `apiKey` | 未设置 | `X-API-Key`;不设表示部署无鉴权 |
| `tenantId` | 未设置 | `X-Tenant-ID`,平台级 Key 必填 |
| `knowledgeBaseIds` | `[]` | 调用未指定范围时的默认值;留空表示检索该凭据可见的全部知识库 |
| `agentId` | 未设置 | 让 `weknora_ask` 走 ReAct 流水线 |
| `maxResults` | `8` | 同时是 `max_results` 与引用条数的上限 |
| `maxChunkChars` | `1200` | 单个片段的字符预算 |
| `requestTimeoutMs` | `30000` | 检索与读文档 |
| `chatTimeoutMs` | `300000` | `weknora_ask`,要等 WeKnora 自己的模型调用 |
| `resourceUrls` | `public` | `public` 返回可直接加载的文件直链;`handle` 保留内部 `resource://` 引用。限定知识库的 API Key 会自动回退到 `handle`。可用 `WEKNORA_RESOURCE_URLS` 覆盖。 |
| `toolPrefix` | `weknora` | 工具名前缀,需匹配 `^[a-z][a-z0-9_]*$` |
| `tools.*` | 全部 `true` | 少注册几个工具就少占几分 prompt |
## 开发
```sh
npm install
npm test # 先构建,再跑单元测试与契约测试
npm run typecheck
```
端到端检查会把这个包装进一个临时 dsh profile,用 mock 的 WeKnora 后端和一个确定性的 OpenAI 兼容模型启动 headless
profile,并断言 harness 真的调用了工具、并且答案来自工具返回的内容:
```sh
npm run build
node test/e2e/run-in-dsh.mjs # 首次运行会从 npm 装一个 dsh
DSH_BIN=/path/to/dsh node test/e2e/run-in-dsh.mjs # 或者复用已有安装
```
dsh 的会话持久化需要带 zstd 的 Node(≥ 22.15 或 ≥ 24),并且 `PATH` 上要有 pnpm ≥ 10——`dsh plugin add` 通过 pnpm 装进
profile,而它写出的 profile 把设置放在 `pnpm-workspace.yaml` 里。插件自身只要求 Node ≥ 20.11,两者都不需要。
`test/fixtures/api-contract.json` 记录了本插件发出的每一个 WeKnora 调用。`test/contract.test.mjs` 断言插件仍然只发这些
调用,`contract/contract_test.go` 断言 WeKnora 真实的 Go 请求/响应类型仍然接受并提供这些字段——任何一侧改名都会在 CI 挂掉,
而不是在用户的 Agent 里挂掉。
## 兼容性
已针对 dsh `0.1.0-rc.8` 与 WeKnora `0.8.0` 验证。dsh 处于 developer preview 并明确会有破坏性变更;本包因此**没有任何运行
时依赖**,交给 `ctx.tools.register()` 的只是一个普通对象,不锁定任何 harness 包版本。如果 harness 的工具定义契约发生变化,
请在本仓库提 issue。
## 许可
MIT,与 WeKnora 和 dsh 一致。
@@ -0,0 +1,245 @@
// Package contract asserts that the WeKnora API surface the dsh-weknora plugin
// speaks still exists on this server: the request bodies it sends must decode
// into the real handler request structs with no unknown fields, and the response
// fields it reads must still be produced by the real response types.
//
// The fixture is shared with the plugin's own JavaScript tests
// (packages/dsh-weknora/test/contract.test.mjs), which assert that the plugin
// still emits exactly these calls. Together the two sides catch a rename on
// either side at CI time instead of inside a user's agent.
package contract
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/Tencent/WeKnora/internal/handler/session"
"github.com/Tencent/WeKnora/internal/types"
)
type contractCall struct {
Tool string `json:"tool"`
Method string `json:"method"`
Path string `json:"path"`
Query map[string]string `json:"query"`
Body json.RawMessage `json:"body"`
GoRequestType *string `json:"goRequestType"`
}
type contractFixture struct {
Calls []contractCall `json:"calls"`
ResponseFieldsRead map[string][]string `json:"responseFieldsRead"`
StreamResponseTypes []string `json:"streamResponseTypes"`
}
func loadFixture(t *testing.T) contractFixture {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "test", "fixtures", "api-contract.json"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var fixture contractFixture
if err := json.Unmarshal(raw, &fixture); err != nil {
t.Fatalf("parse fixture: %v", err)
}
if len(fixture.Calls) == 0 {
t.Fatal("fixture declares no calls")
}
return fixture
}
// newRequestTarget returns an empty instance of the handler request struct the
// named call decodes into.
func newRequestTarget(t *testing.T, name string) any {
t.Helper()
switch name {
case "session.SearchKnowledgeRequest":
return &session.SearchKnowledgeRequest{}
case "session.CreateSessionRequest":
return &session.CreateSessionRequest{}
case "session.CreateKnowledgeQARequest":
return &session.CreateKnowledgeQARequest{}
case "types.Pagination":
return &types.Pagination{}
default:
t.Fatalf("fixture names an unknown Go request type %q", name)
return nil
}
}
// TestPluginRequestBodiesDecodeIntoHandlerTypes: every body the plugin sends is
// accepted by the struct its handler binds, with no field the server would drop.
func TestPluginRequestBodiesDecodeIntoHandlerTypes(t *testing.T) {
fixture := loadFixture(t)
decoded := 0
for _, call := range fixture.Calls {
if call.GoRequestType == nil || len(call.Body) == 0 || string(call.Body) == "null" {
continue
}
target := newRequestTarget(t, *call.GoRequestType)
decoder := json.NewDecoder(bytes.NewReader(call.Body))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
t.Errorf("%s %s (%s) body rejected by %s: %v",
call.Method, call.Path, call.Tool, *call.GoRequestType, err)
continue
}
decoded++
}
if decoded == 0 {
t.Fatal("no request body was checked; the fixture lost its bodies")
}
}
// TestRetrievalRequestCarriesRequiredFields: the search body binds the fields
// the handler validates, so the plugin cannot send a request the handler rejects
// with 400 at runtime.
func TestRetrievalRequestCarriesRequiredFields(t *testing.T) {
fixture := loadFixture(t)
for _, call := range fixture.Calls {
if call.Path != "/api/v1/knowledge-search" {
continue
}
var request session.SearchKnowledgeRequest
if err := json.Unmarshal(call.Body, &request); err != nil {
t.Fatalf("decode search body: %v", err)
}
if request.Query == "" {
t.Error("the plugin must always send a non-empty query; the handler rejects an empty one")
}
if len(request.KnowledgeBaseIDs) == 0 && len(request.KnowledgeIDs) == 0 {
t.Error("the plugin must scope retrieval; the handler rejects a request with no target")
}
return
}
t.Fatal("the fixture no longer covers /knowledge-search")
}
// TestChatRequestSelectsThePipeline: the agent route must carry both the agent id
// and the agent-enabled flag, since the RAG and ReAct pipelines are selected by
// the same struct.
func TestChatRequestSelectsThePipeline(t *testing.T) {
fixture := loadFixture(t)
var sawRAG, sawAgent bool
for _, call := range fixture.Calls {
if call.GoRequestType == nil || *call.GoRequestType != "session.CreateKnowledgeQARequest" {
continue
}
var request session.CreateKnowledgeQARequest
if err := json.Unmarshal(call.Body, &request); err != nil {
t.Fatalf("decode chat body: %v", err)
}
if request.Query == "" {
t.Errorf("%s must send a query", call.Path)
}
if request.Channel != "api" {
t.Errorf("%s must declare channel \"api\", got %q", call.Path, request.Channel)
}
switch {
case request.AgentID != "":
sawAgent = true
if !request.AgentEnabled {
t.Errorf("%s names an agent but leaves agent_enabled false", call.Path)
}
default:
sawRAG = true
}
}
if !sawRAG || !sawAgent {
t.Fatal("the fixture must cover both the RAG and the agent chat route")
}
}
// TestResponseFieldsThePluginReadsStillExist: marshal the real response types and
// confirm every JSON key the plugin depends on is present, so a renamed or
// dropped json tag fails here.
func TestResponseFieldsThePluginReadsStillExist(t *testing.T) {
fixture := loadFixture(t)
samples := map[string]any{
"types.SearchResult": types.SearchResult{
ID: "chunk-1",
Content: "默认的 vector_threshold 是 0.5",
KnowledgeID: "doc-1",
ChunkIndex: 3,
KnowledgeTitle: "检索流程.md",
KnowledgeFilename: "检索流程.md",
Score: 0.83,
},
"types.Knowledge": types.Knowledge{
ID: "doc-1",
Title: "检索流程.md",
FileName: "检索流程.md",
Description: "讲解 WeKnora 混合检索的召回与阈值。",
KnowledgeBaseID: "kb-product",
KnowledgeBaseName: "Product docs",
},
"types.Chunk": types.Chunk{
ID: "chunk-1",
Content: "默认的 vector_threshold 是 0.5",
ChunkIndex: 3,
KnowledgeID: "doc-1",
},
"types.StreamResponse": types.StreamResponse{
ID: "event-1",
ResponseType: types.ResponseTypeAnswer,
Content: "答案",
SessionID: "session-1",
KnowledgeReferences: types.References{{ID: "chunk-1"}},
ToolCalls: []types.LLMToolCall{{ID: "call-1", Type: "function"}},
},
"types.LLMToolCall": types.LLMToolCall{ID: "call-1", Type: "function"},
"types.FunctionCall": types.FunctionCall{
Name: "knowledge_search",
Arguments: "{}",
},
}
for typeName, fields := range fixture.ResponseFieldsRead {
sample, ok := samples[typeName]
if !ok {
t.Errorf("fixture names %s but this test has no sample for it", typeName)
continue
}
raw, err := json.Marshal(sample)
if err != nil {
t.Errorf("marshal %s: %v", typeName, err)
continue
}
var asMap map[string]json.RawMessage
if err := json.Unmarshal(raw, &asMap); err != nil {
t.Errorf("re-decode %s: %v", typeName, err)
continue
}
for _, field := range fields {
if _, ok := asMap[field]; !ok {
t.Errorf("%s no longer serializes %q, which dsh-weknora reads", typeName, field)
}
}
}
}
// TestStreamResponseTypesThePluginHandlesStillExist: the SSE event names the
// plugin switches on must still be the server's constants.
func TestStreamResponseTypesThePluginHandlesStillExist(t *testing.T) {
fixture := loadFixture(t)
known := map[string]types.ResponseType{
"answer": types.ResponseTypeAnswer,
"references": types.ResponseTypeReferences,
"tool_call": types.ResponseTypeToolCall,
"error": types.ResponseTypeError,
"complete": types.ResponseTypeComplete,
}
for _, name := range fixture.StreamResponseTypes {
constant, ok := known[name]
if !ok {
t.Errorf("fixture handles stream event %q, which this test does not map to a server constant", name)
continue
}
if string(constant) != name {
t.Errorf("stream event %q is now spelled %q on the server", name, string(constant))
}
}
}
+14
View File
@@ -0,0 +1,14 @@
# The configuration layer this bundle contributes when a profile lists it.
# Override the row from the profile's own cordis.patch.yml to point the plugin
# at your deployment; every field is documented in README.md.
- insert:
- id: weknora
# Must be the published package name — the loader imports this specifier.
# Quoted because YAML reserves a leading '@' in plain scalars.
name: "@wxg-prc-cpg/dsh-weknora"
config:
baseUrl: !!js process.env.WEKNORA_BASE_URL ?? 'http://localhost:8080/api/v1'
apiKey: !!js process.env.WEKNORA_API_KEY
knowledgeBaseIds: !!js (process.env.WEKNORA_KNOWLEDGE_BASE_IDS ?? '').split(',').filter(Boolean)
agentId: !!js process.env.WEKNORA_AGENT_ID
resourceUrls: !!js process.env.WEKNORA_RESOURCE_URLS || 'public'
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@wxg-prc-cpg/dsh-weknora",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@wxg-prc-cpg/dsh-weknora",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.13.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=20.11"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@wxg-prc-cpg/dsh-weknora",
"version": "0.1.0",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"description": "WeKnora knowledge retrieval tools for DeepSeek Harness (dsh): semantic search, document reading and RAG/agent answers over your own knowledge bases.",
"keywords": [
"dsh",
"dsh-plugin",
"deepseek-harness",
"cordis",
"weknora",
"rag",
"retrieval",
"knowledge-base"
],
"license": "MIT",
"author": "WeKnora",
"homepage": "https://github.com/Tencent/WeKnora/tree/main/packages/dsh-weknora",
"repository": {
"type": "git",
"url": "git+https://github.com/Tencent/WeKnora.git",
"directory": "packages/dsh-weknora"
},
"bugs": {
"url": "https://github.com/Tencent/WeKnora/issues"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"cordis.patch.yml",
"README.md",
"README_CN.md"
],
"engines": {
"node": ">=20.11"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"prepare": "npm run build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "npm run build && node --test \"test/*.test.mjs\"",
"e2e": "node test/e2e/run-in-dsh.mjs"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"devDependencies": {
"@types/node": "^22.13.0",
"typescript": "^5.7.3"
}
}
+433
View File
@@ -0,0 +1,433 @@
/** Minimal WeKnora REST client: JSON endpoints plus the SSE chat streams. */
import type { ResolvedConfig } from './config.ts'
/** One retrieval hit, as returned by WeKnora's `SearchResult`. */
export interface SearchResult {
id?: string
content?: string
knowledge_id?: string
chunk_index?: number
knowledge_title?: string
knowledge_filename?: string
score?: number
match_type?: number | string
[key: string]: unknown
}
/** A knowledge base as listed by `GET /knowledge-bases`. */
export interface KnowledgeBaseSummary {
id?: string
name?: string
description?: string
[key: string]: unknown
}
/** One stored chunk as returned by `GET /chunks/:knowledge_id`. */
export interface ChunkRecord {
id?: string
content?: string
chunk_index?: number
knowledge_id?: string
[key: string]: unknown
}
/** A document as returned by the by-name search and by `GET /knowledge/:id`. */
export interface DocumentRecord {
id?: string
title?: string
file_name?: string
file_type?: string
description?: string
knowledge_base_id?: string
knowledge_base_name?: string
[key: string]: unknown
}
/** A page of chunks plus its pagination envelope. */
export interface ChunkPage {
chunks: ChunkRecord[]
total: number
page: number
pageSize: number
}
/** The assembled outcome of one streamed answer. */
export interface StreamedAnswer {
answer: string
references: SearchResult[]
sessionId: string
toolCalls: string[]
}
/** Raised for any non-2xx response or transport failure, with the key redacted. */
export class WeknoraApiError extends Error {
readonly status: number | undefined
constructor(message: string, status?: number) {
super(message)
this.name = 'WeknoraApiError'
this.status = status
}
}
interface Envelope<T> {
success?: boolean
data?: T
error?: unknown
message?: string
total?: number
page?: number
page_size?: number
}
/** Trim a response body for an error message without leaking a whole page. */
function snippet(body: string): string {
const collapsed = body.replace(/\s+/g, ' ').trim()
return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed
}
/** Extract a human-readable reason from WeKnora's error envelope. */
function reasonOf(body: string): string {
try {
const parsed = JSON.parse(body) as { error?: { message?: string } | string, message?: string }
if (typeof parsed.error === 'string') return parsed.error
if (parsed.error?.message !== undefined) return parsed.error.message
if (parsed.message !== undefined) return parsed.message
} catch {
// Fall through to the raw snippet: a proxy may answer with HTML.
}
return snippet(body)
}
/**
* Combine the caller's cancellation with this call's deadline. The tool body
* must abort when the harness aborts, and a hung backend must not hold a turn
* open forever.
*/
function deadline(signal: AbortSignal, timeoutMs: number): AbortSignal {
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
}
export class WeknoraClient {
/**
* A knowledge-base-restricted API key rejects `resource_urls=public` with
* 403. After the first such refusal this client stays on handle mode so
* later calls do not pay the same round trip.
*/
private publicModeBlocked = false
constructor(private readonly config: ResolvedConfig) {}
private headers(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
if (this.config.apiKey !== undefined) headers['X-API-Key'] = this.config.apiKey
if (this.config.tenantId !== undefined) headers['X-Tenant-ID'] = this.config.tenantId
return headers
}
private resourceMode(): 'handle' | 'public' {
return this.config.resourceUrls === 'public' && !this.publicModeBlocked ? 'public' : 'handle'
}
private url(path: string, query?: Record<string, string | number | undefined>): string {
const url = new URL(`${this.config.baseUrl}${path}`)
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value))
}
if (this.resourceMode() === 'public') url.searchParams.set('resource_urls', 'public')
return url.toString()
}
private isPublicModeForbidden(error: unknown): boolean {
return error instanceof WeknoraApiError
&& this.config.resourceUrls === 'public'
&& !this.publicModeBlocked
&& error.status === 403
&& error.message.includes('resource_urls=public')
}
private async fetchJson<T>(
path: string,
init: { method: 'GET' | 'POST', body?: unknown, query?: Record<string, string | number | undefined> },
signal: AbortSignal,
): Promise<Envelope<T>> {
try {
return await this.fetchJsonOnce(path, init, signal)
} catch (error) {
if (!this.isPublicModeForbidden(error)) throw error
this.publicModeBlocked = true
return await this.fetchJsonOnce(path, init, signal)
}
}
private async fetchJsonOnce<T>(
path: string,
init: { method: 'GET' | 'POST', body?: unknown, query?: Record<string, string | number | undefined> },
signal: AbortSignal,
): Promise<Envelope<T>> {
const url = this.url(path, init.query)
let response: Response
try {
response = await fetch(url, {
method: init.method,
headers: this.headers(),
...init.body === undefined ? {} : { body: JSON.stringify(init.body) },
signal: deadline(signal, this.config.requestTimeoutMs),
})
} catch (cause) {
throw new WeknoraApiError(`${init.method} ${path} failed: ${describeTransportFailure(cause, signal)}`)
}
const text = await response.text()
if (!response.ok) {
throw new WeknoraApiError(`${init.method} ${path} failed with HTTP ${response.status}: ${reasonOf(text)}`, response.status)
}
try {
return JSON.parse(text) as Envelope<T>
} catch {
throw new WeknoraApiError(`${init.method} ${path} returned a non-JSON body: ${snippet(text)}`, response.status)
}
}
/** List the knowledge bases this credential can retrieve from. */
async listKnowledgeBases(signal: AbortSignal): Promise<KnowledgeBaseSummary[]> {
const envelope = await this.fetchJson<KnowledgeBaseSummary[]>('/knowledge-bases', { method: 'GET' }, signal)
return Array.isArray(envelope.data) ? envelope.data : []
}
/** Hybrid (vector + keyword) retrieval across one or more knowledge bases. */
async search(
input: { query: string, knowledgeBaseIds: string[], knowledgeIds: string[] },
signal: AbortSignal,
): Promise<SearchResult[]> {
const envelope = await this.fetchJson<SearchResult[]>('/knowledge-search', {
method: 'POST',
body: {
query: input.query,
...input.knowledgeBaseIds.length > 0 ? { knowledge_base_ids: input.knowledgeBaseIds } : {},
...input.knowledgeIds.length > 0 ? { knowledge_ids: input.knowledgeIds } : {},
},
}, signal)
return Array.isArray(envelope.data) ? envelope.data : []
}
/**
* Find documents by title or filename. Unlike `search` this spans every
* knowledge base the credential can see and needs no scope, so it is what
* answers "where is the document called X".
*/
async findDocuments(
input: { keyword: string, limit: number },
signal: AbortSignal,
): Promise<DocumentRecord[]> {
const envelope = await this.fetchJson<DocumentRecord[]>('/knowledge/search', {
method: 'GET',
query: { keyword: input.keyword, limit: input.limit },
}, signal)
return Array.isArray(envelope.data) ? envelope.data : []
}
/** A document's metadata, including the generated summary WeKnora stores as `description`. */
async getDocument(knowledgeId: string, signal: AbortSignal): Promise<DocumentRecord> {
const envelope = await this.fetchJson<DocumentRecord>(
`/knowledge/${encodeURIComponent(knowledgeId)}`,
{ method: 'GET' },
signal,
)
return envelope.data ?? {}
}
/** One page of a document's stored chunks, in storage order. */
async listChunks(
input: { knowledgeId: string, page: number, pageSize: number },
signal: AbortSignal,
): Promise<ChunkPage> {
const envelope = await this.fetchJson<ChunkRecord[]>(`/chunks/${encodeURIComponent(input.knowledgeId)}`, {
method: 'GET',
query: { page: input.page, page_size: input.pageSize },
}, signal)
const chunks = Array.isArray(envelope.data) ? envelope.data : []
return {
chunks,
total: typeof envelope.total === 'number' ? envelope.total : chunks.length,
page: typeof envelope.page === 'number' ? envelope.page : input.page,
pageSize: typeof envelope.page_size === 'number' ? envelope.page_size : input.pageSize,
}
}
/** Create a conversation container to hold one or more asked questions. */
async createSession(title: string, signal: AbortSignal): Promise<string> {
const envelope = await this.fetchJson<{ id?: string }>('/sessions', {
method: 'POST',
body: { title },
}, signal)
const id = envelope.data?.id
if (typeof id !== 'string' || id === '') {
throw new WeknoraApiError('POST /sessions returned no session id')
}
return id
}
/**
* Ask a question and assemble the streamed answer. `agentId` selects the
* ReAct pipeline (`/agent-chat`); without it the RAG pipeline answers
* (`/knowledge-chat`).
*/
async ask(
input: {
sessionId: string
query: string
knowledgeBaseIds: string[]
agentId: string | undefined
webSearch: boolean
},
signal: AbortSignal,
): Promise<StreamedAnswer> {
const route = input.agentId === undefined ? 'knowledge-chat' : 'agent-chat'
const path = `/${route}/${encodeURIComponent(input.sessionId)}`
const body = {
query: input.query,
channel: 'api',
...input.knowledgeBaseIds.length > 0 ? { knowledge_base_ids: input.knowledgeBaseIds } : {},
...input.agentId === undefined ? {} : { agent_id: input.agentId, agent_enabled: true },
...input.webSearch ? { web_search_enabled: true } : {},
}
const open = async (): Promise<Response> => {
const combined = deadline(signal, this.config.chatTimeoutMs)
try {
return await fetch(this.url(path), {
method: 'POST',
headers: { ...this.headers(), Accept: 'text/event-stream' },
body: JSON.stringify(body),
signal: combined,
})
} catch (cause) {
throw new WeknoraApiError(`POST ${path} failed: ${describeTransportFailure(cause, signal)}`)
}
}
let response = await open()
if (!response.ok) {
const text = await response.text()
const error = new WeknoraApiError(`POST ${path} failed with HTTP ${response.status}: ${reasonOf(text)}`, response.status)
if (!this.isPublicModeForbidden(error)) throw error
this.publicModeBlocked = true
response = await open()
}
if (!response.ok) {
const text = await response.text()
throw new WeknoraApiError(`POST ${path} failed with HTTP ${response.status}: ${reasonOf(text)}`, response.status)
}
if (response.body === null) {
throw new WeknoraApiError(`POST ${path} returned no response body`)
}
return await assembleStream(response.body, input.sessionId, path, signal)
}
}
/** Turn an aborted or refused fetch into a message that names the real cause. */
function describeTransportFailure(cause: unknown, callerSignal: AbortSignal): string {
if (cause instanceof Error && cause.name === 'AbortError') {
return callerSignal.aborted ? 'the call was cancelled' : 'the request timed out'
}
if (cause instanceof Error && cause.name === 'TimeoutError') return 'the request timed out'
return cause instanceof Error ? cause.message : String(cause)
}
/** One decoded `data:` payload of the WeKnora stream. */
interface StreamEvent {
response_type?: string
content?: string
knowledge_references?: SearchResult[]
session_id?: string
tool_calls?: { function?: { name?: string }, name?: string }[]
}
/**
* Consume WeKnora's `text/event-stream` and assemble the parts a tool result
* needs: the answer text, its citations, and the tool names the agent used.
* Server-side `error` events become a thrown failure so the model is never
* handed a silently empty answer.
*/
async function assembleStream(
body: ReadableStream<Uint8Array>,
sessionId: string,
path: string,
callerSignal: AbortSignal,
): Promise<StreamedAnswer> {
const decoder = new TextDecoder()
const reader = body.getReader()
const answer: string[] = []
const toolCalls: string[] = []
let references: SearchResult[] = []
let resolvedSessionId = sessionId
let buffer = ''
let completed = false
const consumeLine = (line: string): void => {
if (!line.startsWith('data:')) return
const payload = line.slice(5).trim()
if (payload === '' || payload === '[DONE]') return
let event: StreamEvent
try {
event = JSON.parse(payload) as StreamEvent
} catch {
return
}
if (typeof event.session_id === 'string' && event.session_id !== '') resolvedSessionId = event.session_id
switch (event.response_type) {
case 'answer':
if (typeof event.content === 'string') answer.push(event.content)
break
case 'references':
if (Array.isArray(event.knowledge_references)) references = event.knowledge_references
break
case 'tool_call':
for (const call of event.tool_calls ?? []) {
const name = call.function?.name ?? call.name
if (typeof name === 'string' && name !== '' && !toolCalls.includes(name)) toolCalls.push(name)
}
break
case 'error':
throw new WeknoraApiError(`POST ${path} streamed an error: ${event.content ?? 'unknown error'}`)
case 'complete':
completed = true
break
default:
break
}
}
try {
while (!completed) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let newline = buffer.indexOf('\n')
while (newline >= 0) {
consumeLine(buffer.slice(0, newline).replace(/\r$/, ''))
buffer = buffer.slice(newline + 1)
if (completed) break
newline = buffer.indexOf('\n')
}
}
if (!completed && buffer !== '') consumeLine(buffer.replace(/\r$/, ''))
} catch (cause) {
if (cause instanceof WeknoraApiError) throw cause
throw new WeknoraApiError(`POST ${path} stream failed: ${describeTransportFailure(cause, callerSignal)}`)
} finally {
await reader.cancel().catch(() => undefined)
}
// WeKnora ends every answer with a `complete` event. Without it the stream was
// cut short, and handing the model the partial text would present a truncated
// answer as a whole one.
if (!completed) {
throw new WeknoraApiError(`POST ${path} ended before WeKnora completed the answer; `
+ `${answer.join('').length} character(s) had streamed`)
}
return { answer: answer.join(''), references, sessionId: resolvedSessionId, toolCalls }
}
+192
View File
@@ -0,0 +1,192 @@
/** Plugin configuration: shape, defaults, and load-time validation. */
/** Which tools the plugin registers. Omitted entries keep their default. */
export interface ToolToggles {
listKnowledgeBases?: boolean
search?: boolean
readDocument?: boolean
ask?: boolean
}
/** User-supplied configuration, as written in a Cordis patch row. */
export interface Config {
/** WeKnora API root, with or without the `/api/v1` suffix. */
baseUrl?: string
/** Tenant or platform API key, sent as `X-API-Key`. */
apiKey?: string
/** Workspace id, required only for platform-scoped API keys (`X-Tenant-ID`). */
tenantId?: string
/** Default knowledge-base scope when a call names none. */
knowledgeBaseIds?: string[]
/** Default custom agent for `weknora_ask`; omitted uses the RAG pipeline. */
agentId?: string
/** Default and maximum number of chunks a search returns. */
maxResults?: number
/** Per-chunk character budget before the plugin truncates content. */
maxChunkChars?: number
/** Timeout for non-streaming requests. */
requestTimeoutMs?: number
/** Timeout for a streamed answer, which includes model and tool time. */
chatTimeoutMs?: number
/**
* `public` asks WeKnora for directly loadable file URLs in answers and
* citations (the default: dsh can render `https://` Markdown images, not
* `resource://` handles). `handle` keeps the internal references.
*/
resourceUrls?: 'handle' | 'public'
/** Tool-name prefix, so two instances can serve two deployments. */
toolPrefix?: string
/** Per-tool registration switches. */
tools?: ToolToggles
}
/** Configuration after defaults and validation. */
export interface ResolvedConfig {
baseUrl: string
apiKey: string | undefined
tenantId: string | undefined
knowledgeBaseIds: string[]
agentId: string | undefined
maxResults: number
maxChunkChars: number
requestTimeoutMs: number
chatTimeoutMs: number
resourceUrls: 'handle' | 'public'
toolPrefix: string
tools: Required<ToolToggles>
}
const DEFAULTS = {
baseUrl: 'http://localhost:8080/api/v1',
maxResults: 8,
maxChunkChars: 1200,
requestTimeoutMs: 30_000,
chatTimeoutMs: 300_000,
resourceUrls: 'public',
toolPrefix: 'weknora',
} as const
/** Thrown when a patch row configures this plugin in a way it cannot serve. */
export class ConfigError extends Error {
constructor(violations: string[]) {
super(`dsh-weknora configuration is invalid:\n - ${violations.join('\n - ')}`)
this.name = 'ConfigError'
}
}
const API_ROOT = /\/api\/v\d+$/
const TOOL_PREFIX = /^[a-z][a-z0-9_]*$/
/**
* Normalize a WeKnora base URL to an API root. Both `https://kb.example.com`
* and `https://kb.example.com/api/v1` are accepted, because a deployment's
* documented address is usually the bare origin.
*/
export function normalizeBaseUrl(raw: string): string {
const trimmed = raw.trim().replace(/\/+$/, '')
return API_ROOT.test(trimmed) ? trimmed : `${trimmed}/api/v1`
}
function positiveInt(value: unknown, field: string, violations: string[]): number | undefined {
if (value === undefined) return undefined
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
violations.push(`${field} must be a positive number, received ${JSON.stringify(value)}`)
return undefined
}
return Math.floor(value)
}
function stringList(value: unknown, field: string, violations: string[]): string[] | undefined {
if (value === undefined) return undefined
if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string' || entry.trim() === '')) {
violations.push(`${field} must be an array of non-empty strings`)
return undefined
}
return (value as string[]).map(entry => entry.trim())
}
function optionalString(value: unknown, field: string, violations: string[]): string | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') {
violations.push(`${field} must be a string`)
return undefined
}
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
function toggle(value: unknown, field: string, fallback: boolean, violations: string[]): boolean {
if (value === undefined) return fallback
if (typeof value !== 'boolean') {
violations.push(`${field} must be a boolean`)
return fallback
}
return value
}
/**
* Apply defaults and reject an unusable row while the plugin loads, so a typo
* surfaces at boot rather than inside the first tool call the model makes.
* @param raw - the `config` value from the Cordis row.
* @returns configuration every consumer can read without re-checking.
*/
export function resolveConfig(raw: Config | undefined): ResolvedConfig {
const violations: string[] = []
const config = raw ?? {}
if (typeof config !== 'object' || Array.isArray(config)) {
throw new ConfigError(['config must be an object'])
}
const baseUrlInput = optionalString(config.baseUrl, 'baseUrl', violations) ?? DEFAULTS.baseUrl
let baseUrl: string = DEFAULTS.baseUrl
try {
const url = new URL(normalizeBaseUrl(baseUrlInput))
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
violations.push(`baseUrl must use http or https, received ${url.protocol}`)
}
baseUrl = url.toString().replace(/\/+$/, '')
} catch {
violations.push(`baseUrl must be an absolute URL, received ${JSON.stringify(baseUrlInput)}`)
}
const resourceUrls = config.resourceUrls ?? DEFAULTS.resourceUrls
if (resourceUrls !== 'handle' && resourceUrls !== 'public') {
violations.push(`resourceUrls must be "handle" or "public", received ${JSON.stringify(resourceUrls)}`)
}
const toolPrefix = optionalString(config.toolPrefix, 'toolPrefix', violations) ?? DEFAULTS.toolPrefix
if (!TOOL_PREFIX.test(toolPrefix)) {
violations.push(`toolPrefix must match ${String(TOOL_PREFIX)}, received ${JSON.stringify(toolPrefix)}`)
}
const toolToggles = config.tools ?? {}
if (typeof toolToggles !== 'object' || Array.isArray(toolToggles)) {
violations.push('tools must be an object of booleans')
}
const resolved: ResolvedConfig = {
baseUrl,
apiKey: optionalString(config.apiKey, 'apiKey', violations),
tenantId: optionalString(config.tenantId, 'tenantId', violations),
knowledgeBaseIds: stringList(config.knowledgeBaseIds, 'knowledgeBaseIds', violations) ?? [],
agentId: optionalString(config.agentId, 'agentId', violations),
maxResults: positiveInt(config.maxResults, 'maxResults', violations) ?? DEFAULTS.maxResults,
maxChunkChars: positiveInt(config.maxChunkChars, 'maxChunkChars', violations) ?? DEFAULTS.maxChunkChars,
requestTimeoutMs: positiveInt(config.requestTimeoutMs, 'requestTimeoutMs', violations) ?? DEFAULTS.requestTimeoutMs,
chatTimeoutMs: positiveInt(config.chatTimeoutMs, 'chatTimeoutMs', violations) ?? DEFAULTS.chatTimeoutMs,
resourceUrls: resourceUrls === 'public' ? 'public' : 'handle',
toolPrefix,
tools: {
listKnowledgeBases: toggle(toolToggles.listKnowledgeBases, 'tools.listKnowledgeBases', true, violations),
search: toggle(toolToggles.search, 'tools.search', true, violations),
readDocument: toggle(toolToggles.readDocument, 'tools.readDocument', true, violations),
ask: toggle(toolToggles.ask, 'tools.ask', true, violations),
},
}
if (!Object.values(resolved.tools).some(Boolean)) {
violations.push('at least one tool must stay enabled; remove the plugin row instead of disabling all of them')
}
if (violations.length > 0) throw new ConfigError(violations)
return resolved
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Structural types for the slice of the DeepSeek Harness contract this plugin
* uses. Declaring them here keeps the package free of runtime dependencies on
* harness internals: the shipped code only ever hands `ctx.tools.register()` a
* plain object, so a harness release that adds optional definition fields does
* not require a republish of this plugin.
*
* The shapes mirror `@deepseek-ai/dsh-tools` (`ToolDefinition`) and
* `@deepseek-ai/dsh-llm` (`ContentBlock`, `ToolSchema`) as of dsh 0.1.0-rc.8.
*/
/** Supported subset of JSON Schema that the harness tool registry accepts. */
export interface JsonSchemaNode {
type?: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
properties?: Record<string, JsonSchemaNode>
required?: string[]
additionalProperties?: boolean
items?: JsonSchemaNode
enum?: (string | number | boolean | null)[]
const?: string | number | boolean | null
description?: string
title?: string
}
/** Model-facing content block returned by `output.render`. */
export interface TextContentBlock {
type: 'text'
text: string
}
/** Execution context handed to a tool body. */
export interface ToolRunContext {
readonly signal: AbortSignal
}
/** One registered model-facing tool. */
export interface ToolDefinition {
readonly name: string
readonly description: string
readonly parameters: JsonSchemaNode
readonly output: {
readonly schema: JsonSchemaNode
render(args: unknown, value: unknown): TextContentBlock[]
}
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
readonly timeoutMs?: number
isConcurrencySafe?(args: unknown): boolean
}
/** The `ctx.tools` service seam. */
export interface ToolRegistry {
register(definition: ToolDefinition): () => void
}
/** The slice of the Cordis context this plugin injects. */
export interface HarnessContext {
readonly tools: ToolRegistry
readonly logger?: {
info(...args: unknown[]): void
warn(...args: unknown[]): void
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* dsh-weknora: a DeepSeek Harness plugin that gives the agent retrieval,
* document reading and composed answers from a WeKnora knowledge base.
* @module dsh-weknora
*/
import { WeknoraClient } from './client.ts'
import { resolveConfig } from './config.ts'
import type { HarnessContext } from './harness.ts'
import { createTools } from './tools.ts'
export const name = 'dsh-weknora'
/** Cordis waits for the tool registry before applying this plugin. */
export const inject = ['tools'] as const
export type { Config } from './config.ts'
export { ConfigError, normalizeBaseUrl, resolveConfig } from './config.ts'
export { WeknoraApiError, WeknoraClient } from './client.ts'
export { createTools } from './tools.ts'
/**
* Register the configured tools. Each registration is an effect, so unloading
* or reconfiguring the plugin withdraws the tools without a restart.
* @param ctx - the Cordis context, with `ctx.tools` injected.
* @param config - the plugin's `config` row; validated here so a typo fails the load.
*/
export function apply(ctx: HarnessContext, config: unknown): void {
const resolved = resolveConfig(config as never)
const client = new WeknoraClient(resolved)
const registered: string[] = []
for (const definition of createTools(client, resolved)) {
ctx.tools.register(definition)
registered.push(definition.name)
}
ctx.logger?.info(`dsh-weknora: registered ${registered.join(', ')} against ${resolved.baseUrl}`)
}
+25
View File
@@ -0,0 +1,25 @@
/** Pure projections from a tool's canonical value to model-facing text. */
/** Clip text to a character budget, reporting whether anything was dropped. */
export function clip(text: string, maxChars: number): { text: string, truncated: boolean } {
const normalized = text.replace(/\r\n/g, '\n').trim()
if (normalized.length <= maxChars) return { text: normalized, truncated: false }
return { text: `${normalized.slice(0, maxChars).trimEnd()}…`, truncated: true }
}
/** Human-readable score, tolerant of a backend that omits it. */
export function formatScore(score: number): string {
return Number.isFinite(score) ? score.toFixed(3) : 'n/a'
}
/**
* Name a retrieval scope without spending the model's context on it. A
* deployment can hold dozens of knowledge bases, and spelling out every id on
* every search costs far more than the count is worth; the ids stay in the
* tool's canonical value for anything that needs them.
*/
export function describeScope(ids: string[]): string {
if (ids.length === 0) return '(deployment default)'
if (ids.length <= 3) return ids.join(', ')
return `${ids.length} knowledge bases`
}
+562
View File
@@ -0,0 +1,562 @@
/** The model-facing tools this plugin contributes to `ctx.tools`. */
import type { ChunkRecord, DocumentRecord, SearchResult, WeknoraClient } from './client.ts'
import type { ResolvedConfig } from './config.ts'
import type { JsonSchemaNode, TextContentBlock, ToolDefinition } from './harness.ts'
import { clip, describeScope, formatScore } from './render.ts'
/** A retrieval hit projected onto the fields the model and follow-up calls need. */
interface SearchHit {
rank: number
chunk_id: string
knowledge_id: string
document: string
chunk_index: number
score: number
content: string
truncated: boolean
}
/** A document whose name matched, which passage retrieval alone would miss. */
interface DocumentMatch {
knowledge_id: string
title: string
knowledge_base_id: string
knowledge_base: string
}
interface SearchValue {
query: string
knowledge_base_ids: string[]
count: number
results: SearchHit[]
documents: DocumentMatch[]
}
interface KnowledgeBasesValue {
count: number
knowledge_bases: { id: string, name: string, description: string }[]
}
interface DocumentValue {
knowledge_id: string
title: string
summary: string
page: number
page_size: number
total_chunks: number
returned_chunks: number
has_more: boolean
truncated: boolean
content: string
}
interface AskValue {
answer: string
session_id: string
pipeline: 'rag' | 'agent'
tool_calls: string[]
references: { knowledge_id: string, document: string, chunk_index: number, content: string }[]
}
const text = (value: string): TextContentBlock[] => [{ type: 'text', text: value }]
const STRING_ARRAY: JsonSchemaNode = { type: 'array', items: { type: 'string' } }
/** Read an argument the harness passes as `unknown` without trusting its shape. */
function argRecord(args: unknown): Record<string, unknown> {
return typeof args === 'object' && args !== null && !Array.isArray(args) ? args as Record<string, unknown> : {}
}
function requiredString(args: unknown, field: string, toolName: string): string {
const value = argRecord(args)[field]
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${toolName}: "${field}" is required and must be a non-empty string`)
}
return value.trim()
}
function optionalStringArg(args: unknown, field: string): string | undefined {
const value = argRecord(args)[field]
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
}
function stringArrayArg(args: unknown, field: string): string[] {
const value = argRecord(args)[field]
if (!Array.isArray(value)) return []
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== '').map(entry => entry.trim())
}
function boundedIntArg(args: unknown, field: string, fallback: number, max: number): number {
const value = argRecord(args)[field]
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback
return Math.min(Math.floor(value), max)
}
/** Prefer a human title, then the source filename, then the opaque id. */
function documentLabel(result: SearchResult): string {
const title = typeof result.knowledge_title === 'string' ? result.knowledge_title.trim() : ''
if (title !== '') return title
const filename = typeof result.knowledge_filename === 'string' ? result.knowledge_filename.trim() : ''
if (filename !== '') return filename
return typeof result.knowledge_id === 'string' && result.knowledge_id !== '' ? result.knowledge_id : '(untitled)'
}
function projectHit(result: SearchResult, rank: number, maxChunkChars: number): SearchHit {
const clipped = clip(typeof result.content === 'string' ? result.content : '', maxChunkChars)
return {
rank,
chunk_id: typeof result.id === 'string' ? result.id : '',
knowledge_id: typeof result.knowledge_id === 'string' ? result.knowledge_id : '',
document: documentLabel(result),
chunk_index: typeof result.chunk_index === 'number' ? result.chunk_index : -1,
// 0 rather than NaN: the canonical value must stay lossless JSON.
score: typeof result.score === 'number' && Number.isFinite(result.score) ? result.score : 0,
content: clipped.text,
truncated: clipped.truncated,
}
}
/**
* Keep the by-name matches worth showing: inside the scope the caller asked
* for, and not already represented by a passage hit. WeKnora's by-name search
* spans the whole tenant, so the scope filter is what stops a narrowed call
* from reporting documents outside it.
*/
function projectNamedDocuments(
named: DocumentRecord[],
passages: SearchHit[],
knowledgeBaseIds: string[],
knowledgeIds: string[],
): DocumentMatch[] {
const already = new Set(passages.map(hit => hit.knowledge_id))
const scope = new Set(knowledgeBaseIds)
const wanted = new Set(knowledgeIds)
const out: DocumentMatch[] = []
for (const document of named) {
const id = typeof document.id === 'string' ? document.id : ''
const kbId = typeof document.knowledge_base_id === 'string' ? document.knowledge_base_id : ''
if (id === '' || already.has(id)) continue
if (wanted.size > 0 ? !wanted.has(id) : !scope.has(kbId)) continue
const title = typeof document.title === 'string' && document.title.trim() !== ''
? document.title.trim()
: typeof document.file_name === 'string' ? document.file_name : id
out.push({
knowledge_id: id,
title,
knowledge_base_id: kbId,
knowledge_base: typeof document.knowledge_base_name === 'string' ? document.knowledge_base_name : kbId,
})
already.add(id)
}
return out
}
/** Assemble the four tool definitions for one configured deployment. */
export function createTools(client: WeknoraClient, config: ResolvedConfig): ToolDefinition[] {
const name = (suffix: string): string => `${config.toolPrefix}_${suffix}`
const scopeNote = config.knowledgeBaseIds.length > 0
? ` Searches knowledge base(s) ${config.knowledgeBaseIds.join(', ')} unless you name others.`
: ' Searches every knowledge base this credential can see unless you narrow it with knowledge_base_ids.'
// An unscoped call reaches WeKnora as a refusal on the retrieval route and as
// an answer grounded in nothing on the RAG route, so an unconfigured
// deployment resolves the full visible set once and reuses it. Making the
// model pick a scope first is worse: knowledge bases are often named too
// poorly to choose between, and fanning out across them is cheap while they
// share a vector store.
let everyId: Promise<string[]> | undefined
const allKnowledgeBaseIds = async (signal: AbortSignal): Promise<string[]> => {
everyId ??= client.listKnowledgeBases(signal).then(
bases => bases.map(kb => typeof kb.id === 'string' ? kb.id : '').filter(id => id !== ''),
(error: unknown) => {
everyId = undefined
throw error
},
)
return await everyId
}
const resolveScope = async (requested: string[], knowledgeIds: string[], signal: AbortSignal): Promise<string[]> => {
if (requested.length > 0) return requested
if (config.knowledgeBaseIds.length > 0) return config.knowledgeBaseIds
if (knowledgeIds.length > 0) return []
return await allKnowledgeBaseIds(signal)
}
const definitions: ToolDefinition[] = []
if (config.tools.listKnowledgeBases) {
definitions.push({
name: name('list_knowledge_bases'),
description: 'List the WeKnora knowledge bases this deployment can retrieve from, with their ids. '
+ `${name('search')} already spans them all, so reach for this only to report what is available or to `
+ 'narrow a later search to one of them.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
timeoutMs: config.requestTimeoutMs,
isConcurrencySafe: () => true,
output: {
schema: {
type: 'object',
properties: {
count: { type: 'integer' },
knowledge_bases: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
},
required: ['id', 'name', 'description'],
additionalProperties: false,
},
},
},
required: ['count', 'knowledge_bases'],
additionalProperties: false,
},
render: (_args, value) => {
const result = value as KnowledgeBasesValue
if (result.count === 0) return text('No knowledge base is available to this WeKnora credential.')
const lines = result.knowledge_bases.map(kb => {
const description = kb.description === '' ? '' : ` — ${kb.description}`
return `- ${kb.name} (id: ${kb.id})${description}`
})
return text(`${result.count} knowledge base(s):\n${lines.join('\n')}`)
},
},
async execute(_args, exec): Promise<KnowledgeBasesValue> {
const bases = await client.listKnowledgeBases(exec.signal)
return {
count: bases.length,
knowledge_bases: bases.map(kb => ({
id: typeof kb.id === 'string' ? kb.id : '',
name: typeof kb.name === 'string' ? kb.name : '',
description: typeof kb.description === 'string' ? kb.description : '',
})),
}
},
})
}
if (config.tools.search) {
definitions.push({
name: name('search'),
description: 'Search WeKnora knowledge bases and return the matching passages verbatim (hybrid vector + keyword '
+ 'retrieval, no model summarization). Use this to ground an answer in the organization\'s own documents, and '
+ 'also to locate a document by name — a query that reads like a title additionally returns the documents it '
+ 'names.' + scopeNote
+ ` Every hit carries a knowledge_id you can pass to ${name('read_document')} for the full document.`,
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Natural-language query; a full question retrieves better than keywords.' },
knowledge_base_ids: { ...STRING_ARRAY, description: 'Restrict the search to these knowledge base ids.' },
knowledge_ids: { ...STRING_ARRAY, description: 'Restrict the search to these document ids.' },
max_results: { type: 'integer', description: `Maximum passages to return (default ${config.maxResults}).` },
},
required: ['query'],
additionalProperties: false,
},
timeoutMs: config.requestTimeoutMs,
isConcurrencySafe: () => true,
output: {
schema: {
type: 'object',
properties: {
query: { type: 'string' },
knowledge_base_ids: STRING_ARRAY,
count: { type: 'integer' },
results: {
type: 'array',
items: {
type: 'object',
properties: {
rank: { type: 'integer' },
chunk_id: { type: 'string' },
knowledge_id: { type: 'string' },
document: { type: 'string' },
chunk_index: { type: 'integer' },
score: { type: 'number' },
content: { type: 'string' },
truncated: { type: 'boolean' },
},
required: ['rank', 'chunk_id', 'knowledge_id', 'document', 'chunk_index', 'score', 'content', 'truncated'],
additionalProperties: false,
},
},
documents: {
type: 'array',
items: {
type: 'object',
properties: {
knowledge_id: { type: 'string' },
title: { type: 'string' },
knowledge_base_id: { type: 'string' },
knowledge_base: { type: 'string' },
},
required: ['knowledge_id', 'title', 'knowledge_base_id', 'knowledge_base'],
additionalProperties: false,
},
},
},
required: ['query', 'knowledge_base_ids', 'count', 'results', 'documents'],
additionalProperties: false,
},
render: (_args, value) => {
const result = value as SearchValue
const named = result.documents.length === 0
? ''
: `\n\nDocuments named "${result.query}" (read them with ${name('read_document')}):\n`
+ result.documents.map(document =>
`- ${document.title} · knowledge_id: ${document.knowledge_id} · in ${document.knowledge_base}`).join('\n')
if (result.count === 0) {
if (named === '') {
return text(`Nothing in WeKnora matched "${result.query}" `
+ `(searched: ${describeScope(result.knowledge_base_ids)}). `
+ 'Try a differently worded query, or widen the knowledge base scope.')
}
return text(`No passage matched "${result.query}", but its name matches document(s).${named}`)
}
const blocks = result.results.map(hit =>
`[${hit.rank}] ${hit.document} · score ${formatScore(hit.score)} · chunk ${hit.chunk_index} `
+ `· knowledge_id: ${hit.knowledge_id}\n${hit.content}${hit.truncated ? '\n(passage truncated)' : ''}`)
return text(`${result.count} passage(s) for "${result.query}" `
+ `(searched: ${describeScope(result.knowledge_base_ids)}):\n\n${blocks.join('\n\n')}${named}`)
},
},
async execute(args, exec): Promise<SearchValue> {
const toolName = name('search')
const query = requiredString(args, 'query', toolName)
const knowledgeIds = stringArrayArg(args, 'knowledge_ids')
const knowledgeBaseIds = await resolveScope(
stringArrayArg(args, 'knowledge_base_ids'),
knowledgeIds,
exec.signal,
)
// Fail here rather than let WeKnora answer 400: the model can act on a
// message naming the argument to supply, not on a transport error.
if (knowledgeBaseIds.length === 0 && knowledgeIds.length === 0) {
throw new Error(`${toolName}: this WeKnora credential can see no knowledge base, so there is `
+ 'nothing to search. Check the deployment\'s API key scope.')
}
const limit = boundedIntArg(args, 'max_results', config.maxResults, config.maxResults)
const [hits, named] = await Promise.all([
client.search({ query, knowledgeBaseIds, knowledgeIds }, exec.signal),
// By-name matching is an enrichment, and older WeKnora builds may not
// route it. Losing it must not cost the model its passages.
client.findDocuments({ keyword: query, limit }, exec.signal).catch(() => []),
])
const results = hits.slice(0, limit).map((hit, index) => projectHit(hit, index + 1, config.maxChunkChars))
return {
query,
knowledge_base_ids: knowledgeBaseIds,
count: results.length,
results,
documents: projectNamedDocuments(named, results, knowledgeBaseIds, knowledgeIds),
}
},
})
}
if (config.tools.readDocument) {
definitions.push({
name: name('read_document'),
description: 'Read a WeKnora document\'s stored passages in order, reassembled into text. '
+ `Use it after ${name('search')} when one passage is not enough context; page through long documents.`,
parameters: {
type: 'object',
properties: {
knowledge_id: { type: 'string', description: 'Document id, as returned in a search hit.' },
page: { type: 'integer', description: 'Page of passages, starting at 1.' },
page_size: { type: 'integer', description: 'Passages per page (max 100).' },
},
required: ['knowledge_id'],
additionalProperties: false,
},
timeoutMs: config.requestTimeoutMs,
isConcurrencySafe: () => true,
output: {
schema: {
type: 'object',
properties: {
knowledge_id: { type: 'string' },
title: { type: 'string' },
summary: { type: 'string' },
page: { type: 'integer' },
page_size: { type: 'integer' },
total_chunks: { type: 'integer' },
returned_chunks: { type: 'integer' },
has_more: { type: 'boolean' },
truncated: { type: 'boolean' },
content: { type: 'string' },
},
required: [
'knowledge_id', 'title', 'summary', 'page', 'page_size',
'total_chunks', 'returned_chunks', 'has_more', 'truncated', 'content',
],
additionalProperties: false,
},
render: (_args, value) => {
const result = value as DocumentValue
const label = result.title === '' ? result.knowledge_id : `${result.title} (${result.knowledge_id})`
if (result.returned_chunks === 0) {
return text(`Document ${label} has no passage on page ${result.page} `
+ `(${result.total_chunks} passage(s) in total).`)
}
// The summary lets the model judge a long document from page 1
// instead of paging blindly to find out what it is holding.
const summary = result.summary === '' ? '' : `\n\nSummary: ${result.summary}`
const more = result.has_more ? `\n\n(more passages available: request page ${result.page + 1})` : ''
const cut = result.truncated ? '\n(content truncated)' : ''
return text(`Document ${label}, passages ${result.returned_chunks} of ${result.total_chunks} `
+ `(page ${result.page}):${summary}\n\n${result.content}${cut}${more}`)
},
},
async execute(args, exec): Promise<DocumentValue> {
const toolName = name('read_document')
const knowledgeId = requiredString(args, 'knowledge_id', toolName)
const page = boundedIntArg(args, 'page', 1, 10_000)
const pageSize = boundedIntArg(args, 'page_size', 20, 100)
const [result, metadata] = await Promise.all([
client.listChunks({ knowledgeId, page, pageSize }, exec.signal),
// Only page 1 is worth the extra call and the summary's tokens: by
// page 2 the model has already seen what this document is. Metadata
// is context, not the payload, so a deployment that cannot serve it
// must still hand over the text.
page === 1
? client.getDocument(knowledgeId, exec.signal).catch(() => ({}) as DocumentRecord)
: Promise.resolve({} as DocumentRecord),
])
const ordered = [...result.chunks].sort(orderByChunkIndex)
const joined = ordered.map(chunk => typeof chunk.content === 'string' ? chunk.content : '').join('\n\n')
const clipped = clip(joined, config.maxChunkChars * Math.max(1, Math.min(ordered.length, 10)))
const title = typeof metadata.title === 'string' && metadata.title.trim() !== ''
? metadata.title.trim()
: typeof metadata.file_name === 'string' ? metadata.file_name.trim() : ''
return {
knowledge_id: knowledgeId,
title,
summary: clip(typeof metadata.description === 'string' ? metadata.description.trim() : '', config.maxChunkChars).text,
page: result.page,
page_size: result.pageSize,
total_chunks: result.total,
returned_chunks: ordered.length,
has_more: result.page * result.pageSize < result.total,
truncated: clipped.truncated,
content: clipped.text,
}
},
})
}
if (config.tools.ask) {
const pipeline = config.agentId === undefined ? 'RAG' : 'agent'
definitions.push({
name: name('ask'),
description: `Ask WeKnora a question and get its own composed answer with citations (${pipeline} pipeline runs `
+ 'server-side: query rewriting, retrieval, reranking and summarization). Reserve it for broad or '
+ 'synthesis questions whose answer spans many documents, where retrieving passages yourself would take '
+ `several rounds. For anything you can answer from specific passages, prefer ${name('search')}: it is far `
+ 'faster and leaves you the evidence to reason over rather than another model\'s conclusion.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'The question to answer.' },
knowledge_base_ids: { ...STRING_ARRAY, description: 'Restrict retrieval to these knowledge base ids.' },
agent_id: { type: 'string', description: 'Custom agent id; selects the server-side ReAct pipeline.' },
session_id: { type: 'string', description: 'Continue an earlier WeKnora session instead of starting one.' },
web_search: { type: 'boolean', description: 'Let WeKnora also search the web, when its deployment allows it.' },
},
required: ['query'],
additionalProperties: false,
},
timeoutMs: config.chatTimeoutMs,
output: {
schema: {
type: 'object',
properties: {
answer: { type: 'string' },
session_id: { type: 'string' },
pipeline: { type: 'string', enum: ['rag', 'agent'] },
tool_calls: STRING_ARRAY,
references: {
type: 'array',
items: {
type: 'object',
properties: {
knowledge_id: { type: 'string' },
document: { type: 'string' },
chunk_index: { type: 'integer' },
content: { type: 'string' },
},
required: ['knowledge_id', 'document', 'chunk_index', 'content'],
additionalProperties: false,
},
},
},
required: ['answer', 'session_id', 'pipeline', 'tool_calls', 'references'],
additionalProperties: false,
},
render: (_args, value) => {
const result = value as AskValue
const parts: string[] = []
parts.push(result.answer === ''
? 'WeKnora returned an empty answer. Retry with a more specific question, or retrieve passages instead.'
: result.answer)
if (result.references.length > 0) {
const cited = result.references.map((reference, index) =>
`[${index + 1}] ${reference.document} · chunk ${reference.chunk_index} · knowledge_id: ${reference.knowledge_id}`)
parts.push(`Citations:\n${cited.join('\n')}`)
}
if (result.tool_calls.length > 0) parts.push(`WeKnora tools used: ${result.tool_calls.join(', ')}`)
parts.push(`WeKnora session: ${result.session_id} (pass session_id to ask a follow-up in context)`)
return text(parts.join('\n\n'))
},
},
async execute(args, exec): Promise<AskValue> {
const toolName = name('ask')
const query = requiredString(args, 'query', toolName)
const requested = stringArrayArg(args, 'knowledge_base_ids')
const agentId = optionalStringArg(args, 'agent_id') ?? config.agentId
// A custom agent resolves its own scope server-side from its
// KBSelectionMode, and ids sent here would override that as an explicit
// mention. The RAG pipeline has no such default: it retrieves only what
// the request names, and answers from nothing when it names nothing.
const knowledgeBaseIds = agentId === undefined
? await resolveScope(requested, [], exec.signal)
: requested.length > 0 ? requested : config.knowledgeBaseIds
if (agentId === undefined && knowledgeBaseIds.length === 0) {
throw new Error(`${toolName}: this WeKnora credential can see no knowledge base, so there is `
+ 'nothing to answer from. Check the deployment\'s API key scope.')
}
const webSearch = argRecord(args)['web_search'] === true
const sessionId = optionalStringArg(args, 'session_id')
?? await client.createSession(`dsh: ${clip(query, 60).text}`, exec.signal)
const streamed = await client.ask({ sessionId, query, knowledgeBaseIds, agentId, webSearch }, exec.signal)
return {
answer: streamed.answer.trim(),
session_id: streamed.sessionId,
pipeline: agentId === undefined ? 'rag' : 'agent',
tool_calls: streamed.toolCalls,
references: streamed.references.slice(0, config.maxResults).map(reference => ({
knowledge_id: typeof reference.knowledge_id === 'string' ? reference.knowledge_id : '',
document: documentLabel(reference),
chunk_index: typeof reference.chunk_index === 'number' ? reference.chunk_index : -1,
content: clip(typeof reference.content === 'string' ? reference.content : '', config.maxChunkChars).text,
})),
}
},
})
}
return definitions
}
/** Storage order, so a reassembled document reads top to bottom. */
function orderByChunkIndex(left: ChunkRecord, right: ChunkRecord): number {
const a = typeof left.chunk_index === 'number' ? left.chunk_index : 0
const b = typeof right.chunk_index === 'number' ? right.chunk_index : 0
return a - b
}
+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})`))
})
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}