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