From 43abf0aca5775ced10b863a8ba09553de33e443c Mon Sep 17 00:00:00 2001 From: "zeyu.fz" Date: Wed, 5 Aug 2026 12:02:32 +0800 Subject: [PATCH 01/26] =?UTF-8?q?feat(knowledge):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93=E7=AE=A1=E7=90=86=E5=8F=8A=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=97=85=E7=A8=8B=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加test:journey脚本,覆盖知识库跨命令全链路用户旅程测试 - 在文档中新增Journey E2E章节,详细说明用户旅程测试定位及断言机制 - 完善commands模块,新增知识库相关命令包括知识库列表、信息、创建、更新、删除 - 新增知识库文档相关命令,如文档列表、状态、上传、删除、打标签及OSS导入 - 添加知识服务管理命令,支持列表、创建、更新、部署、删除及复制 - 支持知识块增删查改命令,完善知识点的灵活操作能力 - 实现数据中心分类管理命令,支持分类增删查操作 - 优化knowledge chat命令,增加workspace-id统一解析及agent-version版本控制 - 重构与知识库相关命令的导出与注册,完善CLI整体能力覆盖 - 新增命令详尽的帮助文档,包含参数说明、使用示例及错误边界 - 实现批量删除知识块的自动分批处理逻辑,易于操作大规模数据 - 添加必要的输入校验与安全提示,确保操作安全且符合规范 --- docs/agents/cli-e2e-tests.md | 11 +- package.json | 1 + packages/cli/doc-commands.md | 160 ++ packages/cli/src/commands.ts | 64 + .../src/commands/knowledge/category-add.ts | 79 + .../src/commands/knowledge/category-delete.ts | 65 + .../src/commands/knowledge/category-list.ts | 98 ++ .../commands/src/commands/knowledge/chat.ts | 26 +- .../src/commands/knowledge/chunk-add.ts | 160 ++ .../src/commands/knowledge/chunk-delete.ts | 108 ++ .../src/commands/knowledge/chunk-list.ts | 101 ++ .../src/commands/knowledge/chunk-update.ts | 173 +++ .../commands/knowledge/collection-create.ts | 110 ++ .../src/commands/knowledge/collection-get.ts | 79 + .../src/commands/knowledge/doc-delete.ts | 94 ++ .../src/commands/knowledge/doc-import-oss.ts | 109 ++ .../src/commands/knowledge/doc-list.ts | 83 + .../src/commands/knowledge/doc-status.ts | 124 ++ .../src/commands/knowledge/doc-tag.ts | 88 ++ .../src/commands/knowledge/doc-upload.ts | 294 ++++ .../src/commands/knowledge/file-delete.ts | 88 ++ .../src/commands/knowledge/file-get.ts | 63 + .../src/commands/knowledge/file-list.ts | 103 ++ .../src/commands/knowledge/kb-create.ts | 165 ++ .../src/commands/knowledge/kb-delete.ts | 98 ++ .../src/commands/knowledge/kb-info.ts | 126 ++ .../src/commands/knowledge/kb-list.ts | 92 ++ .../src/commands/knowledge/kb-stats.ts | 103 ++ .../src/commands/knowledge/kb-update.ts | 100 ++ .../commands/src/commands/knowledge/search.ts | 29 +- .../src/commands/knowledge/service-copy.ts | 67 + .../src/commands/knowledge/service-create.ts | 105 ++ .../src/commands/knowledge/service-delete.ts | 98 ++ .../src/commands/knowledge/service-deploy.ts | 112 ++ .../src/commands/knowledge/service-get.ts | 94 ++ .../src/commands/knowledge/service-list.ts | 134 ++ .../src/commands/knowledge/service-update.ts | 362 +++++ .../commands/src/commands/knowledge/shared.ts | 143 ++ .../src/commands/knowledge/upload-support.ts | 126 ++ packages/commands/src/index.ts | 31 + packages/commands/tests/e2e/helpers.ts | 2 + .../tests/e2e/knowledge/journeys/README.md | 34 + .../journeys/j1-cold-start.e2e.test.ts | 177 +++ .../journeys/j2-content-ops.e2e.test.ts | 175 +++ .../journeys/j3-chunk-tuning.e2e.test.ts | 166 ++ .../journeys/j4-service-tuning.e2e.test.ts | 165 ++ .../journeys/j5-data-plane.e2e.test.ts | 186 +++ .../e2e/knowledge/journeys/journey-helpers.ts | 451 ++++++ .../knowledge-chat.e2e.test.ts | 71 +- .../knowledge-chunk-category-file.e2e.test.ts | 1345 +++++++++++++++++ .../knowledge-doc-delete.e2e.test.ts | 90 ++ .../knowledge/knowledge-doc-list.e2e.test.ts | 97 ++ .../knowledge-doc-status.e2e.test.ts | 95 ++ .../knowledge/knowledge-doc-tag.e2e.test.ts | 254 ++++ .../knowledge-doc-upload.e2e.test.ts | 189 +++ .../knowledge/knowledge-kb-create.e2e.test.ts | 156 ++ .../knowledge/knowledge-kb-delete.e2e.test.ts | 220 +++ .../knowledge/knowledge-kb-info.e2e.test.ts | 92 ++ .../knowledge/knowledge-kb-list.e2e.test.ts | 127 ++ .../knowledge/knowledge-kb-update.e2e.test.ts | 115 ++ .../knowledge-search.e2e.test.ts | 30 +- .../knowledge/knowledge-service.e2e.test.ts | 651 ++++++++ .../e2e/{ => knowledge}/knowledge.e2e.test.ts | 4 +- packages/commands/tests/e2e/topic-routes.ts | 139 ++ .../knowledge/knowledge-kb-create.test.ts | 22 + .../tests/knowledge/knowledge-shared.test.ts | 42 + .../knowledge-upload-support.test.ts | 62 + packages/core/src/client/endpoints.ts | 49 + packages/core/src/client/index.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/types/api.ts | 4 + packages/core/src/types/index.ts | 1 + packages/core/src/types/knowledge-admin.ts | 349 +++++ packages/core/tests/rag-endpoints.test.ts | 22 + packages/e2e/src/gating.ts | 11 + packages/kscli/.gitignore | 1 + packages/kscli/src/commands.ts | 62 + packages/runtime/src/confirm.ts | 36 + packages/runtime/src/index.ts | 1 + packages/runtime/tests/confirm.test.ts | 25 + pnpm-lock.yaml | 4 - skills/bailian-cli/reference/index.md | 191 ++- skills/bailian-cli/reference/knowledge.md | 1164 +++++++++++++- 83 files changed, 11207 insertions(+), 139 deletions(-) create mode 100644 packages/cli/doc-commands.md create mode 100644 packages/commands/src/commands/knowledge/category-add.ts create mode 100644 packages/commands/src/commands/knowledge/category-delete.ts create mode 100644 packages/commands/src/commands/knowledge/category-list.ts create mode 100644 packages/commands/src/commands/knowledge/chunk-add.ts create mode 100644 packages/commands/src/commands/knowledge/chunk-delete.ts create mode 100644 packages/commands/src/commands/knowledge/chunk-list.ts create mode 100644 packages/commands/src/commands/knowledge/chunk-update.ts create mode 100644 packages/commands/src/commands/knowledge/collection-create.ts create mode 100644 packages/commands/src/commands/knowledge/collection-get.ts create mode 100644 packages/commands/src/commands/knowledge/doc-delete.ts create mode 100644 packages/commands/src/commands/knowledge/doc-import-oss.ts create mode 100644 packages/commands/src/commands/knowledge/doc-list.ts create mode 100644 packages/commands/src/commands/knowledge/doc-status.ts create mode 100644 packages/commands/src/commands/knowledge/doc-tag.ts create mode 100644 packages/commands/src/commands/knowledge/doc-upload.ts create mode 100644 packages/commands/src/commands/knowledge/file-delete.ts create mode 100644 packages/commands/src/commands/knowledge/file-get.ts create mode 100644 packages/commands/src/commands/knowledge/file-list.ts create mode 100644 packages/commands/src/commands/knowledge/kb-create.ts create mode 100644 packages/commands/src/commands/knowledge/kb-delete.ts create mode 100644 packages/commands/src/commands/knowledge/kb-info.ts create mode 100644 packages/commands/src/commands/knowledge/kb-list.ts create mode 100644 packages/commands/src/commands/knowledge/kb-stats.ts create mode 100644 packages/commands/src/commands/knowledge/kb-update.ts create mode 100644 packages/commands/src/commands/knowledge/service-copy.ts create mode 100644 packages/commands/src/commands/knowledge/service-create.ts create mode 100644 packages/commands/src/commands/knowledge/service-delete.ts create mode 100644 packages/commands/src/commands/knowledge/service-deploy.ts create mode 100644 packages/commands/src/commands/knowledge/service-get.ts create mode 100644 packages/commands/src/commands/knowledge/service-list.ts create mode 100644 packages/commands/src/commands/knowledge/service-update.ts create mode 100644 packages/commands/src/commands/knowledge/shared.ts create mode 100644 packages/commands/src/commands/knowledge/upload-support.ts create mode 100644 packages/commands/tests/e2e/knowledge/journeys/README.md create mode 100644 packages/commands/tests/e2e/knowledge/journeys/j1-cold-start.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/journeys/j2-content-ops.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/journeys/j3-chunk-tuning.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/journeys/j4-service-tuning.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/journeys/j5-data-plane.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/journeys/journey-helpers.ts rename packages/commands/tests/e2e/{ => knowledge}/knowledge-chat.e2e.test.ts (79%) create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-doc-list.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-doc-status.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-doc-tag.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-kb-create.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-kb-info.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-kb-list.e2e.test.ts create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-kb-update.e2e.test.ts rename packages/commands/tests/e2e/{ => knowledge}/knowledge-search.e2e.test.ts (88%) create mode 100644 packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts rename packages/commands/tests/e2e/{ => knowledge}/knowledge.e2e.test.ts (98%) create mode 100644 packages/commands/tests/knowledge/knowledge-kb-create.test.ts create mode 100644 packages/commands/tests/knowledge/knowledge-shared.test.ts create mode 100644 packages/commands/tests/knowledge/knowledge-upload-support.test.ts create mode 100644 packages/core/src/types/knowledge-admin.ts create mode 100644 packages/core/tests/rag-endpoints.test.ts create mode 100644 packages/runtime/src/confirm.ts create mode 100644 packages/runtime/tests/confirm.test.ts diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index 08d3880e..cfae0770 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -6,6 +6,7 @@ | --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- | | **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup(`private`,不发布) | | **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、live(gated);每用例最小路由 | +| **Journey E2E** | `packages/commands/tests/e2e/knowledge/journeys` | 用户旅程全链路(跨命令回路 + 标记词召回闭环),全部 live gated;见 `journeys/README.md` | | **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help、根 help | | **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | 从 `kscli/src/commands.ts` 推导 path/分组;identity(`--version`、`search --help` path) | | **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 | @@ -27,7 +28,7 @@ ### commands E2E -- 路径:`packages/commands/tests/e2e/.e2e.test.ts` +- 路径:`packages/commands/tests/e2e/.e2e.test.ts`;knowledge 领域集中在 `packages/commands/tests/e2e/knowledge/` 子目录(新增 knowledge 命令测试放这里) - 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`(spawn `harness/main.ts`,`routes` 为本 topic 最小 path → export 映射) - fixtures:`packages/commands/tests/e2e/fixtures/` - 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map) @@ -78,6 +79,14 @@ describe.skipIf()("e2e: (DashScope …)", () => { 3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本 4. **真实集成**:放在 skip 块**末尾** +## Journey 层(用户旅程全链路) + +- **定位**:命令 E2E 验单命令契约;journey 验“用户带着目标跨命令走通回路”,结构性断言不在 journey 重复 +- **闭环断言**:fixture 埋独特标记词,以“标记词能否被召回”判定回路闭合;硬断言 fail,软断言 `recordSoft` 落报告人工复核 +- **日志产物**:`createJourneyReporter` 在 `test/output//` 落盘 `journey-report.md`、分步 stdout/stderr、`resources.json`(未清理资源警示) +- **入口**:`pnpm run test:journey`;旅程清单与约定见 [journeys/README.md](../../packages/commands/tests/e2e/knowledge/journeys/README.md) +- **新增命令时**:评估是否属于某条旅程的环节,是则纳入对应 journey 并更新 README 映射表 + ## 增删命令同步 - **commands export** + **topic 路由**(`topic-routes.ts` 或测试文件内 `ROUTES`)+ **产品 map**(`cli/commands.ts` / `kscli/commands.ts`) diff --git a/package.json b/package.json index c631a0b9..a120ae71 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "bl": "pnpm -F bailian-cli dev", "kscli": "pnpm -F knowledge-studio-cli dev", "test": "vp test", + "test:journey": "vp test packages/commands/tests/e2e/knowledge/journeys", "release:check": "node tools/release/check.mjs", "wiki:crawl": "node tools/wiki-crawler/index.mjs", "test:stress": "node packages/cli/tests/stress/run.mjs" diff --git a/packages/cli/doc-commands.md b/packages/cli/doc-commands.md new file mode 100644 index 00000000..f2f57b94 --- /dev/null +++ b/packages/cli/doc-commands.md @@ -0,0 +1,160 @@ +# 迭代一设计 · doc 组命令 + +> 命令:`doc upload` / `doc list` / `doc status` / `doc delete` / `doc tag` / `doc import-oss` +> 公共约定见 [README.md](README.md)。 + +## doc upload — 上传本地文件入库(编排命令) + +**说明**:本迭代最复杂命令。把"本地文件 → 数据中心 →(可选)导入知识库"封装为一条命令,替代构建期最高频的控制台操作(S2.2 痛点:高)。对标竞品 add-file。 + +**编排四步**: + +| 步 | API | 输入 | 输出 | +| ---------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------- | +| 1 申请租约 | `POST /api/v1/connector/dash/applyFileUploadLease` | `category`(类目ID) + `fileName` + `sizeBytes`(字符串!) + `contentMd5`(Base64) | `leaseId` + `param.url/method/headers` | +| 2 OSS 上传 | `PUT {param.url}` | 文件二进制 + `param.headers`(含 `x-bailian-extra`、`Content-Type`) | HTTP 200 | +| 3 注册文件 | `POST /api/v1/connector/dash/addFile` | `leaseId` + `category` + `parser: "AUTO_SELECT"` + `tags?` | `fileId` | +| 4 导入(可选,传 `--index-id` 时) | `POST /api/v1/indices/rag/index/job/create` | `indexId` + `dataSource: { sourceType: "DATA_CENTER_FILE", fileIds }` | `ingestionId` | + +坑位(实现注释必须标注): + +- `sizeBytes` 必须字符串;`contentMd5` = `crypto.createHash("md5").update(buf).digest("base64")` +- 租约/注册的类目参数名是 `category`,不是 `categoryId` +- 第 4 步 body 是嵌套 `dataSource: { sourceType, fileIds }`(实测;公开文档的平铺 `documentIds` 会报 `Index.InvalidParameter`) +- **第 4 步必须显式传 `sourceType`,不传会导入整个数据中心(API 文档明示的默认行为)** +- 步骤 2 走 OSS 域名不走 DashScope 网关,用原生 fetch 而非 ctx.client(无 Bearer 头);失败归类 NETWORK + +**Flags**: + +| flag | 类型 | 必填 | 说明 | +| -------------------------------------------------- | ------ | ---- | --------------------------------------------------------------------------------------------------------------------- | +| `--file ` | array | 是 | 本地文件路径,可重复;扩展名与大小按产品支持范围预校验(见下方格式白名单) | +| `--index-id ` | string | 否 | 注册后立即导入该知识库(触发第 4 步,多文件合并为一个 job) | +| `--category-id ` | string | 否 | 目标类目;缺省自动解析默认类目(listCategory 取 `isDefault: true`),解析失败报 GENERAL + hint 显式传 `--category-id` | +| `--tag ` | array | 否 | addFile tags,可重复 | +| `--wait` / `--poll-interval ` / `--timeout ` | — | 否 | 与 `--index-id` 联用,轮询 job status 至终态 | + +**validate**:`--wait` 无 `--index-id` → USAGE;文件不存在/不可读 → GENERAL + errno hint(沿用错误边界规范)。 + +**格式白名单与大小预校验**(依据 data/documents.md「支持的格式」,读文件前拦截,避免白传 OSS): + +| 类型 | 扩展名 | 硬限(超限 USAGE) | +| ------ | -------------------------- | ----------------------------------------------------- | +| 文档 | .doc .docx .ppt .pptx .pdf | 150 MB | +| 表格 | .xls .xlsx | 10 MB(产品为“建议值”,超限降级为 stderr 警告不拦截) | +| 图片 | .png .jpg .jpeg .bmp .gif | 20 MB(尺寸约束不做客户端校验,留服务端) | +| 纯文本 | .md .txt .html | 10 MB(同表格,警告不拦截) | + +- 扩展名不在白名单 → USAGE,错误信息列出支持格式;白名单常量独立导出便于后续随产品更新 +- 开放问题:create-kb.md 提及 .csv 但 documents.md 格式表未列——文档口径不一致,实现前向产品确认;确认前 .csv 暂入白名单(服务端拒绝会透传) + +**输出**: + +- text:每文件一行 ` registered`;有导入时追加 `job: `;--wait 结束追加终态 +- json:`{ files: [{path, fileId}], index_id?, ingestion_id?, final_status? }`(编排命令无单一响应可透传,输出自定义稳定结构) +- quiet:仅 fileId 每行一个 + +**实现方案**: + +- 文件 `doc-upload.ts`;多文件串行执行 1-3 步(首版不并发,避免 OSS 限流复杂化),全部注册成功后合并执行第 4 步 +- 部分失败语义:任一文件步骤 1-3 失败即中止并报错,已成功的 fileId 列入错误 hint(幂等重传代价低) +- 默认类目解析结果进程内缓存(多文件只查一次) +- dry-run:不读文件内容(size/md5 以占位符表示),输出四步编排计划 `{ steps: [{step, endpoint, request}] }` + +**测试方案**: + +- help / 缺 `--file` exitCode 2 / `--wait` 无 `--index-id` exitCode 2 +- 文件不存在 → 非零退出 + ENOENT hint;`.zip` 扩展名 → USAGE 列出支持格式 +- dry-run:断言 steps 长度(带/不带 --index-id 为 4/3)、lease 请求 `sizeBytes` 为字符串类型、job 请求含 `sourceType: "DATA_CENTER_FILE"` +- live:上传 1KB 临时 md 文件 → 断言 fileId 前缀 `file_` → afterAll doc delete + 数据中心 deleteFile 清理 + +## doc list — 查询知识库文档列表 + +**说明**:列出库内文档及解析/索引状态,含 FAILED 发现(S2.3 / S5.2)。 + +**API**:`GET /api/v1/indices/rag/index/files`,query string:`index_id` + `page_num`(注意本接口是 page_num)+ `page_size`(默认 10,最大 100)。 + +**Flags**:`--index-id` 必填;`--page-number` / `--page-size`。 + +**输出**: + +- text:每行 `doc_id status doc_name doc_type size`;status=FAILED 行红色高亮(TTY);尾行 `total: N` +- json 透传;quiet 仅 doc_id + +**实现/测试**:单 API 直映射(`doc-list.ts`);dry-run 断言 query 参数名为 `page_num`;live 断言 rows 结构与 doc_id 前缀。 + +## doc status — 查询导入任务状态 + +**说明**:查导入任务进度,`--wait` 阻塞至终态供脚本串行(S2.3 痛点:高,L3 验收:FAILED 时非零 exit code)。 + +**API**:`GET /api/v1/indices/rag/index_job/status`,query string:`index_id` + `job_id`(**双必填,仅传其一服务端返回 SystemError,客户端前置双校验拦截**)+ 分页参数。 + +**Flags**: + +| flag | 必填 | 说明 | +| -------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------- | +| `--index-id ` | 是 | 知识库 ID | +| `--job-id ` | 是 | 导入任务 ID(kb create / doc upload 返回的 ingestionId;也见 doc list 的 ingestion_id) | +| `--page-number` / `--page-size` | 否 | 任务含大量文档时分页 | +| `--wait` / `--poll-interval `(默认 5) / `--timeout `(默认 600) | 否 | 轮询至终态 | + +**行为**: + +- 终态 FINISH → exit 0;FAILED → `BailianError(GENERAL)` 透传服务端 message(含文档级失败明细摘要),exit 1 +- `--wait` 超时 → TIMEOUT(5) +- 已知行为:库无进行中任务时接口可能返回 SystemError——hint 引导 "check ingestion_id via doc list" + +**输出**:text 顶部任务总状态 + 文档级状态列表(FAILED 高亮);json 透传。 + +**测试方案**:help / 缺任一必填(两条用例)/ dry-run 断言 query 含两个 id / live:配合 upload 用例拿真实 job 轮询到 FINISH;`--wait --timeout 1` 对慢任务断言 exitCode 5(若不稳定则仅静态覆盖超时路径,live 标记 skip 原因)。 + +## doc delete — 删除文档【危险操作】 + +**说明**:从知识库删除文档及其全部切片(S5.1 内容更新循环)。 + +**API**:`POST /api/v1/indices/rag/index/delete_file`,body `{ index_id, doc_ids }`(snake_case)。响应 `data.deleted[]` 为实际删除列表。 + +**Flags**:`--index-id` 必填;`--doc-id` array 必填(可重复);`--yes`。 + +**实现方案**:`doc-delete.ts`;确认摘要含 index_id + doc_id 列表(≤5 个全列,超出显示前 5 + 总数);输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。 + +**测试方案**:help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 非 TTY 无 `--yes` exitCode 2 / live 配合 upload 清理链。 + +## doc tag — 批量更新文档标签 + +**说明**:批量打标,支撑标签过滤检索(S2.4)。 + +**API**:`POST /api/v1/connector/dash/batchUpdateFileTag`。`fileInfos`(1-20 项,每项 `fileId` + `tags`,单标签 ≤32 字符、单文件 ≤100 个、总长 ≤700)+ `updateMode`(OVERWRITE/APPEND)。 + +**Flags**: + +| flag | 必填 | 说明 | +| --------------- | ---- | ------------------------------------------------------------------------- | +| `--doc-id ` | 是 | 可重复,1-20 个(客户端预校验),映射 fileInfos[].fileId | +| `--tag ` | 是 | 可重复,应用到所有 `--doc-id`(首版同一组标签批量打;异构标签用多次调用) | +| `--mode ` | 否 | choices: `overwrite`/`append`,默认 `append`(追加比覆盖安全,作为缺省) | + +**实现/测试**:`doc-tag.ts` 单 API 直映射;客户端预校验标签长度约束(USAGE 前置拦截);dry-run 断言 `updateMode: "APPEND"` 大写映射与 fileInfos 结构;live 打标后 listFile/describeFile 验证回读。 + +## doc import-oss — 从授权 OSS 批量导入 + +**说明**:从已 SLR 授权的 OSS Bucket 批量导入数据中心(大客户批量场景)。 + +**API**:`POST /api/v1/connector/dash/addFilesFromAuthorizedOss`。必填 `categoryId/categoryType/ossBucket/ossRegionId/fileDetails`(1-10 项,每项 `fileName+ossKey`)。返回 `data.fileIds`。 + +**Flags**: + +| flag | 必填 | 说明 | +| -------------------- | ---- | -------------------------------------------- | +| `--bucket ` | 是 | 映射 ossBucket | +| `--region ` | 是 | 映射 ossRegionId(如 cn-beijing) | +| `--oss-key ` | 是 | 可重复,1-10 个;fileName 取 key 的 basename | +| `--category-id ` | 否 | 缺省走默认类目解析(复用 upload 的解析函数) | +| `--tag ` | 否 | 可重复,≤10 | +| `--overwrite` | 否 | switch,映射 overWriteFileByOssKey | + +固定值:`categoryType: "UNSTRUCTURED"`;`parser` 不暴露(默认 AUTO_SELECT,审慎原则——DASH_QWEN_VL_PARSER 等需配 parserConfig,使用方式未验证)。 + +**错误边界**:SLR 未授权的服务端权限错误原样透传,hint 附 RAM 控制台确认 `AliyunServiceRoleForBailian` 的指引(该指引来自 API 文档 Note,属可权威解释范围)。 + +**实现/测试**:`doc-import-oss.ts` 单 API 直映射;dry-run 断言 fileDetails 结构与 fileName 派生逻辑;live 依赖 OSS 授权环境,gating 追加 `BAILIAN_E2E_OSS_BUCKET` 环境变量,无则 skip。 diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 2cbda30d..c06179b5 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -33,6 +33,37 @@ import { knowledgeRetrieve, knowledgeSearch, knowledgeChat, + knowledgeKbList, + knowledgeKbInfo, + knowledgeDocList, + knowledgeDocStatus, + knowledgeDocUpload, + knowledgeKbCreate, + knowledgeKbUpdate, + knowledgeKbDelete, + knowledgeDocDelete, + knowledgeDocTag, + knowledgeServiceList, + knowledgeServiceGet, + knowledgeServiceCreate, + knowledgeServiceUpdate, + knowledgeServiceDeploy, + knowledgeServiceDelete, + knowledgeServiceCopy, + knowledgeChunkAdd, + knowledgeChunkList, + knowledgeChunkUpdate, + knowledgeChunkDelete, + knowledgeKbStats, + knowledgeCategoryList, + knowledgeCategoryAdd, + knowledgeCategoryDelete, + knowledgeFileList, + knowledgeFileGet, + knowledgeFileDelete, + knowledgeCollectionCreate, + knowledgeCollectionGet, + knowledgeDocImportOss, mcpCall, mcpList, mcpTools, @@ -151,6 +182,39 @@ export const commands: Record = { "knowledge retrieve": knowledgeRetrieve, "knowledge search": knowledgeSearch, "knowledge chat": knowledgeChat, + "knowledge list": knowledgeKbList, + "knowledge info": knowledgeKbInfo, + "knowledge create": knowledgeKbCreate, + "knowledge update": knowledgeKbUpdate, + "knowledge delete": knowledgeKbDelete, + "knowledge doc list": knowledgeDocList, + "knowledge doc status": knowledgeDocStatus, + "knowledge doc upload": knowledgeDocUpload, + "knowledge doc delete": knowledgeDocDelete, + "knowledge doc tag": knowledgeDocTag, + "knowledge service list": knowledgeServiceList, + "knowledge service get": knowledgeServiceGet, + "knowledge service create": knowledgeServiceCreate, + "knowledge service update": knowledgeServiceUpdate, + "knowledge service deploy": knowledgeServiceDeploy, + "knowledge service delete": knowledgeServiceDelete, + "knowledge service copy": knowledgeServiceCopy, + "knowledge chunk add": knowledgeChunkAdd, + "knowledge chunk list": knowledgeChunkList, + "knowledge chunk update": knowledgeChunkUpdate, + "knowledge chunk delete": knowledgeChunkDelete, + "knowledge stats": knowledgeKbStats, + "knowledge doc import-oss": knowledgeDocImportOss, + // Data-center commands live under knowledge (no separate connector namespace); + // the user-facing term for connector is "collection". + "knowledge collection create": knowledgeCollectionCreate, + "knowledge collection get": knowledgeCollectionGet, + "knowledge category list": knowledgeCategoryList, + "knowledge category add": knowledgeCategoryAdd, + "knowledge category delete": knowledgeCategoryDelete, + "knowledge file list": knowledgeFileList, + "knowledge file get": knowledgeFileGet, + "knowledge file delete": knowledgeFileDelete, "mcp call": mcpCall, "mcp list": mcpList, "mcp tools": mcpTools, diff --git a/packages/commands/src/commands/knowledge/category-add.ts b/packages/commands/src/commands/knowledge/category-add.ts new file mode 100644 index 00000000..57c25dcc --- /dev/null +++ b/packages/commands/src/commands/knowledge/category-add.ts @@ -0,0 +1,79 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagAddCategoryResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const CATEGORY_ADD_FLAGS = { + name: { + type: "string", + valueHint: "", + description: "Category name (1-20 chars)", + required: true, + }, + parentId: { + type: "string", + valueHint: "", + description: "Create as a sub-category of this category", + }, + collectionId: { + type: "string", + valueHint: "", + description: "Create under this collection (defaults to the platform collection)", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Create a data-center category", + auth: "apiKey", + usageArgs: "--name [flags]", + flags: CATEGORY_ADD_FLAGS, + notes: ["Use categories to organize data-center files by business domain."], + exampleArgs: ["--name product-docs --workspace-id ws-xxx", "--name sub --parent-id cate-xxx"], + validate(flags) { + if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters"; + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // categoryType fixed to UNSTRUCTURED (the only valid value for knowledge-base creation today) + const body = { + categoryName: flags.name, + categoryType: "UNSTRUCTURED", + ...(flags.parentId ? { parentCategoryId: flags.parentId } : {}), + ...(flags.collectionId ? { connectorId: flags.collectionId } : {}), + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addCategory); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + const categoryId = response.data?.categoryId; + if (settings.quiet) { + emitBare(categoryId ?? ""); + return; + } + if (format === "text") { + emitBare(`created: ${categoryId ?? "-"} (${flags.name})`); + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/category-delete.ts b/packages/commands/src/commands/knowledge/category-delete.ts new file mode 100644 index 00000000..58c0c28b --- /dev/null +++ b/packages/commands/src/commands/knowledge/category-delete.ts @@ -0,0 +1,65 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagConnectorResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const CATEGORY_DELETE_FLAGS = { + categoryId: { + type: "string", + valueHint: "", + description: "Category ID to delete", + required: true, + }, + yes: { type: "switch", description: "Skip the confirmation prompt" }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Delete a data-center category", + auth: "apiKey", + usageArgs: "--category-id [flags]", + flags: CATEGORY_DELETE_FLAGS, + notes: [ + "Behavior for categories containing files or sub-categories is server-defined — the server error is passed through as-is.", + ], + exampleArgs: ["--category-id cate-xxx --workspace-id ws-xxx", "--category-id cate-xxx --yes"], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + const body = { categoryId: flags.categoryId }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteCategory); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + await confirmDangerousAction( + `Delete category ${flags.categoryId}\nThis cannot be undone.`, + flags.yes ?? false, + ); + + const response = await ctx.client.requestJson< + RagConnectorResponse | undefined> + >({ + path: endpoint, + method: "POST", + body, + }); + + if (settings.quiet) return; + if (format === "text") { + emitBare(`deleted: ${flags.categoryId}`); + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/category-list.ts b/packages/commands/src/commands/knowledge/category-list.ts new file mode 100644 index 00000000..95a601fa --- /dev/null +++ b/packages/commands/src/commands/knowledge/category-list.ts @@ -0,0 +1,98 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagListCategoryResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts"; + +const CATEGORY_LIST_FLAGS = { + collectionId: { + type: "string", + valueHint: "", + description: "Filter by collection ID", + }, + parentId: { + type: "string", + valueHint: "", + description: "List sub-categories of this category", + }, + name: { + type: "string", + valueHint: "", + description: "Filter by category name (exact match, unlike the knowledge base list)", + }, + nextToken: { + type: "string", + valueHint: "", + description: "Cursor for the next page (from previous output)", + }, + maxResult: { + type: "number", + valueHint: "", + description: "Items per page (default: 20)", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "List data-center categories", + auth: "apiKey", + usageArgs: "[flags]", + flags: CATEGORY_LIST_FLAGS, + notes: [ + "Categories marked [default] are where files land when no category is specified.", + "Pagination is cursor-based: reuse the printed next token to continue.", + ], + exampleArgs: ["--workspace-id ws-xxx", "--name my-category", "--next-token "], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // type fixed to UNSTRUCTURED, not exposed as a flag (the only valid value today); note: maxResult is singular + const body = { + type: "UNSTRUCTURED", + ...(flags.collectionId ? { connectorId: flags.collectionId } : {}), + ...(flags.parentId ? { parentId: flags.parentId } : {}), + ...(flags.name ? { categoryName: flags.name } : {}), + ...(flags.nextToken ? { nextToken: flags.nextToken } : {}), + ...(flags.maxResult !== undefined ? { maxResult: flags.maxResult } : {}), + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listCategory); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + const categories = response.data?.categoryList ?? []; + if (settings.quiet) { + for (const category of categories) emitBare(category.categoryId ?? ""); + return; + } + if (format === "text") { + if (categories.length === 0) { + emitBare("No categories found."); + } else { + for (const category of categories) { + const defaultMark = category.isDefault ? " [default]" : ""; + emitBare(truncateLine(`${category.categoryId} ${category.categoryName}${defaultMark}`)); + } + } + const nextToken = response.data?.nextToken; + if (nextToken) emitBare(`next: --next-token ${nextToken}`); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/knowledge/chat.ts b/packages/commands/src/commands/knowledge/chat.ts index 9e0d2559..3e5e03c1 100644 --- a/packages/commands/src/commands/knowledge/chat.ts +++ b/packages/commands/src/commands/knowledge/chat.ts @@ -13,6 +13,7 @@ import { type KnowledgeChatStreamChunk, } from "bailian-cli-core"; import { ansi, emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; const CHAT_FLAGS = { message: { @@ -27,11 +28,15 @@ const CHAT_FLAGS = { description: "Q&A service ID (find in console knowledge Q&A page)", required: true, }, - // 知识库走 workspace 专属域名,--workspace-id 属命令自有 flag(console 凭证域不适用)。 - workspaceId: { + // Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command + // flag here (the console credential scope does not apply). + ...WORKSPACE_FLAG, + // Named to avoid the runtime-reserved global --version flag + agentVersion: { type: "string", - valueHint: "", - description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)", + valueHint: "", + description: + "Service version to call: beta (draft for debugging) or a published number; default is the latest published version", }, image: { type: "array", @@ -146,6 +151,7 @@ export default defineCommand({ "Auth: uses DashScope API Key (Bearer token). Get yours from the console API Key page.", "`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or `kscli config set workspace_id `.", 'Multi-turn: use --message "user:..." and --message "assistant:..." to pass conversation history.', + "`--agent-version beta` calls the draft config for debugging before it is deployed.", ], exampleArgs: [ '--message "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx', @@ -168,14 +174,7 @@ export default defineCommand({ messages = [{ role: "user", content: "" }]; } - const workspaceId = flags.workspaceId || settings.workspaceId; - if (!workspaceId) { - throw new BailianError( - "Workspace ID is required.", - ExitCode.USAGE, - `Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id `, - ); - } + const workspaceId = resolveWorkspaceId(ctx); const format = detectOutputFormat(settings.output); // API only supports SSE; streamOutput controls whether to print tokens in real-time @@ -199,6 +198,9 @@ export default defineCommand({ parameters: { agent_options: { agent_id: flags.agentId, + // Omitted flag → field not sent (default behavior unchanged); the value is + // not validated — the set of versions is server-side state + ...(flags.agentVersion ? { agent_version: flags.agentVersion } : {}), }, }, stream: true, diff --git a/packages/commands/src/commands/knowledge/chunk-add.ts b/packages/commands/src/commands/knowledge/chunk-add.ts new file mode 100644 index 00000000..ca6130fe --- /dev/null +++ b/packages/commands/src/commands/knowledge/chunk-add.ts @@ -0,0 +1,160 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + BailianError, + ExitCode, + type FlagsDef, + type RagMutationResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; +import { readUtf8TextFile } from "./upload-support.ts"; + +const CHUNK_ADD_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + docId: { + type: "string", + valueHint: "", + description: "Attach the chunk to this document (document-type knowledge bases)", + }, + content: { + type: "string", + valueHint: "", + description: "Chunk body text, up to 6000 chars (document-type); alternative to --content-file", + }, + contentFile: { + type: "string", + valueHint: "", + description: "Read chunk body from a UTF-8 plain text file (.md/.txt etc.)", + }, + title: { + type: "string", + valueHint: "", + description: "Chunk title, up to 50 chars (document-type)", + }, + imageUrl: { + type: "array", + valueHint: "", + description: "Chunk image URL (repeatable, up to 10; document-type)", + }, + field: { + type: "array", + valueHint: "", + description: + "Arbitrary field entry (repeatable) for table/image knowledge bases where keys are Excel column headers; mutually exclusive with content/title/image flags", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +/** Parse --field key=value: split on the first =, value may contain = */ +export function parseFieldEntries(entries: string[]): Record { + const field: Record = {}; + for (const entry of entries) { + const separatorIndex = entry.indexOf("="); + if (separatorIndex <= 0) { + throw new BailianError(`--field must be key=value, got: ${entry}`, ExitCode.USAGE); + } + field[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1); + } + return field; +} + +export default defineCommand({ + description: "Add a chunk directly to a knowledge base", + auth: "apiKey", + usageArgs: "--index-id (--content | --field ) [flags]", + flags: CHUNK_ADD_FLAGS, + notes: [ + "Document / table / image knowledge bases are supported; audio-video ones are not.", + "The API is idempotent but rate-limited to 10 calls per second — throttle batch scripts.", + "The response carries no chunk id; list chunks afterwards to find the new one.", + "For table/image knowledge bases use --field with Excel column headers as keys; values are passed through as strings.", + ], + exampleArgs: [ + '--index-id idx-xxx --content "chunk text" --title intro --workspace-id ws-xxx', + "--index-id idx-xxx --field 列A=v1 --field 列B=v2", + ], + validate(flags) { + const hasConvenience = + flags.content !== undefined || + flags.contentFile !== undefined || + flags.title !== undefined || + !!flags.imageUrl?.length; + const hasField = !!flags.field?.length; + if (hasConvenience && hasField) { + return "--field is mutually exclusive with --content/--content-file/--title/--image-url"; + } + if (!hasConvenience && !hasField) { + return "Provide chunk content via --content/--content-file or --field entries"; + } + if (flags.content !== undefined && flags.contentFile !== undefined) { + return "Use either --content or --content-file, not both"; + } + if (flags.content !== undefined && flags.content.length > 6000) { + return "--content must be at most 6000 characters"; + } + if (flags.title !== undefined && flags.title.length > 50) { + return "--title must be at most 50 characters"; + } + if (flags.imageUrl !== undefined && flags.imageUrl.length > 10) { + return "--image-url accepts at most 10 entries"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // dry-run also reads the file and parses --field (rehearsal semantics) + let field: Record; + if (flags.field?.length) { + field = parseFieldEntries(flags.field); + } else { + const content = + flags.contentFile !== undefined ? readUtf8TextFile(flags.contentFile) : flags.content; + if (typeof content === "string" && content.length > 6000) { + throw new BailianError("Chunk content must be at most 6000 characters", ExitCode.USAGE); + } + field = { + ...(content !== undefined ? { content } : {}), + ...(flags.title !== undefined ? { title: flags.title } : {}), + ...(flags.imageUrl?.length ? { image_urls: flags.imageUrl } : {}), + }; + } + + const body = { + pipelineId: flags.indexId, + ...(flags.docId ? { dataId: flags.docId } : {}), + field, + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkCreate); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + // The response carries no chunk_id — quiet mode exits 0 silently on success + if (settings.quiet) return; + if (format === "text") { + emitBare(`chunk created (pipeline: ${flags.indexId})`); + emitBare("List chunks to find the new chunk id."); + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/chunk-delete.ts b/packages/commands/src/commands/knowledge/chunk-delete.ts new file mode 100644 index 00000000..83433f01 --- /dev/null +++ b/packages/commands/src/commands/knowledge/chunk-delete.ts @@ -0,0 +1,108 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + BailianError, + type FlagsDef, + type RagMutationResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const CHUNK_DELETE_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + chunkId: { + type: "array", + valueHint: "", + description: "Chunk ID to delete (repeatable; batches of 10 are sent automatically)", + required: true, + }, + yes: { type: "switch", description: "Skip the confirmation prompt" }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +/** The server caps each request at 10 chunk ids — the client batches automatically (bulk delete is where the CLI beats the console) */ +export function splitIntoBatches(chunkIds: string[], batchSize = 10): string[][] { + const batches: string[][] = []; + for (let batchStart = 0; batchStart < chunkIds.length; batchStart += batchSize) { + batches.push(chunkIds.slice(batchStart, batchStart + batchSize)); + } + return batches; +} + +export default defineCommand({ + description: "Delete chunks from a knowledge base (irreversible)", + auth: "apiKey", + usageArgs: "--index-id --chunk-id [flags]", + flags: CHUNK_DELETE_FLAGS, + notes: [ + "The server accepts at most 10 ids per call; larger sets are split into sequential batches automatically.", + "If a batch fails, the operation stops and already-deleted batches are listed in the error.", + ], + exampleArgs: [ + "--index-id idx-xxx --chunk-id chunk-a --chunk-id chunk-b --workspace-id ws-xxx", + "--index-id idx-xxx --chunk-id chunk-a --yes", + ], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + const batches = splitIntoBatches(flags.chunkId); + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkDelete); + + if (settings.dryRun) { + emitResult( + { + endpoint, + batches: batches.map((batchIds) => ({ + request: { pipelineId: flags.indexId, chunkIds: batchIds }, + })), + }, + format, + ); + return; + } + + await confirmDangerousAction( + `Delete ${flags.chunkId.length} chunk(s) from knowledge base ${flags.indexId} in ${batches.length} batch(es).\nChunks are permanently removed. This cannot be undone.`, + flags.yes ?? false, + ); + + // Sequential batches; any batch failure aborts, listing already-deleted batches in the error + let deletedCount = 0; + for (const batchIds of batches) { + try { + await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body: { pipelineId: flags.indexId, chunkIds: batchIds }, + }); + deletedCount += batchIds.length; + } catch (error) { + if (deletedCount > 0 && error instanceof BailianError && !error.hint) { + throw new BailianError( + error.message, + error.exitCode, + `${deletedCount} chunk(s) in earlier batches were already deleted.`, + { cause: error, api: error.api, rawResponse: error.rawResponse }, + ); + } + throw error; + } + } + + if (settings.quiet) return; + if (format === "text") { + emitBare(`deleted: ${deletedCount} chunk(s) in ${batches.length} batch(es)`); + return; + } + emitResult({ deleted_count: deletedCount, batches: batches.length }, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/chunk-list.ts b/packages/commands/src/commands/knowledge/chunk-list.ts new file mode 100644 index 00000000..13f949de --- /dev/null +++ b/packages/commands/src/commands/knowledge/chunk-list.ts @@ -0,0 +1,101 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagChunkListResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts"; + +const CHUNK_LIST_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + docId: { + type: "string", + valueHint: "", + description: "Only show chunks belonging to this document", + }, + ...PAGE_FLAGS, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "List chunks in a knowledge base with content and status", + auth: "apiKey", + usageArgs: "--index-id [flags]", + flags: CHUNK_LIST_FLAGS, + notes: [ + "Use metadata._id as the chunk id and metadata.doc_id as the document id in chunk update/delete commands.", + "Page size defaults to 20 (server default), max 100.", + ], + exampleArgs: [ + "--index-id idx-xxx --workspace-id ws-xxx", + "--index-id idx-xxx --doc-id file-xxx --page-size 50", + ], + validate(flags) { + if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) { + return "--page-size must be between 1 and 100"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // Gotcha: this endpoint's pagination keys are pageNum/pageSize (camelCase, in the body) + const body = { + indexId: flags.indexId, + pageNum: flags.pageNumber ?? 1, + pageSize: flags.pageSize ?? 20, + ...(flags.docId ? { docId: flags.docId } : {}), + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkList); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + const nodes = response.data?.nodes ?? []; + if (settings.quiet) { + // chunk ids only, for piping into chunk update/delete + for (const node of nodes) emitBare(node.metadata?._id ?? ""); + return; + } + if (format === "text") { + if (nodes.length === 0) { + emitBare("No chunks found."); + } else { + for (const node of nodes) { + const metadata = node.metadata ?? {}; + const statusPart = metadata._chunk_status_message + ? ` status: ${metadata._chunk_status_message}` + : ""; + const excludedPart = + metadata.is_displayed_chunk_content === false ? " [excluded from retrieval]" : ""; + emitBare( + `[chunk] ${metadata._id ?? "?"} (doc: ${metadata.doc_name ?? "?"}, doc_id: ${metadata.doc_id ?? "?"})${statusPart}${excludedPart}`, + ); + const contentText = metadata.content ?? node.text ?? ""; + emitBare(` ${contentText.length > 200 ? `${contentText.slice(0, 200)}…` : contentText}`); + } + } + emitBare(`total: ${response.data?.total ?? nodes.length}`); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/knowledge/chunk-update.ts b/packages/commands/src/commands/knowledge/chunk-update.ts new file mode 100644 index 00000000..772b0201 --- /dev/null +++ b/packages/commands/src/commands/knowledge/chunk-update.ts @@ -0,0 +1,173 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + BailianError, + ExitCode, + type Client, + type FlagsDef, + type RagChunkListResponse, + type RagMutationResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; +import { readUtf8TextFile } from "./upload-support.ts"; + +const CHUNK_UPDATE_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + chunkId: { + type: "string", + valueHint: "", + description: "Chunk ID (metadata._id from the chunk list output)", + required: true, + }, + docId: { + type: "string", + valueHint: "", + description: "Document ID owning the chunk (metadata.doc_id from the chunk list output)", + required: true, + }, + content: { + type: "string", + valueHint: "", + description: "New chunk content, 10-6000 chars; alternative to --content-file", + }, + contentFile: { + type: "string", + valueHint: "", + description: "Read new content from a UTF-8 plain text file (.md/.txt etc.)", + }, + title: { + type: "string", + valueHint: "", + description: "Chunk title, 0-50 chars (empty string clears it; omit to keep unchanged)", + }, + exclude: { type: "switch", description: "Exclude this chunk from retrieval" }, + include: { type: "switch", description: "Include this chunk in retrieval (default)" }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +/** When only toggling include/exclude, read back the current content first (the API requires content — hide that quirk from users) */ +async function fetchChunkContent( + client: Client, + workspaceId: string, + indexId: string, + chunkId: string, + docId: string, +): Promise { + const maxPages = 10; + for (let pageNum = 1; pageNum <= maxPages; pageNum++) { + const response = await client.requestJson({ + path: ragEndpoint(workspaceId, RAG_PATHS.chunkList), + method: "POST", + body: { indexId, docId, pageNum, pageSize: 100 }, + }); + const nodes = response.data?.nodes ?? []; + const match = nodes.find((node) => node.metadata?._id === chunkId); + const matchContent = match?.metadata?.content ?? match?.text; + if (typeof matchContent === "string") return matchContent; + if (nodes.length < 100) break; + } + throw new BailianError( + `Chunk not found: ${chunkId}`, + ExitCode.GENERAL, + "Check the chunk id via the chunk list command.", + ); +} + +export default defineCommand({ + description: "Update chunk content or toggle its retrieval visibility", + auth: "apiKey", + usageArgs: "--index-id --chunk-id --doc-id [flags]", + flags: CHUNK_UPDATE_FLAGS, + notes: [ + "Content must be 10-6000 characters and within the knowledge base's max chunk size.", + "--content-file expects a UTF-8 plain text file; document formats (.docx/.pdf) are not parsed here.", + "Toggling --exclude/--include without new content re-submits the existing content automatically.", + ], + exampleArgs: [ + '--index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --content "corrected text"', + "--index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --exclude", + ], + validate(flags) { + if (flags.content !== undefined && flags.contentFile !== undefined) { + return "Use either --content or --content-file, not both"; + } + if (flags.exclude && flags.include) return "--exclude and --include are mutually exclusive"; + const hasContent = flags.content !== undefined || flags.contentFile !== undefined; + if (!hasContent && !flags.exclude && !flags.include && flags.title === undefined) { + return "Nothing to update — pass --content/--content-file, --title, --exclude or --include"; + } + // Content lower-bound is enforced here (not deferred to run) so dry-run and + // missing-flag diagnostics surface the same error as the live request. + if (flags.content !== undefined && (flags.content.length < 10 || flags.content.length > 6000)) { + return "--content must be 10-6000 characters"; + } + if (flags.title !== undefined && flags.title.length > 50) { + return "--title must be at most 50 characters"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // dry-run also reads the file and validates (rehearsal semantics); the read-back + // request is only made outside dry-run and when no new content is given + let content = + flags.contentFile !== undefined ? readUtf8TextFile(flags.contentFile) : flags.content; + if (content !== undefined && (content.length < 10 || content.length > 6000)) { + throw new BailianError("Chunk content must be 10-6000 characters", ExitCode.USAGE); + } + + if (content === undefined) { + if (settings.dryRun) { + content = ""; + } else { + content = await fetchChunkContent( + ctx.client, + workspaceId, + flags.indexId, + flags.chunkId, + flags.docId, + ); + } + } + + const body = { + pipelineId: flags.indexId, + chunkId: flags.chunkId, + dataId: flags.docId, + content, + // Without exclude/include the chunk stays retrievable (safe default) + isDisplayedChunkContent: !flags.exclude, + ...(flags.title !== undefined ? { title: flags.title } : {}), + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkUpdate); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + if (settings.quiet) return; + if (format === "text") { + emitBare(`updated: ${flags.chunkId}`); + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/collection-create.ts b/packages/commands/src/commands/knowledge/collection-create.ts new file mode 100644 index 00000000..cfd18395 --- /dev/null +++ b/packages/commands/src/commands/knowledge/collection-create.ts @@ -0,0 +1,110 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagAddConnectorResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const COLLECTION_CREATE_FLAGS = { + name: { + type: "string", + valueHint: "", + description: "Collection name", + required: true, + }, + description: { + type: "string", + valueHint: "", + description: "Collection description (required by the server)", + required: true, + }, + storeType: { + type: "string", + valueHint: "", + description: "Storage: platform (managed) or custom (your own OSS bucket)", + }, + ossRegion: { + type: "string", + valueHint: "", + description: "OSS region id (required with --store-type custom)", + }, + ossBucket: { + type: "string", + valueHint: "", + description: "OSS bucket name (required with --store-type custom)", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Create a FILE data collection", + auth: "apiKey", + usageArgs: "--name --description [flags]", + flags: COLLECTION_CREATE_FLAGS, + notes: [ + "Store type defaults to platform (managed storage); custom uses your authorized OSS bucket.", + "There is no collection delete API — create collections deliberately.", + ], + exampleArgs: [ + "--name my-collection --description 'team docs' --workspace-id ws-xxx", + "--name oss-coll --description 'own bucket' --store-type custom --oss-region cn-beijing --oss-bucket my-bucket", + ], + validate(flags) { + // Server rejects names longer than 20 characters ("Connector name is longer than 20") + if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters"; + const storeType = flags.storeType ?? "platform"; + if (storeType !== "platform" && storeType !== "custom") { + return "--store-type must be platform or custom"; + } + if (storeType === "custom" && (!flags.ossRegion || !flags.ossBucket)) { + return "--store-type custom requires --oss-region and --oss-bucket"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + const storeType = (flags.storeType ?? "platform").toUpperCase(); + // The server contract still uses connector* fields; only the CLI-facing term is collection + const body = { + connectorType: "FILE", + connectorName: flags.name, + description: flags.description, + fileConnectorConfig: { + storeType, + ...(storeType === "CUSTOM" + ? { ossRegionId: flags.ossRegion, ossBucket: flags.ossBucket } + : {}), + }, + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addConnector); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + const collectionId = response.data?.connectorId; + if (settings.quiet) { + emitBare(collectionId ?? ""); + return; + } + if (format === "text") { + emitBare(`created: ${collectionId ?? "-"} (${flags.name}, ${storeType})`); + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/collection-get.ts b/packages/commands/src/commands/knowledge/collection-get.ts new file mode 100644 index 00000000..167856ec --- /dev/null +++ b/packages/commands/src/commands/knowledge/collection-get.ts @@ -0,0 +1,79 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagGetConnectorResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const COLLECTION_GET_FLAGS = { + collectionId: { + type: "string", + valueHint: "", + description: "Collection ID; alternative to --name", + }, + name: { + type: "string", + valueHint: "", + description: "Collection name; alternative to --collection-id", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Show data collection details", + auth: "apiKey", + usageArgs: "(--collection-id | --name ) [flags]", + flags: COLLECTION_GET_FLAGS, + exampleArgs: ["--collection-id conn-xxx --workspace-id ws-xxx", "--name my-collection"], + validate(flags) { + if (!flags.collectionId && !flags.name) return "Pass --collection-id or --name"; + if (flags.collectionId && flags.name) return "Use either --collection-id or --name, not both"; + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // The server contract still uses connector* fields; only the CLI-facing term is collection + const body = { + ...(flags.collectionId ? { connectorId: flags.collectionId } : {}), + ...(flags.name ? { connectorName: flags.name } : {}), + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.getConnector); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + const collection = response.data; + if (settings.quiet) { + emitBare(collection?.connectorId ?? ""); + return; + } + if (format === "text") { + emitBare(`id: ${collection?.connectorId ?? "-"}`); + emitBare(`name: ${collection?.connectorName ?? "-"}`); + emitBare(`description: ${collection?.description ?? "-"}`); + const storeType = collection?.fileConnectorConfig?.storeType; + emitBare(`storeType: ${storeType ?? "-"}`); + if (storeType === "CUSTOM") { + emitBare(` ossRegion: ${collection?.fileConnectorConfig?.ossRegionId ?? "-"}`); + emitBare(` ossBucket: ${collection?.fileConnectorConfig?.ossBucket ?? "-"}`); + } + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/doc-delete.ts b/packages/commands/src/commands/knowledge/doc-delete.ts new file mode 100644 index 00000000..86bcd3a7 --- /dev/null +++ b/packages/commands/src/commands/knowledge/doc-delete.ts @@ -0,0 +1,94 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagDeleteFileResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const DOC_DELETE_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + docId: { + type: "array", + valueHint: "", + description: "Document ID to delete (repeatable)", + required: true, + }, + yes: { type: "switch", description: "Skip the confirmation prompt" }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +/** Confirmation summary: list all doc_ids up to 5, otherwise show the first 5 + total count */ +function buildDeleteSummary(indexId: string, docIds: string[]): string { + const listed = + docIds.length <= 5 + ? docIds.join("\n ") + : `${docIds.slice(0, 5).join("\n ")}\n ... (${docIds.length} documents total)`; + return `Delete ${docIds.length} document(s) from knowledge base ${indexId}:\n ${listed}\nDocuments and all their chunks are permanently removed from the index. This cannot be undone.`; +} + +export default defineCommand({ + description: "Delete documents and their chunks from a knowledge base", + auth: "apiKey", + usageArgs: "--index-id --doc-id [flags]", + flags: DOC_DELETE_FLAGS, + notes: [ + "Removes documents from the knowledge base index only; the source files remain in the data center.", + "The output lists the ids actually deleted as reported by the server.", + ], + exampleArgs: [ + "--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx", + "--index-id idx-xxx --doc-id file-a --doc-id file-b --yes", + ], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // snake_case: body { index_id, doc_ids } + const body = { index_id: flags.indexId, doc_ids: flags.docId }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDeleteFile); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + await confirmDangerousAction( + buildDeleteSummary(flags.indexId, flags.docId), + flags.yes ?? false, + ); + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + // Output follows the server's data.deleted list + const deleted = response.data?.deleted ?? []; + if (settings.quiet) { + for (const docId of deleted) emitBare(docId); + return; + } + if (format === "text") { + emitBare(`deleted: ${deleted.length} document(s)`); + for (const docId of deleted) emitBare(` ${docId}`); + if (deleted.length !== flags.docId.length) { + process.stderr.write( + `Warning: requested ${flags.docId.length} deletion(s) but the server reported ${deleted.length}.\n`, + ); + } + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/doc-import-oss.ts b/packages/commands/src/commands/knowledge/doc-import-oss.ts new file mode 100644 index 00000000..40330c96 --- /dev/null +++ b/packages/commands/src/commands/knowledge/doc-import-oss.ts @@ -0,0 +1,109 @@ +import { basename } from "node:path"; +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagOssImportResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const DOC_IMPORT_OSS_FLAGS = { + bucket: { + type: "string", + valueHint: "", + description: "Authorized OSS bucket name", + required: true, + }, + region: { + type: "string", + valueHint: "", + description: "OSS region id (e.g. cn-beijing)", + required: true, + }, + ossKey: { + type: "array", + valueHint: "", + description: "OSS object key to import (repeatable, 1-10 per call)", + required: true, + }, + categoryId: { + type: "string", + valueHint: "", + description: "Target data-center category (default: the default category)", + }, + tag: { + type: "array", + valueHint: "", + description: "File tag applied to every imported file (repeatable, up to 10)", + }, + overwrite: { + type: "switch", + description: "Overwrite files previously imported from the same OSS keys", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Batch import files from an authorized OSS bucket into the data center", + auth: "apiKey", + usageArgs: "--bucket --region --oss-key [flags]", + flags: DOC_IMPORT_OSS_FLAGS, + notes: [ + "The bucket must be authorized to the platform service role beforehand; permission errors from the server are passed through with a pointer to check AliyunServiceRoleForBailian in the RAM console.", + "File names are derived from the OSS key basename.", + ], + exampleArgs: [ + "--bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --workspace-id ws-xxx", + "--bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --oss-key docs/b.docx --overwrite", + ], + validate(flags) { + if (flags.ossKey.length > 10) return "--oss-key accepts at most 10 entries per call"; + if (flags.tag !== undefined && flags.tag.length > 10) { + return "--tag accepts at most 10 entries"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // categoryType fixed to UNSTRUCTURED; parser not exposed as a flag (defaults to AUTO_SELECT) + const body = { + categoryId: flags.categoryId ?? "default", + categoryType: "UNSTRUCTURED", + ossBucket: flags.bucket, + ossRegionId: flags.region, + fileDetails: flags.ossKey.map((ossKey) => ({ fileName: basename(ossKey), ossKey })), + ...(flags.tag?.length ? { tags: flags.tag } : {}), + ...(flags.overwrite ? { overWriteFileByOssKey: true } : {}), + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addFilesFromAuthorizedOss); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + const fileIds = response.data?.fileIds ?? []; + if (settings.quiet) { + for (const fileId of fileIds) emitBare(fileId); + return; + } + if (format === "text") { + emitBare(`imported: ${fileIds.length} file(s)`); + for (const fileId of fileIds) emitBare(` ${fileId}`); + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/doc-list.ts b/packages/commands/src/commands/knowledge/doc-list.ts new file mode 100644 index 00000000..8fe5398e --- /dev/null +++ b/packages/commands/src/commands/knowledge/doc-list.ts @@ -0,0 +1,83 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagIndexFilesResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare, ansi } from "bailian-cli-runtime"; +import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts"; + +const DOC_LIST_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + ...PAGE_FLAGS, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "List documents in a knowledge base with parse/index status", + auth: "apiKey", + usageArgs: "--index-id [flags]", + flags: DOC_LIST_FLAGS, + notes: [ + "Documents with status FAILED are highlighted in text mode — use the import job status command to inspect failures.", + "Page size defaults to 10 (server default), max 100.", + ], + exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx", "--index-id idx-xxx --page-size 100"], + validate(flags) { + if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) { + return "--page-size must be between 1 and 100"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // Gotcha: this endpoint's page parameter is page_num (not page_number) + const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles)); + url.searchParams.set("index_id", flags.indexId); + url.searchParams.set("page_num", String(flags.pageNumber ?? 1)); + url.searchParams.set("page_size", String(flags.pageSize ?? 10)); + const endpoint = url.toString(); + + if (settings.dryRun) { + emitResult({ endpoint, request: null }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "GET", + }); + + const rows = response.data?.rows ?? []; + if (settings.quiet) { + for (const row of rows) emitBare(row.doc_id ?? ""); + return; + } + if (format === "text") { + const styles = ansi(process.stdout); + if (rows.length === 0) { + emitBare("No documents found."); + } else { + for (const row of rows) { + const line = truncateLine( + [row.doc_id, row.status, row.doc_name, row.doc_type ?? "-", row.size ?? "-"].join(" "), + ); + emitBare(row.status === "FAILED" ? styles.red(line) : line); + } + } + emitBare(`total: ${response.data?.total_count ?? rows.length}`); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/knowledge/doc-status.ts b/packages/commands/src/commands/knowledge/doc-status.ts new file mode 100644 index 00000000..08e7a6d0 --- /dev/null +++ b/packages/commands/src/commands/knowledge/doc-status.ts @@ -0,0 +1,124 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + BailianError, + ExitCode, + type FlagsDef, + type RagIndexJobStatusResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare, ansi } from "bailian-cli-runtime"; +import { + resolveWorkspaceId, + PAGE_FLAGS, + WORKSPACE_FLAG, + failedImportDocs, + importJobFailureMessage, + pollImportJob, +} from "./shared.ts"; + +const DOC_STATUS_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + jobId: { + type: "string", + valueHint: "", + description: "Import job ID (ingestionId returned by import commands)", + required: true, + }, + ...PAGE_FLAGS, + wait: { type: "switch", description: "Poll until the job reaches a terminal state" }, + pollInterval: { + type: "number", + valueHint: "", + description: "Polling interval when waiting (default: 5)", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +function printStatus(response: RagIndexJobStatusResponse): void { + const styles = ansi(process.stdout); + emitBare(`status: ${response.data?.ingestion_status ?? "UNKNOWN"}`); + for (const doc of response.data?.rows ?? []) { + const docState = doc.code ?? doc.status ?? "?"; + const line = ` ${doc.doc_id ?? "?"} ${docState} ${doc.doc_name ?? ""}`; + emitBare(docState.includes("FAILED") ? styles.red(line) : line); + } +} + +export default defineCommand({ + description: "Check knowledge base import job status", + auth: "apiKey", + usageArgs: "--index-id --job-id [flags]", + flags: DOC_STATUS_FLAGS, + notes: [ + "Both --index-id and --job-id are required by the server (passing only one returns SystemError).", + "If the server returns SystemError for an idle knowledge base, the job may not exist — check the ingestion id in the document list output.", + "Overall job states are PENDING / RUNNING / COMPLETED; per-document failures (for example PARSE_FAILED) exit non-zero with the server message passed through.", + ], + exampleArgs: [ + "--index-id idx-xxx --job-id job-xxx --workspace-id ws-xxx", + "--index-id idx-xxx --job-id job-xxx --wait --poll-interval 10", + ], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // Both required flags are enforced by the parser up front; parameters go in + // the query string (they are ignored in the body) + const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexJobStatus)); + url.searchParams.set("index_id", flags.indexId); + url.searchParams.set("job_id", flags.jobId); + if (flags.pageNumber !== undefined) { + url.searchParams.set("page_number", String(flags.pageNumber)); + } + if (flags.pageSize !== undefined) { + url.searchParams.set("page_size", String(flags.pageSize)); + } + const endpoint = url.toString(); + + if (settings.dryRun) { + emitResult({ endpoint, request: null }, format); + return; + } + + let response: RagIndexJobStatusResponse; + if (flags.wait) { + // Reuse the shared polling (timeout → TIMEOUT(5)); failure detection happens + // uniformly after return, based on per-document status + response = await pollImportJob(ctx.client, settings, { + statusUrl: endpoint, + intervalSec: flags.pollInterval ?? 5, + }); + } else { + response = await ctx.client.requestJson({ + path: endpoint, + method: "GET", + }); + } + + // Any per-document failure means a non-zero exit; the server message is passed through verbatim + if (failedImportDocs(response).length > 0) { + throw new BailianError( + importJobFailureMessage(response, "Import job reported document failures."), + ExitCode.GENERAL, + ); + } + + if (settings.quiet) { + emitBare(response.data?.ingestion_status ?? "UNKNOWN"); + return; + } + if (format === "text") { + printStatus(response); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/knowledge/doc-tag.ts b/packages/commands/src/commands/knowledge/doc-tag.ts new file mode 100644 index 00000000..55a31076 --- /dev/null +++ b/packages/commands/src/commands/knowledge/doc-tag.ts @@ -0,0 +1,88 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagBatchUpdateTagResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const DOC_TAG_FLAGS = { + docId: { + type: "array", + valueHint: "", + description: "Data-center file ID to tag (repeatable, 1-20 per call)", + required: true, + }, + tag: { + type: "array", + valueHint: "", + description: "Tag applied to every --doc-id (repeatable, each up to 32 chars)", + required: true, + }, + mode: { + type: "string", + valueHint: "", + description: "Update mode: append (default) or overwrite", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Batch update tags on data-center files", + auth: "apiKey", + usageArgs: "--doc-id --tag [flags]", + flags: DOC_TAG_FLAGS, + notes: [ + "The same tag set is applied to every --doc-id; run the command multiple times for different tag sets.", + "Server limits: up to 100 tags per file, total tag length up to 700 chars, tag up to 32 chars.", + ], + exampleArgs: [ + "--doc-id file-xxx --tag project-a --tag draft --workspace-id ws-xxx", + "--doc-id file-a --doc-id file-b --tag final --mode overwrite", + ], + validate(flags) { + if (flags.docId.length > 20) return "--doc-id accepts at most 20 ids per call"; + if (flags.mode !== undefined && flags.mode !== "append" && flags.mode !== "overwrite") { + return "--mode must be append or overwrite"; + } + // Hard limits stated by the API contract: each tag ≤32 chars; ≤100 tags per file; total length ≤700 + if (flags.tag.length > 100) return "At most 100 tags per file"; + const overlongTag = flags.tag.find((tag) => tag.length > 32); + if (overlongTag) return `Tag exceeds 32 characters: ${overlongTag}`; + const totalLength = flags.tag.reduce((sum, tag) => sum + tag.length, 0); + if (totalLength > 700) return "Total tag length exceeds 700 characters"; + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + const body = { + fileInfos: flags.docId.map((fileId) => ({ fileId, tags: flags.tag })), + updateMode: (flags.mode ?? "append").toUpperCase(), + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.batchUpdateFileTag); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + if (settings.quiet) return; + if (format === "text") { + emitBare(`tagged: ${flags.docId.length} file(s) with [${flags.tag.join(", ")}]`); + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/doc-upload.ts b/packages/commands/src/commands/knowledge/doc-upload.ts new file mode 100644 index 00000000..a3615f88 --- /dev/null +++ b/packages/commands/src/commands/knowledge/doc-upload.ts @@ -0,0 +1,294 @@ +// Orchestration command: local file → data center → (optional) import into a knowledge base. +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + BailianError, + ExitCode, + type FlagsDef, + type RagUploadLeaseResponse, + type RagAddFileResponse, + type RagJobCreateResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { + resolveWorkspaceId, + WORKSPACE_FLAG, + failedImportDocs, + importJobFailureMessage, + importJobStatus, + importJobStatusUrl, + pollImportJob, + withPartialSuccessHint, +} from "./shared.ts"; +import { checkUploadFile } from "./upload-support.ts"; + +const DOC_UPLOAD_FLAGS = { + file: { + type: "array", + valueHint: "", + description: "Local file path (repeatable). Extension and size validated before upload", + required: true, + }, + indexId: { + type: "string", + valueHint: "", + description: "Import into this knowledge base after registration (one job for all files)", + }, + categoryId: { + type: "string", + valueHint: "", + description: "Target data-center category; defaults to the workspace default category", + }, + tag: { + type: "array", + valueHint: "", + description: "File tag (repeatable), applied to every uploaded file", + }, + wait: { + type: "switch", + description: "Poll the import job to a terminal state (needs --index-id)", + }, + pollInterval: { + type: "number", + valueHint: "", + description: "Polling interval when waiting (default: 5)", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +interface UploadedFile { + path: string; + fileId: string; +} + +export default defineCommand({ + description: "Upload local files to the data center and optionally import into a knowledge base", + auth: "apiKey", + usageArgs: "--file [flags]", + flags: DOC_UPLOAD_FLAGS, + notes: [ + "Pipeline: apply upload lease → PUT to OSS → register file → (with --index-id) create import job.", + "Without --category-id the workspace default category is resolved automatically.", + "Multiple files are processed sequentially; on failure, already-registered file ids are listed in the error hint.", + ], + exampleArgs: [ + "--file ./a.md --workspace-id ws-xxx", + "--file ./a.md --file ./b.pdf --index-id idx-xxx --wait", + ], + validate(flags) { + if (flags.wait && !flags.indexId) return "--wait requires --index-id"; + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // Local pre-flight validation also runs in dry-run (rehearsal semantics: surface + // file problems early); exceeding a soft limit only warns + const checkedFiles = flags.file.map((filePath) => { + const checked = checkUploadFile(filePath); + if (checked.warning) process.stderr.write(`Warning: ${checked.warning}\n`); + return { filePath, sizeBytes: checked.sizeBytes }; + }); + + if (settings.dryRun) { + // dry-run does not read file contents (md5 shown as a placeholder) + const categoryPlaceholder = flags.categoryId ?? "default"; + const steps = checkedFiles.flatMap((checkedFile) => [ + { + step: "applyFileUploadLease", + endpoint: ragEndpoint(workspaceId, RAG_PATHS.applyFileUploadLease), + request: { + category: categoryPlaceholder, + fileName: basename(checkedFile.filePath), + sizeBytes: String(checkedFile.sizeBytes), // gotcha: must be a string + contentMd5: "", + } as unknown, + }, + { + step: "ossPut", + endpoint: "", + request: { method: "PUT", headers: "" } as unknown, + }, + { + step: "addFile", + endpoint: ragEndpoint(workspaceId, RAG_PATHS.addFile), + request: { + leaseId: "", + category: categoryPlaceholder, + parser: "AUTO_SELECT", + ...(flags.tag?.length ? { tags: flags.tag } : {}), + } as unknown, + }, + ]); + if (flags.indexId) { + steps.push({ + step: "createImportJob", + endpoint: ragEndpoint(workspaceId, RAG_PATHS.indexJobCreate), + request: { + indexId: flags.indexId, + // Gotcha (live-verified): job/create requires the nested dataSource shape; + // the public docs' flat documentIds body returns Index.InvalidParameter. + // Omitting sourceType would import the entire data center. + dataSource: { sourceType: "DATA_CENTER_FILE", fileIds: [""] }, + } as unknown, + }); + } + emitResult({ steps }, format); + return; + } + + // Default category: the literal "default" is accepted by lease/addFile + // (verified against the live API), so no listCategory resolution is needed + const categoryId = flags.categoryId ?? "default"; + + // Multiple files run steps 1-3 sequentially (no concurrency in this version, + // to avoid OSS rate-limit complexity) + const uploaded: UploadedFile[] = []; + for (const checkedFile of checkedFiles) { + try { + const fileBuffer = readFileSync(checkedFile.filePath); + const contentMd5 = createHash("md5").update(fileBuffer).digest("base64"); + + // 1) Apply for an upload lease (gotcha: the category parameter is named + // category, not categoryId; sizeBytes must be a string) + const lease = await ctx.client.requestJson({ + path: ragEndpoint(workspaceId, RAG_PATHS.applyFileUploadLease), + method: "POST", + body: { + category: categoryId, + fileName: basename(checkedFile.filePath), + sizeBytes: String(checkedFile.sizeBytes), + contentMd5, + }, + }); + const leaseId = lease.data?.leaseId; + const leaseParam = lease.data?.param; + if (!leaseId || !leaseParam?.url) { + throw new BailianError( + `Upload lease response missing leaseId/url for ${checkedFile.filePath}`, + ExitCode.GENERAL, + ); + } + + // 2) OSS upload: goes to the OSS host, not the DashScope gateway — native fetch without a Bearer header + let ossResponse: Response; + try { + ossResponse = await fetch(leaseParam.url, { + method: leaseParam.method ?? "PUT", + headers: leaseParam.headers, + body: fileBuffer, + }); + } catch (error) { + const causeCode = (error as { cause?: { code?: string } }).cause?.code; + throw new BailianError( + `OSS upload failed for ${basename(checkedFile.filePath)}`, + ExitCode.NETWORK, + causeCode ? `Network error (${causeCode}).` : undefined, + { cause: error }, + ); + } + if (!ossResponse.ok) { + const ossBody = await ossResponse.text().catch(() => ""); + throw new BailianError( + `OSS upload rejected (HTTP ${ossResponse.status}) for ${basename(checkedFile.filePath)}${ossBody ? `: ${ossBody.slice(0, 300)}` : ""}`, + ExitCode.GENERAL, + ); + } + + // 3) Register the file + const added = await ctx.client.requestJson({ + path: ragEndpoint(workspaceId, RAG_PATHS.addFile), + method: "POST", + body: { + leaseId, + category: categoryId, + parser: "AUTO_SELECT", + ...(flags.tag?.length ? { tags: flags.tag } : {}), + }, + }); + const fileId = added.data?.fileId; + if (!fileId) { + throw new BailianError( + `addFile response missing fileId for ${checkedFile.filePath}`, + ExitCode.GENERAL, + ); + } + uploaded.push({ path: checkedFile.filePath, fileId }); + } catch (error) { + // Partial-failure semantics: abort with an error, listing already-registered + // fileIds in the hint (re-uploading is cheap and idempotent) + if (uploaded.length > 0) { + throw withPartialSuccessHint( + error, + `Already registered: ${uploaded.map((item) => item.fileId).join(", ")}`, + ); + } + throw error; + } + } + + // 4) Optional import (merged into a single job after all files are registered) + let ingestionId: string | undefined; + let finalStatus: string | undefined; + if (flags.indexId) { + const job = await ctx.client.requestJson({ + path: ragEndpoint(workspaceId, RAG_PATHS.indexJobCreate), + method: "POST", + body: { + indexId: flags.indexId, + // Live-verified shape: nested dataSource (the docs' flat documentIds is rejected) + dataSource: { + sourceType: "DATA_CENTER_FILE", + fileIds: uploaded.map((item) => item.fileId), + }, + }, + }); + ingestionId = job.data?.ingestionId; + if (flags.wait && ingestionId) { + const statusResponse = await pollImportJob(ctx.client, settings, { + statusUrl: importJobStatusUrl(workspaceId, flags.indexId, ingestionId).toString(), + intervalSec: flags.pollInterval ?? 5, + }); + finalStatus = importJobStatus(statusResponse); + // Job finished but some documents failed to parse → non-zero exit, server message passed through verbatim + if (failedImportDocs(statusResponse).length > 0) { + throw new BailianError( + importJobFailureMessage(statusResponse, "Import job reported document failures."), + ExitCode.GENERAL, + `Registered file ids: ${uploaded.map((item) => item.fileId).join(", ")}`, + ); + } + } + } + + if (settings.quiet) { + for (const item of uploaded) emitBare(item.fileId); + return; + } + if (format === "text") { + for (const item of uploaded) { + emitBare(`${basename(item.path)} ${item.fileId} registered`); + } + if (ingestionId) emitBare(`job: ${ingestionId}`); + if (finalStatus) emitBare(`status: ${finalStatus}`); + return; + } + // An orchestration command has no single response to pass through — emit a custom stable shape + emitResult( + { + files: uploaded.map((item) => ({ path: item.path, fileId: item.fileId })), + ...(flags.indexId ? { index_id: flags.indexId } : {}), + ...(ingestionId ? { ingestion_id: ingestionId } : {}), + ...(finalStatus ? { final_status: finalStatus } : {}), + }, + format, + ); + }, +}); diff --git a/packages/commands/src/commands/knowledge/file-delete.ts b/packages/commands/src/commands/knowledge/file-delete.ts new file mode 100644 index 00000000..d9986ddb --- /dev/null +++ b/packages/commands/src/commands/knowledge/file-delete.ts @@ -0,0 +1,88 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type Client, + type FlagsDef, + type RagConnectorResponse, + type RagDescribeFileResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const FILE_DELETE_FLAGS = { + fileId: { + type: "string", + valueHint: "", + description: "Data-center file ID to delete", + required: true, + }, + yes: { type: "switch", description: "Skip the confirmation prompt" }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +/** Confirmation summary lookup (file name/size); failure degrades to id-only */ +async function buildDeleteSummary( + client: Client, + workspaceId: string, + fileId: string, +): Promise { + let infoPart = ""; + try { + const detail = await client.requestJson({ + path: ragEndpoint(workspaceId, RAG_PATHS.describeFile), + method: "POST", + body: { fileId }, + }); + if (detail.data?.fileName) infoPart = ` name: ${detail.data.fileName}`; + } catch { + // Degrade gracefully: a failed lookup does not block confirmation + } + return `Delete data-center file ${fileId}${infoPart}\nPERMANENT: if the file is referenced by knowledge bases, their document indexes break too. This differs from removing a document from one knowledge base.`; +} + +export default defineCommand({ + description: "Permanently delete a file from the data center", + auth: "apiKey", + usageArgs: "--file-id [flags]", + flags: FILE_DELETE_FLAGS, + notes: [ + "Irreversible. If knowledge bases reference this file, their related document indexes become invalid.", + "To remove a document from a single knowledge base only, use the document delete command instead.", + ], + exampleArgs: ["--file-id file-xxx --workspace-id ws-xxx", "--file-id file-xxx --yes"], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + const body = { fileId: flags.fileId }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteFile); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const summary = flags.yes + ? "" + : await buildDeleteSummary(ctx.client, workspaceId, flags.fileId); + await confirmDangerousAction(summary, flags.yes ?? false); + + const response = await ctx.client.requestJson< + RagConnectorResponse | undefined> + >({ + path: endpoint, + method: "POST", + body, + }); + + if (settings.quiet) return; + if (format === "text") { + emitBare(`deleted: ${flags.fileId}`); + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/file-get.ts b/packages/commands/src/commands/knowledge/file-get.ts new file mode 100644 index 00000000..d52a05fb --- /dev/null +++ b/packages/commands/src/commands/knowledge/file-get.ts @@ -0,0 +1,63 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagDescribeFileResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const FILE_GET_FLAGS = { + fileId: { + type: "string", + valueHint: "", + description: "Data-center file ID", + required: true, + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Show data-center file details (size, MD5, tags, timestamps)", + auth: "apiKey", + usageArgs: "--file-id [flags]", + flags: FILE_GET_FLAGS, + exampleArgs: ["--file-id file-xxx --workspace-id ws-xxx"], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + const body = { fileId: flags.fileId }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.describeFile); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + const file = response.data; + if (settings.quiet || format !== "text") { + emitResult(response, format === "text" ? "json" : format); + return; + } + emitBare(`id: ${file?.fileId ?? "-"}`); + emitBare(`name: ${file?.fileName ?? "-"}`); + emitBare(`type: ${file?.fileType ?? "-"}`); + emitBare(`size: ${file?.sizeBytes ?? "-"}`); + emitBare(`status: ${file?.status ?? "-"}`); + emitBare(`parser: ${file?.parser ?? "-"}`); + emitBare(`category: ${file?.category ?? "-"}`); + emitBare(`uploaded: ${file?.uploadTime ?? "-"}`); + const tags = Array.isArray(file?.tags) ? file.tags.join(", ") : (file?.tags ?? "-"); + emitBare(`tags: ${tags || "-"}`); + }, +}); diff --git a/packages/commands/src/commands/knowledge/file-list.ts b/packages/commands/src/commands/knowledge/file-list.ts new file mode 100644 index 00000000..af40e650 --- /dev/null +++ b/packages/commands/src/commands/knowledge/file-list.ts @@ -0,0 +1,103 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagListFileResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts"; + +const FILE_LIST_FLAGS = { + categoryId: { + type: "string", + valueHint: "", + description: "Category to list (find ids via the category list command)", + required: true, + }, + name: { + type: "string", + valueHint: "", + description: "Filter by file name", + }, + fileId: { + type: "array", + valueHint: "", + description: "Filter by file ID (repeatable)", + }, + nextToken: { + type: "string", + valueHint: "", + description: "Cursor for the next page (from previous output)", + }, + maxResult: { + type: "number", + valueHint: "", + description: "Items per page", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "List files in a data-center category", + auth: "apiKey", + usageArgs: "--category-id [flags]", + flags: FILE_LIST_FLAGS, + notes: [ + "The server requires a real category id here — unlike upload APIs, the literal default is NOT resolved (returns an empty list). Find the id via file details of any uploaded file, or the category list command.", + "Pagination is cursor-based: reuse the printed next token to continue.", + ], + exampleArgs: [ + "--category-id cate-xxx --workspace-id ws-xxx", + "--category-id cate-xxx --name report", + ], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + const body = { + categoryId: flags.categoryId, + ...(flags.name ? { fileName: flags.name } : {}), + ...(flags.fileId?.length ? { fileIds: flags.fileId } : {}), + ...(flags.nextToken ? { nextToken: flags.nextToken } : {}), + ...(flags.maxResult !== undefined ? { maxResult: flags.maxResult } : {}), + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listFile); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + const files = response.data?.fileList ?? []; + if (settings.quiet) { + for (const file of files) emitBare(file.fileId ?? ""); + return; + } + if (format === "text") { + if (files.length === 0) { + emitBare("No files found."); + } else { + for (const file of files) { + emitBare( + truncateLine( + [file.fileId, file.status ?? "-", file.fileName, file.sizeBytes ?? "-"].join(" "), + ), + ); + } + } + const nextToken = response.data?.nextToken; + if (nextToken) emitBare(`next: --next-token ${nextToken}`); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/knowledge/kb-create.ts b/packages/commands/src/commands/knowledge/kb-create.ts new file mode 100644 index 00000000..873cca09 --- /dev/null +++ b/packages/commands/src/commands/knowledge/kb-create.ts @@ -0,0 +1,165 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + BailianError, + ExitCode, + type FlagsDef, + type RagCreateIndexV2Response, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { + resolveWorkspaceId, + WORKSPACE_FLAG, + failedImportDocs, + importJobFailureMessage, + importJobStatus, + importJobStatusUrl, + pollImportJob, +} from "./shared.ts"; + +const KB_CREATE_FLAGS = { + name: { + type: "string", + valueHint: "", + description: "Knowledge base name (1-20 chars, unique in workspace)", + required: true, + }, + docId: { + type: "array", + valueHint: "", + description: + "Data-center file id to import (repeatable); mutually exclusive with --category-id", + }, + categoryId: { + type: "array", + valueHint: "", + description: + "Import every file under this category (repeatable); mutually exclusive with --doc-id", + }, + embeddingModel: { + type: "string", + valueHint: "", + description: "Embedding model name (default: text-embedding-v4)", + }, + chunkSize: { + type: "number", + valueHint: "", + description: "Chunk size in characters (default: 600, recommended 300-800)", + }, + wait: { type: "switch", description: "Poll the initial import job to a terminal state" }, + pollInterval: { + type: "number", + valueHint: "", + description: "Polling interval when waiting (default: 5)", + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +/** sourceType/docIds/categoryIds derivation, centralized for unit testing (gotcha: the parameter is docIds, not fileIds) */ +export function buildDataSourceFields(flags: { docId?: string[]; categoryId?: string[] }): { + sourceType: string; + docIds?: string[]; + categoryIds?: string[]; + dataSources: Array<{ sourceType: string }>; +} { + if (flags.docId?.length) { + return { + sourceType: "DATA_CENTER_FILE", + docIds: flags.docId, + dataSources: [{ sourceType: "DATA_CENTER_FILE" }], + }; + } + return { + sourceType: "DATA_CENTER_CATEGORY", + categoryIds: flags.categoryId, + dataSources: [{ sourceType: "DATA_CENTER_CATEGORY" }], + }; +} + +export default defineCommand({ + description: "Create a knowledge base and import data-center files or categories", + auth: "apiKey", + usageArgs: "--name (--doc-id | --category-id ) [flags]", + flags: KB_CREATE_FLAGS, + notes: [ + "Structure/sink types are fixed to the default document knowledge base (unstructured, BUILT_IN storage).", + "Returns the knowledge base id (pipelineId) and the initial import job id (ingestionId).", + "Use the import job status command (or --wait) to track the initial import.", + ], + exampleArgs: [ + "--name demo --doc-id file-xxx --workspace-id ws-xxx", + "--name demo --category-id cate-xxx --wait", + ], + validate(flags) { + if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters"; + const hasDocIds = !!flags.docId?.length; + const hasCategoryIds = !!flags.categoryId?.length; + if (hasDocIds && hasCategoryIds) return "Use either --doc-id or --category-id, not both"; + if (!hasDocIds && !hasCategoryIds) + return "Provide --doc-id or --category-id as the data source"; + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // Fixed values, not exposed as flags in this version: structureType unstructured, sinkType BUILT_IN. + // Note: the public docs' example uses sinkType DEFAULT, but BUILT_IN is what works against the live API. + const body = { + name: flags.name, + structureType: "unstructured", + sinkType: "BUILT_IN", + embeddingModelName: flags.embeddingModel ?? "text-embedding-v4", + chunkSize: flags.chunkSize ?? 600, + ...buildDataSourceFields(flags), + }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexCreateV2); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + const pipelineId = response.data?.pipelineId; + const ingestionId = response.data?.ingestionId; + + let finalStatus: string | undefined; + if (flags.wait && pipelineId && ingestionId) { + const statusResponse = await pollImportJob(ctx.client, settings, { + statusUrl: importJobStatusUrl(workspaceId, pipelineId, ingestionId).toString(), + intervalSec: flags.pollInterval ?? 5, + }); + finalStatus = importJobStatus(statusResponse); + // Job finished but some documents failed to parse → non-zero exit, server message + // passed through verbatim (the knowledge base was created; its id goes in the hint) + if (failedImportDocs(statusResponse).length > 0) { + throw new BailianError( + importJobFailureMessage(statusResponse, "Initial import reported document failures."), + ExitCode.GENERAL, + `Knowledge base created: ${pipelineId}`, + ); + } + } + + if (settings.quiet) { + emitBare(pipelineId ?? ""); + return; + } + if (format === "text") { + emitBare(`index_id: ${pipelineId ?? "-"}`); + if (ingestionId) emitBare(`ingestion_id: ${ingestionId}`); + if (finalStatus) emitBare(`status: ${finalStatus}`); + emitBare("Next: check the import job status, then search against this knowledge base."); + return; + } + emitResult(finalStatus ? { ...response, final_status: finalStatus } : response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/kb-delete.ts b/packages/commands/src/commands/knowledge/kb-delete.ts new file mode 100644 index 00000000..2f9ddb5e --- /dev/null +++ b/packages/commands/src/commands/knowledge/kb-delete.ts @@ -0,0 +1,98 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagIndexFilesResponse, + type RagMutationResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; +import { fetchIndexDetail } from "./kb-info.ts"; + +const KB_DELETE_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + yes: { type: "switch", description: "Skip the confirmation prompt" }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +/** Confirmation summary lookup: name + document count; any lookup failure degrades to id-only (never blocks deletion) */ +async function buildDeleteSummary( + ctx: { client: Parameters[0] }, + workspaceId: string, + indexId: string, +): Promise { + let namePart = ""; + let docCountPart = ""; + try { + const detail = await fetchIndexDetail(ctx.client, workspaceId, indexId); + namePart = ` name: ${detail.name}`; + } catch { + // Degrade gracefully: a missing name does not block confirmation + } + try { + const filesUrl = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles)); + filesUrl.searchParams.set("index_id", indexId); + filesUrl.searchParams.set("page_num", "1"); + filesUrl.searchParams.set("page_size", "1"); + const files = await ctx.client.requestJson({ + path: filesUrl.toString(), + method: "GET", + }); + const totalCount = files.data?.total_count; + if (typeof totalCount === "number") docCountPart = ` documents: ${totalCount}`; + } catch { + // Same graceful degradation as above + } + return `Delete knowledge base ${indexId}${namePart}${docCountPart}\nThis permanently removes the knowledge base with all documents and chunks. It cannot be undone.`; +} + +export default defineCommand({ + description: "Delete a knowledge base with all its documents and chunks", + auth: "apiKey", + usageArgs: "--index-id [flags]", + flags: KB_DELETE_FLAGS, + notes: [ + "Irreversible — the knowledge base and all indexed content are permanently removed.", + "Files in the data center are not affected; only the knowledge base index is deleted.", + ], + exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx", "--index-id idx-xxx --yes"], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // This endpoint is back to snake_case: body { index_id } + const body = { index_id: flags.indexId }; + const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDelete); + + if (settings.dryRun) { + emitResult({ endpoint, request: body }, format); + return; + } + + const summary = flags.yes + ? "" // --yes bypasses the prompt, so skip the summary lookups + : await buildDeleteSummary(ctx, workspaceId, flags.indexId); + await confirmDangerousAction(summary, flags.yes ?? false); + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + + if (settings.quiet) return; + if (format === "text") { + emitBare(`deleted: ${flags.indexId}`); + return; + } + emitResult(response, format); + }, +}); diff --git a/packages/commands/src/commands/knowledge/kb-info.ts b/packages/commands/src/commands/knowledge/kb-info.ts new file mode 100644 index 00000000..81fc5c4d --- /dev/null +++ b/packages/commands/src/commands/knowledge/kb-info.ts @@ -0,0 +1,126 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + BailianError, + ExitCode, + type Client, + type FlagsDef, + type RagIndexListResponse, + type RagIndexRow, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const KB_INFO_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +function indexListUrl(workspaceId: string, pageNumber: number): string { + const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexList)); + url.searchParams.set("page_number", String(pageNumber)); + url.searchParams.set("page_size", "100"); + return url.toString(); +} + +/** + * There is no dedicated detail API on the server, so fall back to paging + * through index/list. If a detail API ships, only this function changes. + * Also reused by kb delete for its confirmation summary. + */ +export async function fetchIndexDetail( + client: Client, + workspaceId: string, + indexId: string, +): Promise { + const maxPages = 10; + for (let pageNumber = 1; pageNumber <= maxPages; pageNumber++) { + const response = await client.requestJson({ + path: indexListUrl(workspaceId, pageNumber), + method: "GET", + }); + const rows = response.data?.rows ?? []; + const match = rows.find((row) => row.id === indexId); + if (match) return match; + if (rows.length < 100) break; // reached the last page + } + throw new BailianError( + `Knowledge base not found: ${indexId}`, + ExitCode.GENERAL, + "Check the id — list knowledge bases in this workspace to verify it.", + ); +} + +function formatField(label: string, value: string | number | boolean | null | undefined): string { + return ` ${label}: ${value ?? "-"}`; +} + +export default defineCommand({ + description: "Show knowledge base configuration details", + auth: "apiKey", + usageArgs: "--index-id [flags]", + flags: KB_INFO_FLAGS, + notes: [ + "No dedicated detail API on the server yet — falls back to paginating the index list (up to 10 pages of 100).", + "Indexing settings are immutable; changing them requires recreating the knowledge base.", + ], + exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx"], + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + if (settings.dryRun) { + emitResult( + { + endpoint: indexListUrl(workspaceId, 1), + request: null, + strategy: + "paginate index/list (page_size=100, up to 10 pages) to find --index-id; no dedicated detail API", + }, + format, + ); + return; + } + + const row = await fetchIndexDetail(ctx.client, workspaceId, flags.indexId); + + if (settings.quiet || format !== "text") { + emitResult(row, format === "text" ? "json" : format); + return; + } + + // Grouped by diagnostic concern; the immutable annotation on Indexing tells + // users which settings require recreating the knowledge base + emitBare("Basic:"); + emitBare(formatField("id", row.id)); + emitBare(formatField("name", row.name)); + emitBare(formatField("description", row.description)); + emitBare(formatField("dataType", row.dataType)); + emitBare("Indexing: [immutable — recreate required to change]"); + emitBare(formatField("embeddingModelName", row.embeddingModelName)); + emitBare(formatField("embeddingDimension", row.embeddingDimension)); + emitBare(formatField("chunkSize", row.chunkSize)); + emitBare(formatField("overlapSize", row.overlapSize)); + emitBare(formatField("chunkMode", row.chunkMode)); + emitBare(formatField("separator", row.separator)); + emitBare("Retrieval:"); + emitBare(formatField("rerankModelName", row.rerankModelName)); + emitBare(formatField("rerankMinScore", row.rerankMinScore)); + emitBare(formatField("rerankTopN", row.rerankTopN)); + emitBare(formatField("rerankMode", row.rerankMode)); + emitBare(formatField("enableRewrite", row.enableRewrite)); + emitBare(formatField("denseSimilarityTopK", row.denseSimilarityTopK)); + emitBare(formatField("sparseSimilarityTopK", row.sparseSimilarityTopK)); + emitBare("Data:"); + emitBare(formatField("sourceType", row.sourceType)); + emitBare(formatField("connectorId", row.connectorId)); + }, +}); diff --git a/packages/commands/src/commands/knowledge/kb-list.ts b/packages/commands/src/commands/knowledge/kb-list.ts new file mode 100644 index 00000000..8e322dfd --- /dev/null +++ b/packages/commands/src/commands/knowledge/kb-list.ts @@ -0,0 +1,92 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + type FlagsDef, + type RagIndexListResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts"; + +const KB_LIST_FLAGS = { + name: { + type: "string", + valueHint: "", + description: "Filter by knowledge base name (fuzzy match, 1-20 chars)", + }, + ...PAGE_FLAGS, + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: "List knowledge bases in the workspace", + auth: "apiKey", + usageArgs: "[flags]", + flags: KB_LIST_FLAGS, + notes: [ + "Auth: uses DashScope API Key (Bearer token).", + "`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or config workspace_id.", + "Use the returned id as --index-id in knowledge base / document management commands.", + ], + exampleArgs: ["--workspace-id ws-xxx", "--name demo --page-number 2 --page-size 50"], + validate(flags) { + if (flags.name !== undefined && (flags.name.length < 1 || flags.name.length > 20)) { + return "--name must be 1-20 characters"; + } + if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) { + return "--page-size must be between 1 and 100"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const workspaceId = resolveWorkspaceId(ctx); + const format = detectOutputFormat(settings.output); + + // Pagination and filter parameters must go in the query string — the server ignores them in the body + const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexList)); + if (flags.name) url.searchParams.set("pipeline_name", flags.name); + url.searchParams.set("page_number", String(flags.pageNumber ?? 1)); + url.searchParams.set("page_size", String(flags.pageSize ?? 20)); + const endpoint = url.toString(); + + if (settings.dryRun) { + emitResult({ endpoint, request: null }, format); + return; + } + + const response = await ctx.client.requestJson({ + path: endpoint, + method: "GET", + }); + + const rows = response.data?.rows ?? []; + if (settings.quiet) { + for (const row of rows) emitBare(row.id); + return; + } + if (format === "text") { + if (rows.length === 0) { + emitBare("No knowledge bases found."); + } else { + for (const row of rows) { + emitBare( + truncateLine( + [ + row.id, + row.name, + row.embeddingModelName ?? "-", + row.chunkSize ?? "-", + row.description ?? "", + ].join(" "), + ), + ); + } + } + emitBare(`total: ${response.data?.total ?? rows.length}`); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/knowledge/kb-stats.ts b/packages/commands/src/commands/knowledge/kb-stats.ts new file mode 100644 index 00000000..275d6519 --- /dev/null +++ b/packages/commands/src/commands/knowledge/kb-stats.ts @@ -0,0 +1,103 @@ +import { + defineCommand, + ragEndpoint, + RAG_PATHS, + detectOutputFormat, + BailianError, + ExitCode, + type FlagsDef, + type RagMonitorResponse, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const KB_STATS_FLAGS = { + indexId: { + type: "string", + valueHint: "", + description: "Knowledge base ID", + required: true, + }, + start: { + type: "string", + valueHint: "