viatris-exam-cli 1.8.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/package.json +1 -1
- package/skills/viatris-exam-cli/SKILL.md +4 -3
- package/skills/viatris-exam-cli/references/cli-reference.md +28 -12
- package/skills/viatris-exam-cli.zip +0 -0
- package/src/command-handlers/knowledge.js +53 -1
- package/src/help.js +3 -0
- package/src/input.js +12 -3
- package/src/metadata.js +25 -6
- package/src/sanitizers/documents.js +134 -25
package/README.md
CHANGED
|
@@ -102,7 +102,7 @@ viatris-exam products update-knowledge-tree "$PRODUCT_ID" \
|
|
|
102
102
|
viatris-exam products delete-knowledge-tree "$PRODUCT_ID"
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
产品主数据不属于业务知识树层级;详情、整树回写和整树删除均接受产品编码、产品主数据 ID 或历史知识树根节点 ID,推荐使用稳定的产品编码。固定维度为 L1;知识树回写是完整替换操作,必须一次提交全部 15-500 个 L2/L3/L4 动态节点。整树删除会保留产品根和固定维度,但级联删除动态节点及文档并解绑题目,只能在明确要求时执行。
|
|
106
106
|
|
|
107
107
|
### 知识节点与文档
|
|
108
108
|
|
|
@@ -118,6 +118,9 @@ viatris-exam knowledge documents list <node-id>
|
|
|
118
118
|
viatris-exam knowledge documents create <node-id> --input document.json
|
|
119
119
|
viatris-exam knowledge documents upsert <node-id> --input documents.json
|
|
120
120
|
viatris-exam knowledge documents update <document-id> --input changes.json
|
|
121
|
+
viatris-exam knowledge documents get <document-id>
|
|
122
|
+
viatris-exam knowledge documents link <node-id> <document-id> --input reference.json
|
|
123
|
+
viatris-exam knowledge documents unlink <node-id> <document-id>
|
|
121
124
|
viatris-exam knowledge documents delete <document-id>
|
|
122
125
|
```
|
|
123
126
|
|
|
@@ -125,7 +128,9 @@ viatris-exam knowledge documents delete <document-id>
|
|
|
125
128
|
|
|
126
129
|
`/knowledge` CRUD 响应中的 `level` 是物理存储层级(产品根为 1、固定维度为 2);产品生成接口使用业务层级(产品隐藏、固定维度为 L1、动态节点为 L2-L4)。
|
|
127
130
|
|
|
128
|
-
|
|
131
|
+
知识原文以产品级文档保存,L4 节点通过 `documentRefs` 引用文档,并可携带必须为原文逐字子串的 `markedExcerpts`。关联与解除关联只改变节点引用,不删除文档本体。
|
|
132
|
+
|
|
133
|
+
所有文档写入都会在请求发出前从 `title`、`url`、`summary`、`content` 和 `markedExcerpts` 中移除“小荷”“小荷医疗”和“小荷 AI 医生”及其空格变体。新版写接口发送 `title`、`url`、`content` 和适用场景下的 `markedExcerpts`;兼容字段 `summary` 仍执行净化,但不再写入新版接口。该规则覆盖文档创建、批量 upsert、单文档更新、文档关联和整树回写;标题或原文清洗后为空会直接返回校验错误。
|
|
129
134
|
|
|
130
135
|
### 出题任务
|
|
131
136
|
|
|
@@ -137,7 +142,7 @@ viatris-exam gen-tasks submit "$TASK_ID" --input examples/generated-questions.js
|
|
|
137
142
|
|
|
138
143
|
`externalRef` 是题目回写的幂等键。Agent 重试 POST 前应复用原值。
|
|
139
144
|
|
|
140
|
-
产品全节点任务的 `dimension` 为 `all`。应按 `knowledgeContexts` 覆盖每个叶子节点,并在回写题目时携带对应 `knowledgeNodeId
|
|
145
|
+
产品全节点任务的 `dimension` 为 `all`。应按 `knowledgeContexts` 覆盖每个叶子节点,并在回写题目时携带对应 `knowledgeNodeId`。上下文中的证据文档通过 `excerpts` 提供可用于出题的标记片段;未标记时该数组包含完整原文。
|
|
141
146
|
|
|
142
147
|
### 考试记录
|
|
143
148
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: viatris-exam-cli
|
|
3
3
|
description: Operate the Viatris exam OpenAPI through its agent-friendly CLI. Use for login, products, knowledge, generation tasks, exam records, scoring, exams, questions, or medical search.
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.9.0
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Viatris Exam CLI
|
|
@@ -33,8 +33,9 @@ Use the installed CLI for every supported API operation. Do not call the HTTP en
|
|
|
33
33
|
- Apply every returned generation rule. Product-scoped rules identify applicable product codes through `products`; global rules return an empty `products` array.
|
|
34
34
|
- Prefer scoring rule V2 for generated questions and scoring results. Use stable point and deduction-rule IDs.
|
|
35
35
|
- Treat product master records as outside business knowledge-tree levels. Fixed dimensions are business L1; generated nodes are L2-L4. Product detail lookup also accepts a historical knowledge-tree root ID.
|
|
36
|
-
-
|
|
37
|
-
-
|
|
36
|
+
- Store full evidence sources as product-level documents and attach them to L4 nodes through document references. Keep `markedExcerpts` as exact substrings of the referenced `content`.
|
|
37
|
+
- Document writes automatically remove 小荷 source-brand labels, including 小荷医疗 and whitespace variants of 小荷 AI 医生, from title, URL, summary, content, and marked excerpts. Never reintroduce these labels after CLI sanitization.
|
|
38
|
+
- Current document write APIs accept `title`, `url`, `content`, and `markedExcerpts` as applicable. The CLI retains summary sanitization for compatibility but drops an input `summary` from current outbound document payloads.
|
|
38
39
|
- Treat knowledge-tree and recursive node deletion as destructive. Inspect the target first and perform them only when the user explicitly requests deletion.
|
|
39
40
|
- Do not automatically retry POST, PUT, PATCH, or DELETE commands. Inspect the returned error and determine whether the server accepted the request.
|
|
40
41
|
- Keep temporary payloads outside the repository unless they are requested deliverables.
|
|
@@ -98,7 +98,7 @@ Get a product and its fixed L1 dimensions:
|
|
|
98
98
|
viatris-exam products get "$PRODUCT_ID" --compact
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
Product detail, full-tree update, and full-tree deletion accept a stable product code, product master ID, or historical knowledge-tree root ID. Prefer the product code. The response also contains nullable `nameEn`, `genericName`, `indication`, `dosageForm`, and `remark`. Product master data is outside business levels L1-L4. Use each returned `fixedDimensions[].clientRef` as the parent reference for an L2 node. Each fixed dimension also returns `level: 1` and a display `codePrefix`.
|
|
102
102
|
|
|
103
103
|
Update verified optional medical master data:
|
|
104
104
|
|
|
@@ -126,6 +126,14 @@ Payload shape:
|
|
|
126
126
|
|
|
127
127
|
```json
|
|
128
128
|
{
|
|
129
|
+
"documents": [
|
|
130
|
+
{
|
|
131
|
+
"clientRef": "doc.lipitor_pi",
|
|
132
|
+
"title": "立普妥说明书",
|
|
133
|
+
"url": "https://example.com/evidence/lipitor.pdf",
|
|
134
|
+
"content": "阿托伐他汀每日一次,给药剂量为10~80mg。"
|
|
135
|
+
}
|
|
136
|
+
],
|
|
129
137
|
"nodes": [
|
|
130
138
|
{
|
|
131
139
|
"clientRef": "disease.hypertension",
|
|
@@ -150,10 +158,12 @@ Payload shape:
|
|
|
150
158
|
"dimensionCode": "disease",
|
|
151
159
|
"name": "诊断阈值",
|
|
152
160
|
"sortOrder": 1,
|
|
153
|
-
"
|
|
161
|
+
"documentRefs": [
|
|
154
162
|
{
|
|
155
|
-
"
|
|
156
|
-
"
|
|
163
|
+
"documentRef": "doc.lipitor_pi",
|
|
164
|
+
"markedExcerpts": [
|
|
165
|
+
"阿托伐他汀每日一次,给药剂量为10~80mg"
|
|
166
|
+
]
|
|
157
167
|
}
|
|
158
168
|
]
|
|
159
169
|
}
|
|
@@ -161,7 +171,7 @@ Payload shape:
|
|
|
161
171
|
}
|
|
162
172
|
```
|
|
163
173
|
|
|
164
|
-
Submit the complete L2/L3/L4 node set, between 15 and 500 nodes. Every `clientRef` must be unique within
|
|
174
|
+
Submit up to 200 product-level source documents and the complete L2/L3/L4 node set, between 15 and 500 nodes. Every document and node `clientRef` must be unique within its collection. L2 nodes use `dimension:<code>` as `parentClientRef`; L3/L4 nodes use their direct parent's `clientRef`. Only L4 nodes may use `documentRefs`, every L4 node needs at least one reference, and every marked excerpt must be an exact substring of the referenced document content. A successful PUT replaces the previous dynamic nodes, so do not send a partial tree.
|
|
165
175
|
|
|
166
176
|
Delete all dynamic knowledge-tree content:
|
|
167
177
|
|
|
@@ -216,19 +226,25 @@ Manage documents:
|
|
|
216
226
|
```bash
|
|
217
227
|
viatris-exam knowledge documents list "$NODE_ID"
|
|
218
228
|
viatris-exam knowledge documents create "$NODE_ID" \
|
|
219
|
-
--json '{"title":"说明书","
|
|
229
|
+
--json '{"title":"说明书","url":"https://example.com/pi.pdf","content":"完整原文","markedExcerpts":["完整原文"]}'
|
|
220
230
|
viatris-exam knowledge documents upsert "$NODE_ID" \
|
|
221
|
-
--json '{"items":[{"title":"说明书","
|
|
231
|
+
--json '{"items":[{"title":"说明书","content":"更新后的完整原文","markedExcerpts":["完整原文"]}]}'
|
|
222
232
|
viatris-exam knowledge documents update "$DOCUMENT_ID" \
|
|
223
|
-
--json '{"
|
|
233
|
+
--json '{"content":"更新后的完整原文"}'
|
|
234
|
+
viatris-exam knowledge documents get "$DOCUMENT_ID"
|
|
235
|
+
viatris-exam knowledge documents link "$NODE_ID" "$DOCUMENT_ID" \
|
|
236
|
+
--json '{"markedExcerpts":["更新后的完整原文"]}'
|
|
237
|
+
viatris-exam knowledge documents unlink "$NODE_ID" "$DOCUMENT_ID"
|
|
224
238
|
viatris-exam knowledge documents delete "$DOCUMENT_ID"
|
|
225
239
|
```
|
|
226
240
|
|
|
227
|
-
Create reuses a document with the same title
|
|
241
|
+
Documents are product-level source records containing full `content`; nodes hold references to them. Create requires `title` and `content`, reuses a document with the same title, and attaches it to the node. Batch upsert accepts 1-20 items. For an existing title, omitted `content` keeps the stored source, omitted `markedExcerpts` keeps the reference marks, and an empty `markedExcerpts` array clears them. A single-document update changes only supplied `title`, `url`, or `content`; send `url: null` to clear the URL.
|
|
242
|
+
|
|
243
|
+
`get` returns the full source and every referencing node. `link` attaches an existing document from the same product; its request body is optional. Supplying `markedExcerpts` replaces the existing marks, an empty array clears them, and omission preserves them. `unlink` deletes only the node reference. Inspect references before an explicitly requested document delete.
|
|
228
244
|
|
|
229
245
|
### Document Sanitization
|
|
230
246
|
|
|
231
|
-
Before every document write, the CLI removes source-brand labels from `title`, `url`, and `
|
|
247
|
+
Before every document write, the CLI removes source-brand labels from `title`, `url`, `summary`, `content`, and `markedExcerpts`. This applies to document create, batch upsert, single-document update, node links, and product-level documents plus L4 references in `products update-knowledge-tree`. The compatibility `summary` field remains sanitized but is dropped from current outbound document payloads.
|
|
232
248
|
|
|
233
249
|
Removed forms include:
|
|
234
250
|
|
|
@@ -236,7 +252,7 @@ Removed forms include:
|
|
|
236
252
|
- `小荷医疗`, including whitespace between `小荷` and `医疗`
|
|
237
253
|
- `小荷AI医生` with arbitrary whitespace, including `小荷 AI 医生`, `小荷 AI医生`, and `小荷AI 医生`
|
|
238
254
|
|
|
239
|
-
Whitespace is normalized after removal, and leading or trailing title separators are removed. If a supplied title becomes empty, the CLI returns `validation_error` without sending the request.
|
|
255
|
+
Whitespace is normalized after removal, and leading or trailing title separators are removed. If a supplied title, content, or marked excerpt becomes empty, the CLI returns `validation_error` without sending the request. When source content and excerpts are supplied together, the CLI also verifies every sanitized excerpt remains an exact substring of the sanitized content.
|
|
240
256
|
|
|
241
257
|
List generation tasks:
|
|
242
258
|
|
|
@@ -255,7 +271,7 @@ Get one generation task:
|
|
|
255
271
|
viatris-exam gen-tasks get "$TASK_ID" --compact
|
|
256
272
|
```
|
|
257
273
|
|
|
258
|
-
For a leaf-node task, `knowledgeContext` is the mandatory target context. For a product-wide task, `dimension` is `all`, `knowledgeContext` is null, and `knowledgeContexts` contains every leaf context. Generate `questionsPerNode` questions for every listed context and follow all returned `rules`.
|
|
274
|
+
For a leaf-node task, `knowledgeContext` is the mandatory target context. For a product-wide task, `dimension` is `all`, `knowledgeContext` is null, and `knowledgeContexts` contains every leaf context. Generate `questionsPerNode` questions for every listed context and follow all returned `rules`. Each context document exposes `excerpts`; these are the marked source substrings when marks exist, otherwise the array contains the full source content.
|
|
259
275
|
|
|
260
276
|
Each rule has `scope` and `products`. For `scope: "product"`, `products` contains one or more applicable product codes. For `scope: "global"`, `products` is an empty array. Do not read the removed single-value `product` field.
|
|
261
277
|
|
|
Binary file
|
|
@@ -3,10 +3,11 @@ import {
|
|
|
3
3
|
assertOptions,
|
|
4
4
|
requiredId,
|
|
5
5
|
} from "../args.js";
|
|
6
|
-
import { readJsonInput } from "../input.js";
|
|
6
|
+
import { readJsonInput, readOptionalJsonInput } from "../input.js";
|
|
7
7
|
import {
|
|
8
8
|
sanitizeDocumentBatchPayload,
|
|
9
9
|
sanitizeDocumentPayload,
|
|
10
|
+
sanitizeDocumentReferencePayload,
|
|
10
11
|
} from "../sanitizers/documents.js";
|
|
11
12
|
|
|
12
13
|
async function jsonCommand(
|
|
@@ -27,6 +28,16 @@ function nodePath(positionals, label) {
|
|
|
27
28
|
return requiredId(positionals, 3, label);
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
async function optionalJsonCommand(method, path, options, transform) {
|
|
32
|
+
assertOptions(options, ["input", "json"]);
|
|
33
|
+
const body = await readOptionalJsonInput(options);
|
|
34
|
+
return {
|
|
35
|
+
method,
|
|
36
|
+
path,
|
|
37
|
+
...(body === undefined ? {} : { body: transform(body) }),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export async function resolveKnowledge(positionals, options) {
|
|
31
42
|
const [resource, area, action] = positionals;
|
|
32
43
|
if (resource !== "knowledge") return null;
|
|
@@ -135,6 +146,47 @@ export async function resolveKnowledge(positionals, options) {
|
|
|
135
146
|
);
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
if (area === "documents" && action === "get") {
|
|
150
|
+
assertNoExtraPositionals(positionals, 4);
|
|
151
|
+
assertOptions(options);
|
|
152
|
+
const id = nodePath(positionals, "knowledge document ID");
|
|
153
|
+
return {
|
|
154
|
+
method: "GET",
|
|
155
|
+
path: `/openapi/knowledge/documents/${id}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (area === "documents" && action === "link") {
|
|
160
|
+
assertNoExtraPositionals(positionals, 5);
|
|
161
|
+
const nodeId = nodePath(positionals, "knowledge node ID");
|
|
162
|
+
const documentId = requiredId(
|
|
163
|
+
positionals,
|
|
164
|
+
4,
|
|
165
|
+
"knowledge document ID",
|
|
166
|
+
);
|
|
167
|
+
return optionalJsonCommand(
|
|
168
|
+
"PUT",
|
|
169
|
+
`/openapi/knowledge/nodes/${nodeId}/documents/${documentId}`,
|
|
170
|
+
options,
|
|
171
|
+
sanitizeDocumentReferencePayload,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (area === "documents" && action === "unlink") {
|
|
176
|
+
assertNoExtraPositionals(positionals, 5);
|
|
177
|
+
assertOptions(options);
|
|
178
|
+
const nodeId = nodePath(positionals, "knowledge node ID");
|
|
179
|
+
const documentId = requiredId(
|
|
180
|
+
positionals,
|
|
181
|
+
4,
|
|
182
|
+
"knowledge document ID",
|
|
183
|
+
);
|
|
184
|
+
return {
|
|
185
|
+
method: "DELETE",
|
|
186
|
+
path: `/openapi/knowledge/nodes/${nodeId}/documents/${documentId}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
138
190
|
if (area === "documents" && action === "delete") {
|
|
139
191
|
assertNoExtraPositionals(positionals, 4);
|
|
140
192
|
assertOptions(options);
|
package/src/help.js
CHANGED
|
@@ -27,6 +27,9 @@ Commands:
|
|
|
27
27
|
knowledge documents create <node-id> --input <file|->
|
|
28
28
|
knowledge documents upsert <node-id> --input <file|->
|
|
29
29
|
knowledge documents update <document-id> --input <file|->
|
|
30
|
+
knowledge documents get <document-id>
|
|
31
|
+
knowledge documents link <node-id> <document-id> [--input <file|->]
|
|
32
|
+
knowledge documents unlink <node-id> <document-id>
|
|
30
33
|
knowledge documents delete <document-id>
|
|
31
34
|
gen-tasks list [--status STATUS] [--limit N]
|
|
32
35
|
gen-tasks get <task-id>
|
package/src/input.js
CHANGED
|
@@ -11,8 +11,7 @@ export function hasPipedInput() {
|
|
|
11
11
|
return !process.stdin.isTTY;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
const canReadStdin = hasPipedInput();
|
|
14
|
+
async function readJsonSource(options, required) {
|
|
16
15
|
if (options.input !== undefined && options.json !== undefined) {
|
|
17
16
|
throw new CliError("usage_error", "Use only one of --input and --json.");
|
|
18
17
|
}
|
|
@@ -25,10 +24,11 @@ export async function readJsonInput(options) {
|
|
|
25
24
|
} else if (options.json !== undefined) {
|
|
26
25
|
source = "--json";
|
|
27
26
|
text = options.json;
|
|
28
|
-
} else if (
|
|
27
|
+
} else if (hasPipedInput()) {
|
|
29
28
|
source = "stdin";
|
|
30
29
|
text = await readStdin();
|
|
31
30
|
} else {
|
|
31
|
+
if (!required) return undefined;
|
|
32
32
|
throw new CliError(
|
|
33
33
|
"usage_error",
|
|
34
34
|
"JSON input is required. Use --input <file>, --input -, --json <value>, or stdin.",
|
|
@@ -36,6 +36,7 @@ export async function readJsonInput(options) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
if (!text.trim()) {
|
|
39
|
+
if (!required && source === "stdin") return undefined;
|
|
39
40
|
throw new CliError("input_error", `JSON input from ${source} is empty.`);
|
|
40
41
|
}
|
|
41
42
|
try {
|
|
@@ -51,3 +52,11 @@ export async function readJsonInput(options) {
|
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
|
|
56
|
+
export async function readJsonInput(options) {
|
|
57
|
+
return readJsonSource(options, true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function readOptionalJsonInput(options) {
|
|
61
|
+
return readJsonSource(options, false);
|
|
62
|
+
}
|
package/src/metadata.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CREDENTIAL_FILE_DISPLAY } from "./credentials.js";
|
|
2
2
|
import { DOCUMENT_SANITIZATION_POLICY } from "./sanitizers/documents.js";
|
|
3
3
|
|
|
4
|
-
export const VERSION = "1.
|
|
4
|
+
export const VERSION = "1.9.0";
|
|
5
5
|
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
6
|
export const DEFAULT_RETRIES = 2;
|
|
7
7
|
|
|
@@ -54,8 +54,8 @@ export const COMMANDS = Object.freeze([
|
|
|
54
54
|
command: "products update-knowledge-tree <product-id>",
|
|
55
55
|
method: "PUT",
|
|
56
56
|
path: "/openapi/products/{id}/knowledge-tree",
|
|
57
|
-
input: "{nodes: [...]}",
|
|
58
|
-
summary: "Replace
|
|
57
|
+
input: "{documents?: [...], nodes: [...documentRefs]}",
|
|
58
|
+
summary: "Replace product documents and dynamic L2/L3/L4 nodes.",
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
command: "products delete-knowledge-tree <product-id>",
|
|
@@ -122,8 +122,8 @@ export const COMMANDS = Object.freeze([
|
|
|
122
122
|
command: "knowledge documents create <node-id>",
|
|
123
123
|
method: "POST",
|
|
124
124
|
path: "/openapi/knowledge/nodes/{id}/documents",
|
|
125
|
-
input: "{title,
|
|
126
|
-
summary: "
|
|
125
|
+
input: "{title, content, url?, markedExcerpts?}",
|
|
126
|
+
summary: "Create or reuse a product document and attach it to a node.",
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
command: "knowledge documents upsert <node-id>",
|
|
@@ -136,9 +136,28 @@ export const COMMANDS = Object.freeze([
|
|
|
136
136
|
command: "knowledge documents update <document-id>",
|
|
137
137
|
method: "PUT",
|
|
138
138
|
path: "/openapi/knowledge/documents/{id}",
|
|
139
|
-
input: "{title?,
|
|
139
|
+
input: "{title?, url?, content?}",
|
|
140
140
|
summary: "Update supplied fields on a knowledge document.",
|
|
141
141
|
},
|
|
142
|
+
{
|
|
143
|
+
command: "knowledge documents get <document-id>",
|
|
144
|
+
method: "GET",
|
|
145
|
+
path: "/openapi/knowledge/documents/{id}",
|
|
146
|
+
summary: "Get document source content and all referencing nodes.",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
command: "knowledge documents link <node-id> <document-id>",
|
|
150
|
+
method: "PUT",
|
|
151
|
+
path: "/openapi/knowledge/nodes/{id}/documents/{documentId}",
|
|
152
|
+
input: "{markedExcerpts?} (optional)",
|
|
153
|
+
summary: "Attach an existing product document or update its excerpts.",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
command: "knowledge documents unlink <node-id> <document-id>",
|
|
157
|
+
method: "DELETE",
|
|
158
|
+
path: "/openapi/knowledge/nodes/{id}/documents/{documentId}",
|
|
159
|
+
summary: "Remove a node reference without deleting the document.",
|
|
160
|
+
},
|
|
142
161
|
{
|
|
143
162
|
command: "knowledge documents delete <document-id>",
|
|
144
163
|
method: "DELETE",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { CliError } from "../errors.js";
|
|
2
2
|
|
|
3
3
|
export const DOCUMENT_SANITIZATION_POLICY = Object.freeze({
|
|
4
|
-
fields: ["title", "url", "summary"],
|
|
5
|
-
writableFields: ["title", "
|
|
6
|
-
droppedWriteFields: ["
|
|
4
|
+
fields: ["title", "url", "summary", "content", "markedExcerpts"],
|
|
5
|
+
writableFields: ["title", "url", "content", "markedExcerpts"],
|
|
6
|
+
droppedWriteFields: ["summary"],
|
|
7
7
|
removedLabels: [
|
|
8
8
|
"小荷",
|
|
9
9
|
"小荷医疗",
|
|
@@ -14,8 +14,10 @@ export const DOCUMENT_SANITIZATION_POLICY = Object.freeze({
|
|
|
14
14
|
"knowledge documents create",
|
|
15
15
|
"knowledge documents upsert",
|
|
16
16
|
"knowledge documents update",
|
|
17
|
+
"knowledge documents link",
|
|
17
18
|
],
|
|
18
19
|
emptyTitle: "validation_error",
|
|
20
|
+
emptyContent: "validation_error",
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
const PROHIBITED_LABEL = /小荷\s*(?:AI\s*医生|医疗)?/giu;
|
|
@@ -51,14 +53,57 @@ export function sanitizeDocument(document, location = "document") {
|
|
|
51
53
|
);
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
|
-
for (const field of ["url", "summary"]) {
|
|
56
|
+
for (const field of ["url", "summary", "content"]) {
|
|
55
57
|
if (typeof sanitized[field] === "string") {
|
|
56
58
|
sanitized[field] = sanitizeText(sanitized[field]);
|
|
57
59
|
}
|
|
58
60
|
}
|
|
61
|
+
if (typeof sanitized.content === "string" && !sanitized.content) {
|
|
62
|
+
throw new CliError(
|
|
63
|
+
"validation_error",
|
|
64
|
+
`${location}.content is empty after prohibited source labels are removed.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(sanitized.markedExcerpts)) {
|
|
68
|
+
sanitized.markedExcerpts = sanitizeMarkedExcerpts(
|
|
69
|
+
sanitized.markedExcerpts,
|
|
70
|
+
`${location}.markedExcerpts`,
|
|
71
|
+
);
|
|
72
|
+
validateMarkedExcerpts(
|
|
73
|
+
sanitized.markedExcerpts,
|
|
74
|
+
sanitized.content,
|
|
75
|
+
`${location}.markedExcerpts`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
59
78
|
return sanitized;
|
|
60
79
|
}
|
|
61
80
|
|
|
81
|
+
function sanitizeMarkedExcerpts(excerpts, location) {
|
|
82
|
+
return excerpts.map((excerpt, index) => {
|
|
83
|
+
if (typeof excerpt !== "string") return excerpt;
|
|
84
|
+
const sanitized = sanitizeText(excerpt);
|
|
85
|
+
if (!sanitized) {
|
|
86
|
+
throw new CliError(
|
|
87
|
+
"validation_error",
|
|
88
|
+
`${location}[${index}] is empty after prohibited source labels are removed.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return sanitized;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateMarkedExcerpts(excerpts, content, location) {
|
|
96
|
+
if (typeof content !== "string") return;
|
|
97
|
+
excerpts.forEach((excerpt, index) => {
|
|
98
|
+
if (typeof excerpt === "string" && !content.includes(excerpt)) {
|
|
99
|
+
throw new CliError(
|
|
100
|
+
"validation_error",
|
|
101
|
+
`${location}[${index}] must be an exact substring of the sanitized document content.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
62
107
|
function prepareDocumentForWrite(document, location) {
|
|
63
108
|
const sanitized = sanitizeDocument(document, location);
|
|
64
109
|
if (
|
|
@@ -68,7 +113,7 @@ function prepareDocumentForWrite(document, location) {
|
|
|
68
113
|
) {
|
|
69
114
|
return sanitized;
|
|
70
115
|
}
|
|
71
|
-
const {
|
|
116
|
+
const { summary: _unsupportedSummary, ...writableDocument } = sanitized;
|
|
72
117
|
return writableDocument;
|
|
73
118
|
}
|
|
74
119
|
|
|
@@ -86,28 +131,92 @@ export function sanitizeDocumentBatchPayload(body) {
|
|
|
86
131
|
};
|
|
87
132
|
}
|
|
88
133
|
|
|
134
|
+
export function sanitizeDocumentReferencePayload(
|
|
135
|
+
body,
|
|
136
|
+
location = "documentReference",
|
|
137
|
+
) {
|
|
138
|
+
if (
|
|
139
|
+
body === null ||
|
|
140
|
+
Array.isArray(body) ||
|
|
141
|
+
typeof body !== "object"
|
|
142
|
+
) {
|
|
143
|
+
return body;
|
|
144
|
+
}
|
|
145
|
+
return prepareDocumentForWrite(body, location);
|
|
146
|
+
}
|
|
147
|
+
|
|
89
148
|
export function sanitizeKnowledgeTreePayload(body) {
|
|
90
|
-
|
|
149
|
+
const documents = Array.isArray(body.documents)
|
|
150
|
+
? body.documents.map((document, documentIndex) =>
|
|
151
|
+
prepareDocumentForWrite(
|
|
152
|
+
document,
|
|
153
|
+
`documents[${documentIndex}]`,
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
: body.documents;
|
|
157
|
+
const documentsByRef = new Map(
|
|
158
|
+
Array.isArray(documents)
|
|
159
|
+
? documents
|
|
160
|
+
.filter(
|
|
161
|
+
(document) =>
|
|
162
|
+
document &&
|
|
163
|
+
typeof document === "object" &&
|
|
164
|
+
typeof document.clientRef === "string",
|
|
165
|
+
)
|
|
166
|
+
.map((document) => [document.clientRef, document])
|
|
167
|
+
: [],
|
|
168
|
+
);
|
|
169
|
+
const nodes = Array.isArray(body.nodes)
|
|
170
|
+
? body.nodes.map((node, nodeIndex) => {
|
|
171
|
+
if (
|
|
172
|
+
node === null ||
|
|
173
|
+
Array.isArray(node) ||
|
|
174
|
+
typeof node !== "object"
|
|
175
|
+
) {
|
|
176
|
+
return node;
|
|
177
|
+
}
|
|
178
|
+
const sanitizedNode = { ...node };
|
|
179
|
+
if (Array.isArray(node.documentRefs)) {
|
|
180
|
+
sanitizedNode.documentRefs = node.documentRefs.map(
|
|
181
|
+
(reference, referenceIndex) => {
|
|
182
|
+
const location =
|
|
183
|
+
`nodes[${nodeIndex}].documentRefs[${referenceIndex}]`;
|
|
184
|
+
const sanitizedReference =
|
|
185
|
+
sanitizeDocumentReferencePayload(reference, location);
|
|
186
|
+
const document = documentsByRef.get(
|
|
187
|
+
sanitizedReference?.documentRef,
|
|
188
|
+
);
|
|
189
|
+
if (
|
|
190
|
+
document &&
|
|
191
|
+
Array.isArray(sanitizedReference.markedExcerpts)
|
|
192
|
+
) {
|
|
193
|
+
validateMarkedExcerpts(
|
|
194
|
+
sanitizedReference.markedExcerpts,
|
|
195
|
+
document.content,
|
|
196
|
+
`${location}.markedExcerpts`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return sanitizedReference;
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
// Keep sanitizing the legacy shape so no document write path can bypass
|
|
204
|
+
// the permanent source-label policy.
|
|
205
|
+
if (Array.isArray(node.documents)) {
|
|
206
|
+
sanitizedNode.documents = node.documents.map(
|
|
207
|
+
(document, documentIndex) =>
|
|
208
|
+
prepareDocumentForWrite(
|
|
209
|
+
document,
|
|
210
|
+
`nodes[${nodeIndex}].documents[${documentIndex}]`,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return sanitizedNode;
|
|
215
|
+
})
|
|
216
|
+
: body.nodes;
|
|
91
217
|
return {
|
|
92
218
|
...body,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
node === null ||
|
|
96
|
-
Array.isArray(node) ||
|
|
97
|
-
typeof node !== "object" ||
|
|
98
|
-
!Array.isArray(node.documents)
|
|
99
|
-
) {
|
|
100
|
-
return node;
|
|
101
|
-
}
|
|
102
|
-
return {
|
|
103
|
-
...node,
|
|
104
|
-
documents: node.documents.map((document, documentIndex) =>
|
|
105
|
-
prepareDocumentForWrite(
|
|
106
|
-
document,
|
|
107
|
-
`nodes[${nodeIndex}].documents[${documentIndex}]`,
|
|
108
|
-
),
|
|
109
|
-
),
|
|
110
|
-
};
|
|
111
|
-
}),
|
|
219
|
+
...(body.documents !== undefined ? { documents } : {}),
|
|
220
|
+
...(body.nodes !== undefined ? { nodes } : {}),
|
|
112
221
|
};
|
|
113
222
|
}
|