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 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
- 产品主数据不属于业务知识树层级;详情查询使用产品主数据 ID,并兼容历史知识树根节点 ID。固定维度为 L1;知识树回写是完整替换操作,必须一次提交全部 15-500 个 L2/L3/L4 动态节点。整树删除会保留产品根和固定维度,但级联删除动态节点及文档并解绑题目,只能在明确要求时执行。
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
- 所有文档写入都会在请求发出前从 `title`、`url`、`summary` 中移除“小荷”“小荷医疗”和“小荷 AI 医生”及其空格变体。当前写接口只接受 `title` 和 `summary`,输入中的 `url` 会在净化后从出站载荷移除,但 URL 净化能力作为永久兼容规则保留。该规则覆盖文档创建、批量 upsert、单文档更新和整树回写中的嵌套文档;标题清洗后为空会直接返回校验错误。
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,6 +1,6 @@
1
1
  {
2
2
  "name": "viatris-exam-cli",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Agent-friendly CLI for the Viatris exam OpenAPI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.8.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
- - Document writes automatically remove 小荷 source-brand labels, including 小荷医疗 and whitespace variants of 小荷 AI 医生, from title, URL, and summary. Never reintroduce these labels after CLI sanitization.
37
- - Current document write APIs accept only `title` and `summary`; the CLI drops an input `url` before sending. Keep URL sanitization in the permanent sanitizer for backward and future compatibility.
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
- Use the product master `id`; historical knowledge-tree root IDs remain accepted for compatibility. 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`.
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
- "documents": [
161
+ "documentRefs": [
154
162
  {
155
- "title": "药品说明书",
156
- "summary": "与该知识点直接对应的证据摘录"
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 the payload. L2 nodes use `dimension:<code>` as `parentClientRef`; L3/L4 nodes use their direct parent's `clientRef`. Every L4 node needs at least one document. A successful PUT replaces the previous dynamic nodes, so do not send a partial tree.
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":"说明书","summary":"摘录"}'
229
+ --json '{"title":"说明书","url":"https://example.com/pi.pdf","content":"完整原文","markedExcerpts":["完整原文"]}'
220
230
  viatris-exam knowledge documents upsert "$NODE_ID" \
221
- --json '{"items":[{"title":"说明书","summary":"更新后的摘录"}]}'
231
+ --json '{"items":[{"title":"说明书","content":"更新后的完整原文","markedExcerpts":["完整原文"]}]}'
222
232
  viatris-exam knowledge documents update "$DOCUMENT_ID" \
223
- --json '{"summary":"更新后的摘要"}'
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 under the node. Batch upsert accepts 1-20 items and updates `summary` for existing titles. A single-document update changes only supplied fields; send `summary: null` to clear the summary. Current write contracts accept only `title` and `summary`; if input contains `url`, the CLI drops it before sending. Read responses may still contain historical URL data. Inspect a document before an explicitly requested delete.
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 `summary`. This applies to document create, batch upsert, single-document update, and documents nested in `products update-knowledge-tree`. URL filtering remains a permanent compatibility rule even though the current API drops URL from write payloads.
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
- export async function readJsonInput(options) {
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 (canReadStdin) {
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.8.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 the product's dynamic L2/L3/L4 knowledge-tree nodes.",
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, summary?}",
126
- summary: "Idempotently create a document under a node.",
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?, summary?}",
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", "summary"],
6
- droppedWriteFields: ["url"],
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 { url: _unsupportedUrl, ...writableDocument } = sanitized;
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
- if (!Array.isArray(body.nodes)) return body;
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
- nodes: body.nodes.map((node, nodeIndex) => {
94
- if (
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
  }