diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9494c0..ba478a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。 - **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。 - **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。 +- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`/` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。 ## [1.0.58-beta.1] - 2026-08-07 diff --git a/docs/pr-860-multi-im-optimization-analysis.md b/docs/pr-860-multi-im-optimization-analysis.md new file mode 100644 index 00000000..7439250d --- /dev/null +++ b/docs/pr-860-multi-im-optimization-analysis.md @@ -0,0 +1,1716 @@ +# PR #860 Multi IM 优化:合并后完整代码分析与跨场景复用报告 + +> PR #860:[DingTalk-Real-AI/dingtalk-workspace-cli#860](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pull/860) +> +> #860 当前标题:`feat(im): harden Multi IM and publish complete Chat Schema` +> +> #860 当前状态:Open、可合并;核对时共 14 个提交、129 个变更文件,+69,391 / -13,045。 +> +> 唯一分析范围:`main@6376f294dab0d0a7bd2f5904077aab89383684d1...codex/multi-im-optimization@33ceab6000d5f18eddbc1c0c97544233f5dd42d6`。 +> +> 报告原则:只分析当前 PR #860 的合并后整体代码。提交来源和中间分支不再作为独立分析单元;所有规模、架构、文件、风险和复用结论均以当前 #860 的完整 diff 为准。 + +## 1. 核心优化思路 + +### 1.1 一句话结论 + +PR #860 的本质不是增加一批 IM 命令,而是把 Chat/Event 能力从“分散、可调用”升级为“意图可路由、目标可确定、执行有边界、输出有契约、风险可确认、能力可发现、文档与代码可持续校验”的完整产品链路。 + +整体优化链路可以概括为: + +```text +用户意图 + -> Golden Route 唯一入口 + -> Typed Resolver 确定目标 + -> Identity / Capability Preflight + -> Bounded Runtime + Typed Confirmation + -> Versioned Result / Error Ledger + -> Authored Registry + Selection/Metadata + -> Generated Schema + Skill/Reference + -> Policy + Drift + Coverage Gates +``` + +### 1.2 核心方法总览 + +| 原有问题 | 核心优化方法 | PR #860 整体落地 | 可复用价值 | +|---|---|---|---| +| 同一意图有多条相似入口 | **Golden Route**:每个意图只推荐一个默认入口 | 统一发送、回复、读取、搜索、建群和监听入口,原始命令只作有理由的 fallback | 通讯录、文档等领域都可先按意图收敛入口 | +| 名称、ID、URL 等目标解析分散 | **Typed Resolver**:业务执行前先得到规范化目标 | 新增 `targetresolver`,全分页检索、精确匹配、歧义和不完整结果均 fail closed | 可扩展为 `DirectoryResolver`、`DocumentTargetResolver` | +| 调用前才发现参数、身份或能力不支持 | **Local Validation + Capability Check** | 建立 user/bot/webhook 能力矩阵,并前置校验时间、页大小、回溯窗口和目标互斥 | 适用于邀请、删除、文档覆盖、权限修改等写操作 | +| 输出依赖原始 API 或展示文本 | **Versioned Contract**:输出稳定的机器契约 | 建立并发布 `im.message-list.v1`,统一消息、引用、资源、reaction 和 continuation | 可定义联系人、文档、云盘的 v1 投影 | +| 分页、导出、下载可能截断或留下半成品 | **Bounded Execution**:限制页数、条数、超时并支持原子提交 | 实现游标停滞检测、完整性账本、安全下载、no-clobber 和跨平台原子替换 | 适用于批量导出、备份、迁移和同步 | +| 高风险命令示例可能绕过确认 | **Typed Confirmation**:Runtime 强制停下,确认后才执行 | 写入/破坏型 Schema metadata 与 Runtime gate 对齐,危险示例不预填 `--yes` | 可用于删除联系人、覆盖文档、权限变更等高风险动作 | +| 能力可运行但 Agent 不可发现 | **Authored Registry + Generated Schema** | Chat Shortcut 达到 97/97;完整 Catalog 为 26 products / 898 tools | 适用于任何具有大量组合命令的产品域 | +| 文档、Schema、Runtime 各自演化 | **Consistency Gates**:把一致性变成 CI 规则 | 增加 Skill Chain、Schema compatibility、interface integrity、generated drift 和 changed-code coverage | 可抽象为所有领域共用的质量框架 | + +### 1.3 八个可复用的优化原则 + +#### 原则一:从“API 优先”转为“意图优先” + +先定义用户究竟要完成什么,再为每个意图选择唯一 Golden Route。底层 API、历史命令和兼容别名可以保留,但不应继续出现在默认发现路径中。这样能够显著减少 Agent 在多个相似工具之间随机选择的问题。 + +#### 原则二:把目标解析变成独立基础设施 + +所有操作先经过统一 Resolver,将名称、ID、手机号、URL 等输入转换为类型明确的 canonical target。业务命令只消费解析结果,不再各自实现搜索、歧义处理或 ID 猜测。 + +#### 原则三:把写操作拆成“计划”和“执行” + +执行前明确目标、身份、能力、副作用和恢复方式。高风险操作只有在目标唯一且能力满足时才进入执行阶段。这种拆分既降低误操作风险,也让 dry-run、确认和审计更容易实现。 + +#### 原则四:用版本化域模型隔离上游变化 + +列表、搜索、读取和写回执都投影到稳定的领域契约。调用方依赖 `messageId`、`canonicalId`、`nextCursor` 等规范字段,而不是供应商原始响应或终端展示文字。 + +#### 原则五:所有长流程都必须有边界和恢复语义 + +分页、导出、下载、批量发送不能只保证 happy path;还要定义最大页数、最大条数、游标推进、临时文件、原子替换、checkpoint、部分失败和幂等重试。 + +#### 原则六:让错误直接驱动下一步动作 + +错误需要区分未命中、歧义、身份不匹配、能力不足、权限不足、限流和服务端故障,并明确是否可重试以及建议动作。对 Agent 而言,错误是控制流的一部分,而非日志文本。 + +#### 原则七:把知识一致性纳入工程门禁 + +Skill 负责路由,Reference 负责细节,Schema 负责机器发现,Runtime 负责真实行为,Policy 负责检查四者闭环。任何新增能力都应同步更新并由测试验证。 + +#### 原则八:把“正式发布”定义为独立交付阶段 + +Runtime 中存在一个命令,不等于 Agent 已经能够正确使用它。能力还必须进入 authored Registry,具备 use/avoid hints、风险和确认 metadata,生成 Schema/Agent metadata,并退出临时 exclusion。PR #860 将这一原则具体化为 Chat 97/97、public Shortcut 265/265 和完整 Catalog 898 tools。 + +### 1.4 标准执行链路 + +```text +用户自然语言意图 + | + v +Golden Route:选择唯一推荐入口 + | + v +Typed Resolver:解析用户、群、会话、消息等目标 + | + v +Preflight Plan:固化 profile、身份、能力和副作用 + | + v +Bounded Runtime:在分页、超时、数量和文件边界内执行 + | + v +Versioned Result / Typed Error Ledger:返回可消费、可恢复结果 + | + v +Authored Registry + Selection/Metadata:正式发布并指导选择 + | + v +Generated Schema + Skill/Reference + Policy:持续防止契约漂移 +``` + +### 1.5 复用时应迁移整条链路 + +不建议只复制某个 shortcut 或 Resolver。若新领域仍缺少稳定输出、错误分类和一致性门禁,局部复用很快会重新产生分散实现。通讯录、文档、云盘、知识库等场景应至少成组迁移以下能力: + +```text +意图清单 + Golden Route + -> 统一 Resolver + -> Plan / Capability Check + -> 有界 Runtime + -> 版本化结果与结构化错误 + -> 分层文档、Schema 与 Policy +``` + +## 2. 分析范围与阅读导航 + +### 2.1 变更规模与边界 + +本报告只使用一个三点对比范围:`6376f294...33ceab60`。它表示 PR #860 当前 head 相对当前 PR base 的完整净变化: + +| 指标 | 当前 PR #860 | +|---|---:| +| 状态 | Open / mergeable | +| 提交数 | 14 | +| 变更文件 | 129 | +| 新增 / 删除 | +69,391 / -13,045 | +| 生产 Go 文件 | 38(+4,028 / -737) | +| Go 测试文件 | 30(+4,343 / -88) | +| Skill Markdown | 30(+1,166 / -1,577) | +| 主要生成 Schema 文件 | 6(+57,215 / -9,229) | + +#860 变更横跨: + +- Runtime:`internal/app`、`internal/shortcut`、`internal/helpers`、`internal/errors`。 +- 目标解析:新增 `internal/shortcut/targetresolver`。 +- Schema:chat/event 的 catalog、agent metadata、hints、registry。 +- Skill:mono、chat、event、shared 四类文档链路。 +- Policy:新增 Multi IM Skill Chain 检查并扩展上下文预算检查。 +- 测试:单元测试、契约测试、mock MCP smoke test、live audit 辅助脚本。 + +这种范围说明优化目标不是“让某几个命令能跑”,而是建立可持续扩展的产品契约。 + +69,391 行新增中,大量内容来自 Schema catalog 和 Agent metadata 等生成物,不能把总行数等同于手写业务逻辑。人工设计变更主要集中在: + +- Chat Shortcut 的 authored CommandRegistry、selection hints 和风险 metadata; +- 本地参数校验、确认门禁、cursor 修复、shorthand 和跨平台路径; +- Schema 完整性、目标消歧、分页、原子文件及跨平台 changed-code 覆盖测试; +- Chat、Event、Shared 和 Mono 四层文档链路重构。 + +### 2.2 文档阅读导航 + +| 阅读目标 | 建议章节 | +|---|---| +| 快速掌握本次优化的方法论 | 第 1 章“核心优化思路” | +| 理解为什么需要改造 | 第 3 章“优化前的典型问题” | +| 理解 Skill、Runtime、Schema、Policy 如何协同 | 第 4 章“总体架构” | +| 查看具体代码机制 | 第 5 章“核心代码优化分析” | +| 查看文档组织变化 | 第 6~8 章 | +| 查看验证方法与质量门禁 | 第 9 章 | +| 复用到通讯录、文档、云盘和知识库 | 第 10~12 章 | +| 查看当前风险收口和整体结论 | 第 13~14 章 | +| 查看 Schema 发布、Runtime 收口和文件分类 | 第 15~18 章 | +| 查看 Go 生产代码和测试代码的专项总结 | 第 19 章 | +| 查看当前 #860 的整体价值与建议 | 第 20~21 章 | + +### 2.3 结论类型说明 + +为避免把“PR 已实现内容”和“建议复用方案”混在一起,后文按以下方式理解: + +- **本次落地**:能够在该 PR 的代码、Schema 或文档变更中找到直接依据。 +- **分析判断**:根据多处变更归纳出的设计意图或工程影响。 +- **复用建议**:面向通讯录、文档等新领域给出的迁移设计,并非声称已在本 PR 中实现。 + +## 3. 优化前的典型问题 + +### 3.1 同一意图存在多条入口 + +发送消息、查看群消息、回复消息等意图可能分别出现在 shortcut、底层 OpenAPI 命令、脚本和多份教程中。模型或用户容易选中功能相似但约束不同的路径,导致参数漂移、行为差异和排障困难。 + +### 3.2 目标解析散落在业务命令中 + +用户可能输入 conversation ID、openConversationId、群名、用户 ID、手机号或自然语言名称。若每条命令各自解析,就会出现: + +- 同一个名称在不同命令中命中不同对象; +- 多结果时有的命令报错、有的静默选第一个; +- ID 类型混用; +- profile、tenant 或身份上下文不一致; +- 新增一种目标类型时需要修改大量命令。 + +### 3.3 列表输出面向人而非机器 + +原始 API 数据结构大而不稳定,字段含义不统一,分页元信息可能丢失。Agent 需要猜测正文、发送者、消息 ID 和时间字段,后续回复、导出和审计难以可靠衔接。 + +### 3.4 长流程缺少完整性边界 + +消息列表、成员列表、导出、下载等操作天然包含分页或文件写入。如果没有最大页数、最大条数、下一页游标、临时文件和原子替换机制,容易发生无限翻页、半文件、重复结果或悄然截断。 + +### 3.5 文档与 Runtime 独立演化 + +命令已经变更但文档仍指向旧路径,或者 Skill 声称支持某个字段而 Runtime 没有对应输出。问题往往到真实 Agent 调用时才暴露。 + +## 4. 总体架构:四层协同 + +### 4.1 Skill 层:告诉 Agent 应该走哪条路 + +Skill 的职责不是罗列所有命令,而是完成意图路由、默认值选择、风险提示和按需加载 Reference。它应该短、稳定、偏决策,不应复制完整 API 手册。 + +### 4.2 Runtime 层:保证行为确定且可测试 + +Runtime 负责统一解析目标、执行能力校验、分页、结果投影、原子文件操作和结构化错误。即使调用者没有阅读文档,Runtime 也应保持安全边界。 + +### 4.3 Schema 层:提供机器可发现的真实能力 + +Schema catalog、agent metadata、selection hints 和 command registry 必须共同描述参数、枚举、返回值和入口选择。Schema 不是生成物附件,而是 Agent 运行时能力的一部分。 + +### 4.4 Policy 层:防止三层再次漂移 + +Policy 检查关键意图是否同时存在合法 Runtime 路由、Schema 暴露和文档说明,并控制 Skill 的上下文体积。它把系统设计转化为可持续执行的合并门槛。 + +## 5. 核心代码优化分析 + +本章按照“目标识别—路由决策—读取契约—长流程执行—错误与兼容治理”的顺序展开。各项机制之间不是孤立关系:Resolver 提供可靠目标,身份/能力矩阵选择执行路径,版本化投影稳定结果,分页与原子写保证完整性,错误和 profile 契约负责失败恢复与上下文一致性。 + +| 分析主题 | 主要代码区域 | 解决的问题 | +|---|---|---| +| Target Resolver | `internal/shortcut/targetresolver` | 统一名称、ID 和自然语言目标解析 | +| 身份与能力路由 | `internal/shortcut/chat/*contract.go`、`unified_send.go` | 在执行前选择合法发送身份和路径 | +| 消息投影 | `chat_message.go`、`smart/chat_messages.go` | 将异构响应转换为稳定消息模型 | +| 分页与导出 | conversation/message/group/export 相关实现 | 防止静默截断、无限翻页和半成品文件 | +| 事件监听 | `internal/app/event_listen_im.go` | 将 IM 事件封装为显式生命周期能力 | +| 错误与上下文 | `internal/errors`、failure classifier、`profilectx` | 提供可行动错误并防止跨 profile 漂移 | +| 兼容治理 | 参数别名、Schema metadata、selection hints | 保持旧调用可用,同时引导新调用走 Golden Route | + +### 5.1 统一 Target Resolver + +新增的 `internal/shortcut/targetresolver/resolver.go` 将目标识别从各业务命令抽离。Resolver 的价值在于把“输入是什么”与“接下来执行什么业务”分开。 + +统一解析应遵循如下优先级: + +1. 显式类型化 ID 优先,避免把合法 ID 当名称搜索。 +2. 显式 target kind 优先于启发式判断。 +3. 自然语言名称进入搜索,并保留候选集。 +4. 唯一命中才自动继续;多结果返回可选择候选。 +5. 未命中返回稳定错误码和建议动作。 +6. 解析结果携带 profile、tenant、来源和置信信息。 + +这样做带来三个直接收益: + +- chat、send、history、members 等命令共享完全一致的目标语义; +- 新增手机号、邮箱、unionId 等输入方式时只扩展 Resolver; +- 解析阶段可以独立测试,不需要触发真实写操作。 + +#### 向通讯录迁移 + +可建立 `DirectoryResolver`: + +```text +输入:姓名 / 手机号 / 邮箱 / userId / unionId / 部门名 / deptId +输出:TypedDirectoryTarget + kind: user | department + canonicalId + displayName + matchedBy + candidates + profileContext +``` + +删除、调岗、批量邀请等高风险动作只接受唯一、类型确定的解析结果。 + +#### 向文档迁移 + +可建立 `DocumentTargetResolver`: + +```text +输入:URL / docId / workspaceId / spaceId / 标题 / 路径 +输出:TypedDocumentTarget + product: doc | drive | wiki + resourceType + canonicalId + containerId + matchedBy + candidates +``` + +这可消除文档 URL、知识库节点 ID、云盘文件 ID 混用造成的问题。 + +### 5.2 Unified Send 与身份/能力矩阵 + +`unified_send.go`、`identity_contract.go` 和 `im_contract.go` 将发送动作从“按 API 名称选命令”提升为“按目标、身份与消息能力选择执行器”。 + +推荐的决策维度包括: + +| 维度 | 示例 | +|---|---| +| 目标 | 单聊、群聊、机器人会话 | +| 身份 | 用户身份、机器人身份、应用身份 | +| 内容 | text、markdown、card、media | +| 能力 | 是否允许主动发送、是否支持回复、是否支持卡片更新 | +| 上下文 | profile、tenant、conversation | + +这种能力矩阵比在每条命令内写条件分支更易审计。发生能力不匹配时,应在真正发起网络请求前失败,并返回建议使用的 route。 + +#### 可复用原则 + +通讯录中的“读取、邀请、修改、删除”,文档中的“读取、创建、编辑、分享、删除”,都应建立身份与能力矩阵。调用者表达的是业务意图,Runtime 决定具体执行入口。 + +### 5.3 `im.message-list.v1` 消息投影 + +消息列表相关代码将上游异构响应投影成稳定的版本化结构。核心思想是:保留原始信息的同时,提供统一的机器字段。 + +典型规范字段包括: + +- `messageId` +- `conversationId` +- `senderId` / `senderName` +- `messageType` +- `text` 或规范化内容 +- `createdAt` +- `replyToMessageId` +- `raw`(必要时用于诊断) +- `hasMore` / `nextCursor` + +版本号的意义是允许未来增加或调整字段,而不破坏依赖 v1 的 Agent 与脚本。 + +#### 可复用原则 + +- 通讯录:定义 `directory.user-list.v1`、`directory.department-list.v1`。 +- 文档:定义 `document.search-result.v1`、`document.block-list.v1`。 +- 云盘:定义 `drive.file-list.v1`、`drive.transfer-result.v1`。 + +不要让自动化直接依赖供应商原始响应;应通过投影层建立稳定域模型。 + +### 5.4 分页完整性与有界执行 + +PR 统一增强了消息、会话和成员列表的分页处理。一个健壮的列表命令至少需要: + +- 明确单页与自动翻页模式; +- 返回 `hasMore` 和 `nextCursor`; +- 支持 `maxPages`、`maxItems` 等硬边界; +- 检测游标不前进或循环; +- 去重并保持稳定顺序; +- 明确结果是完整集合还是截断集合; +- 在中途失败时返回已完成量和失败位置。 + +分页不是 UI 细节,而是数据完整性契约。尤其在“先查再写”的 Agent 流程中,静默截断会导致错误对象被操作。 + +#### 当前需关注的问题 + +本次分析发现 `+conversation-list` 的单页模式存在一个边界风险:代码可能在解析下一页游标前提前退出,最终出现 `hasMore=true` 但 `nextCursor=0`。这不会否定整体设计,但应在合并前补回归测试并修复。 + +### 5.5 原子导出与资源下载 + +新增或强化的消息导出、资源下载路径采用“临时文件 -> 完整写入 -> 校验 -> 原子替换”的思路,避免失败时留下看似成功的残缺文件。 + +推荐流程: + +```text +解析目标 + -> 校验输出路径与权限 + -> 创建同目录临时文件 + -> 有界分页拉取/流式下载 + -> 校验数量、大小或摘要 + -> fsync/close + -> 原子 rename 到最终路径 + -> 返回 typed receipt +``` + +失败时删除临时文件,保留已有最终文件,并返回失败阶段、已处理数量及是否可重试。 + +#### 可复用原则 + +该模式适用于:通讯录批量导出、文档离线备份、附件下载、云盘迁移、知识库归档以及审批记录导出。 + +### 5.6 `+chat-create` 与 `+messages-reply` + +补齐这类语义型 shortcut 的意义在于构建完整 Golden Route:用户不需要先理解底层产品 API,再自己拼接参数。命令命名直接对应业务意图,Schema 则描述必要约束。 + +设计原则是: + +- Shortcut 只暴露稳定、常用的业务参数; +- 复杂底层参数放入高级 Reference; +- 输出统一 envelope; +- 目标解析复用 Resolver; +- 写操作具备 preflight、明确身份和结构化回执。 + +### 5.7 `event +listen-im` + +新增 IM 事件监听入口,将底层事件总线能力收敛为 Agent 易理解的生命周期命令。文档进一步拆出 keys、lifecycle、operations、output 四个主题,说明事件能力被视为长期运行流程,而不只是一次 API 调用。 + +事件监听需要明确: + +- start、status、stop 的生命周期; +- profile 与租户隔离; +- endpoint/key 的稳定标识; +- 前台与后台模式; +- 超时、重连和退出语义; +- 事件 envelope 与消息投影; +- 重复事件、顺序和幂等边界。 + +#### 可复用原则 + +通讯录变更事件、文档协作事件、云盘文件事件都可以复用同一监听骨架,只替换事件类型与投影器。 + +### 5.8 结构化错误与服务端失败分类 + +`internal/errors` 与 `server_failure_classifier.go` 将错误从一段不可解析文字升级为类型化结果。稳定错误至少应包含: + +- `code` +- `message` +- `stage` +- `retryable` +- `details` +- `suggestedAction` +- 必要的 request/trace 标识 + +关键分类包括:参数错误、目标未命中、目标歧义、身份不匹配、能力不支持、权限不足、限流、服务端失败、分页协议异常和文件提交失败。 + +错误分类的主要消费者不只是人,还包括 Agent 的下一步决策。因此 `suggestedAction` 应可执行,例如“提供 conversationId”“选择一个候选用户”“切换 bot identity”,而非笼统写“请重试”。 + +### 5.9 Profile 一致性 + +新增 `profilectx` 并调整 auth/profile 相关逻辑,目的是保证目标解析、API 调用和事件监听使用相同的身份上下文。跨 profile 漂移是多租户 CLI 中非常隐蔽的风险:解析可能在 A 租户命中对象,写操作却在 B 租户执行。 + +可复用规则: + +1. profile 在入口解析一次并向下传递; +2. Resolver 结果记录 profile/tenant; +3. 执行器不得静默回退到另一个默认 profile; +4. 批处理账本记录每项使用的 profile; +5. 事件监听 key 包含足够的上下文隔离信息。 + +### 5.10 隐藏兼容别名 + +PR 保留必要的参数或命令别名,但通过 metadata/schema 控制它们不再成为 Agent 的首选入口。这实现了“兼容旧调用,但不继续传播旧范式”。 + +正确的兼容策略应区分: + +- Runtime compatibility:旧命令仍可运行; +- Discovery visibility:新用户和 Agent 默认看不到旧入口; +- Documentation priority:文档只推荐 Golden Route; +- Deprecation observability:能够统计旧入口使用情况; +- Removal criteria:达到明确阈值后再移除。 + +### 5.11 Shortcut 部分修改详解 + +当前 #860 共修改 44 个 `internal/shortcut` 文件,其中 26 个生产 Go 文件、18 个测试文件。它不是简单增加命令数量,而是把 Shortcut 从“调用 MCP 的便捷包装”升级为以下分层执行系统: + +```text +Shortcut 声明与 Cobra 编译 + -> 本地参数/约束校验 + -> 自然目标解析与全量预检 + -> 身份/能力路由 + -> MCP 读写隔离与 dry-run + -> 有界分页或批量执行 + -> 统一领域投影 + -> 完整性/失败 ledger + -> 原子导出或安全资源落盘 +``` + +#### 5.11.1 Shortcut 框架:声明能力,而不是在命令里重复装配 Cobra + +`internal/shortcut/types.go` 和 `internal/shortcut/runner.go` 扩展了通用声明模型: + +| 修改 | Runtime 行为 | 价值 | +|---|---|---| +| `Flag.Shorthand` | 所有 flag 类型统一通过 Cobra 的 `BoolP/IntP/StringP/StringSliceP` 注册 | `--output` 可安全提供 `-o`,不需要每个命令单独实现 | +| `Shortcut.Aliases` | 将兼容命令映射到同一个执行实现 | 保留旧调用,但 canonical Schema 和文档仍只推荐主入口 | +| `SinglePositionalAliasFor` | 单个位置参数在校验前归一化为指定 flag;与显式 flag 同时出现时拒绝 | 为搜索类命令提供兼容体验,同时避免形成第二套参数契约 | +| `Constraint` | `at_least_one`、`exactly_one`、`mutually_exclusive` 由 Runner 统一执行并投影到 Runtime Schema | Help、Runtime 与 Agent Schema 共用参数关系 | +| `Changed`、`StrFirst`、`IntFirst` | 能区分“用户显式输入”和“默认值”,并统一处理兼容别名 | 防止默认值错误覆盖旧参数或触发不适用校验 | + +Runner 的执行顺序也被固定为:位置参数归一化 → required/enum 校验 → 跨参数约束 → 自定义校验 → 风险确认 → Execute。这样可以保证明显错误和未确认写操作不会到达 MCP。 + +读写调用进一步分离:`CallMCPData` 只允许符合只读命名契约的工具在 dry-run 中执行;`CallMCPWriteData` 在 dry-run 下直接 fail closed。多步写 Shortcut 必须自己输出 plan,不能借“为了拿返回值”之名在预览阶段执行写入。 + +#### 5.11.2 Target Resolver:把名称解析变成共享基础设施 + +`internal/shortcut/targetresolver/resolver.go` 新增了独立解析层,核心类型包括: + +- `Status`:`resolved`、`ambiguous`、`not_found`、`incomplete`; +- `IdentityRequirement`:区分下游需要 `userId`、`openDingTalkId` 或任一稳定身份; +- `UserResolution` / `ChatResolution`:返回规范目标、匹配方式和 profile; +- `Reader`:只依赖只读 `CallMCPData`,便于 Smart Shortcut、事件入口和测试共同复用。 + +群目标解析采用以下 fail-closed 算法: + +1. `cid...` 形式直接识别为稳定 `openConversationId`,不做名称搜索; +2. 群名调用 `im/search_groups`,每页 10 条,最多 40 页; +3. 对候选按稳定 ID 去重;精确名称优先,但多个精确同名仍然算歧义; +4. `hasMore=true` 时必须存在且推进 `nextCursor`; +5. 满页但无分页元数据、游标停滞或达到页数上限时返回 `resolution_incomplete`; +6. 只有完整候选集中的唯一目标才允许继续执行。 + +批量解析 `ResolveUsers/ResolveChats` 会先解析全部目标并收集结构化失败;任一目标未命中或有歧义,就在写操作开始前整体停止。它解决了过去每个 `+dm`、`+send-to-group`、`+group-members` 自己搜索并默认取首项的问题。 + +需要注意:群名解析已经显式证明跨页候选完整;用户解析目前依赖 `search_contact_by_key_word` 单次响应提供完整候选,后续若通讯录接口引入分页,应把相同的完整性规则扩展到用户 Resolver。 + +#### 5.11.3 身份与能力契约:先判断能不能做,再选择工具 + +`internal/shortcut/chat/identity_contract.go` 把 `user`、`bot`、`webhook` 三种身份的能力声明为矩阵,而不是散落在各分支的隐含知识: + +| 身份 | 目标 | 内容 | 自然目标 | 幂等 | 批量 ledger | +|---|---|---|---|---|---| +| user | 群、单聊 user/openDingTalkId | text、markdown、image、file,audio/video 按 file 发送 | 群名、姓名 | 支持 | 否 | +| bot | 单群、多群、批量单聊 | text、markdown | 不支持 | 不支持 | 多群支持 | +| webhook | token 所属群 | text、markdown | 不支持 | 不支持 | 否 | + +`internal/shortcut/chat/im_contract.go` 同时把“不支持什么”也变成可测试契约,例如 thread write、bot rich media、card action callback 和 Range 续传明确标记为不支持,并给出替代路径。这一点很重要:Agent 不应通过试错来发现能力边界。 + +#### 5.11.4 `+messages-send`:统一发送入口内部做严格能力路由 + +`internal/shortcut/chat/unified_send.go` 将 user、bot、webhook 的发送入口合并为一个语义命令,但没有把底层差异抹平: + +- user 目标必须在群、群名、userId、姓名、openDingTalkId 中恰好选择一个; +- bot 必须提供 `robot-code`,且群目标与批量单聊目标必须二选一; +- webhook 的目标由 token 决定,拒绝额外群或用户目标; +- `@` 参数按身份和群聊/单聊分别校验; +- `uuid/idempotency-key` 仅允许下层真实支持的 user 路径; +- bot/webhook 在网络调用前拒绝 image/file/audio/video; +- 内容正文、`media-id` 和本地文件互斥,并自动推导 `msg-type`。 + +user 的文件发送被拆成上传与消息发送两个动作,上传超时为 10 分钟;dry-run 只输出两步 plan。bot 多群发送最多 100 个目标,`groups-file` 必须是工作区内不超过 1 MiB 的普通文件,解析后去重,再通过 `im.batch-write.v1` 返回逐目标成功/失败账本。 + +`batch_write.go` 保持输入顺序,单项失败后继续其余项,但不自动重试写操作,因为远端报错时投递状态可能未知。代价是当前批量执行为顺序调用,优先可审计性和限流安全,而不是最大吞吐量。 + +#### 5.11.5 Smart Shortcut 变薄:只组合公共能力 + +Smart 层的主要变化不是继续增加本地业务逻辑,而是把已有命令改造成公共组件的适配器: + +| Shortcut/文件 | 修改后的职责 | +|---|---| +| `smart/dm.go` | 解析自然用户目标后,委托统一 user markdown 发送器 | +| `smart/send_to_group.go` | 解析群名或稳定 ID 后,委托同一发送器 | +| `smart/search_msg.go` | 在搜索前完成群和发送者消歧,再使用公共消息投影 | +| `smart/at_me.go` | 使用公共目标、分页和消息结果语义 | +| `smart/thread_replies.go` | 复用 `ProjectMessageV1`,不再维护线程专属返回结构 | +| `smart/resolve.go` | 旧局部解析辅助函数迁移到 `targetresolver` | + +这使 Golden Route 保持语义清晰,同时底层身份、消息字段和失败行为只维护一份。 + +#### 5.11.6 消息读取:从“返回数组”升级为 `im.message-list.v1` + +`internal/shortcut/chatmsg/chatmsg.go` 建立所有列表、搜索、mget、@me 和线程读取共享的投影核心 `ProjectMessageV1`。消息行统一包含可获得的: + +```text +messageId / conversationId / threadId +sender / senderId / senderType +messageType / text / createTime / updateTime +reactions / quotedMessage / forwarded / resourceRefs +``` + +投影层做了几项关键防御: + +- 同一语义字段兼容不同下层 key,但不会猜测缺失的 sender type; +- 富文本卡片只提取已识别的正文块,普通文本中的 JSON 片段保持原样; +- 无法解密的密文显示为明确标记,不把 base64 当正文; +- 引用消息做一层投影,避免递归链或循环; +- 合并转发递归使用同一投影; +- 资源从当前、引用和转发子消息中深度提取,并生成可执行下载参数; +- reaction 使用下层已内联的数据,不额外制造网络请求。 + +Envelope 默认保守地令 `complete=false`。只有拿到可靠分页事实时才允许证明完整;消息接口真正可继续执行的边界是末条消息的 `createTime`,因此 `nextPage` 发布 `time + direction`,而不是盲目把下层 cursor 暴露成 CLI 参数。 + +#### 5.11.7 有界分页:完整、截断和失败必须可区分 + +`smart/chat_messages.go` 将消息读取拆为目标编译、单页收集、全量收集、投影、资源下载和导出六个阶段。全量模式具备: + +- `page-limit` 默认 50、硬上限 500; +- `max-results` 结果上限; +- 稳定 `messageId` 跨页去重; +- 时间边界停滞检测; +- `hasMore=true` 但空页、缺边界或缺分页元数据时停止; +- `stopReason` 区分 `source_complete`、`single_page`、`page_limit`、`result_limit`、`read_failure` 和 `pagination_error`; +- 中途失败保留已读结果,并用 `partial/failures/failedCount` 显式标记。 + +`chat_conversation.go` 为会话列表加入同类的 `page-all/page-limit`、稳定 ID 去重和游标停滞检测,同时修正一个关键顺序问题:即使只读取一页,也必须在退出前解析 `nextCursor`,否则会出现 `hasMore=true` 却无法继续的结果。 + +`smart/group_members.go` 则建立 `im.group-members.v1`:用户和机器人分别进入 bucket;用户成员自动翻页并按 `openDingtalkId` 去重,机器人按下层全量结果投影。一个 bucket 失败时,另一个成功 bucket 仍可返回,但整体必须标记 `partial` 和 failures。 + +#### 5.11.8 本地校验、导出和下载:错误尽量发生在网络调用前 + +新增的 `smart/local_chat_validation.go` 集中支持: + +- `RFC3339`、`YYYY-MM-DD HH:mm:ss`、`YYYY-MM-DD` 三种时间格式; +- 显式 `limit/count/days` 必须为正数; +- 分页默认值只在值为零时回退; +- 本地验证错误携带稳定 reason 和可执行 action。 + +消息 JSON 导出与资源下载共享文件安全边界: + +1. 只接受工作目录内相对路径; +2. 同时识别 Unix 根路径、Windows 盘符、反斜杠和 portable `..` 逃逸; +3. 解析父目录 symlink,防止最终路径逃出工作区; +4. 默认 no-clobber,覆盖必须显式声明; +5. 先写同目录临时文件,完成写入、sync、close 后再原子发布; +6. 失败时删除临时文件,不留下看似成功的半成品。 + +资源下载明确采用整文件重试而非 Range 续传。这个限制被写入能力契约,避免文档或 Agent 宣称不存在的断点续传能力。 + +#### 5.11.9 新增和强化的 Golden Route + +- `+chat-create`:先解析所有自然成员和可选群主,整体预检成功后只创建一次;默认群主必须能从当前 profile 中取得并加入成员集合。 +- `+chat-update`:群名或稳定 ID 统一解析,只承诺当前真实支持的改名能力。 +- `+messages-reply`:可先读取原消息自动补齐引用发送者;dry-run 返回引用上下文和真实 transport plan;成功后补充消息、会话、线程、幂等和 delivery status。 +- `+chat-members-list`:统一群目标,区分 users/bots,提供 bucket 完整性。 +- `+chat-messages`:统一群聊/单聊自然目标、分页、消息投影、资源和导出。 +- `+messages-send`:作为高级统一发送入口;`+dm` 和 `+send-to-group` 继续承担更窄、更易选路的常用入口。 + +这体现了 Golden Route 的正确含义:不是强迫所有意图只剩一个巨型命令,而是为每类意图保留一个推荐入口,公共执行逻辑在下层复用。 + +#### 5.11.10 测试重点与整体评价 + +18 个 Shortcut 测试文件主要固定以下不变量: + +- 自然目标零命中、多命中、跨页同名和游标停滞时禁止写入; +- dry-run 中写调用为零,确认后写调用次数精确; +- canonical 参数与隐藏 alias 生成相同 MCP payload; +- 消息身份、正文、引用、转发、reaction、资源和分页投影一致; +- 页数/结果上限、中途失败和部分结果不会被标记为完整; +- Unix/Windows 路径、symlink、no-clobber 和原子发布边界; +- 所有公开 Shortcut 均进入 Schema 或有精确、受评审的 exclusion。 + +Shortcut 改造的核心价值可以概括为:**语义入口仍然轻量,但目标解析、能力判断、完整性证明和安全落盘已经成为 Runtime 强约束。** 当前仍需持续关注三点:`unified_send.go` 参数面较大、下层仍大量使用 `map[string]any` 做兼容投影、用户自然目标的候选完整性尚未像群目标一样显式分页证明。 + +## 6. 文档架构优化 + +本次文档改造的核心是“分层而非堆叠”: + +```text +SKILL.md + 负责意图识别、入口选择和最短路径 + | + +-- intent-guide.md + | 负责意图到命令的系统映射 + | + +-- contracts.md / runtime-contract.md + | 负责稳定输入输出与错误约束 + | + +-- product/reference docs + 负责参数细节、边界与复杂示例 +``` + +这类结构可以降低主 Skill 的上下文消耗,同时避免同一规则复制到多处后发生漂移。 + +## 7. 逐文档总结(30 份) + +文档变更可以先按职责理解,再查看逐文件细节: + +| 文档组 | 数量 | 主要职责 | 优化方向 | +|---|---:|---|---| +| Mono | 4 | 全产品入口和产品索引 | 同步 Golden Route,避免复制领域细节 | +| Chat Skill 与总览 | 6 | IM 意图路由、总览和公共合同 | 收敛入口,建立独立 contracts | +| Chat 卡片 | 4 | 卡片模型与生命周期 | 按 create/update/callback/schema 拆分 | +| Chat 领域子文档 | 6 | bot、会话、群、消息及旧工作流 | 对齐新 Runtime,删除重复路由源 | +| Event | 6 | IM 事件监听 | 按 keys/lifecycle/operations/output 分层 | +| Shared | 4 | 跨产品运行约束 | 统一错误、profile 和 Runtime contract | + +下面每份文档均按“改了什么—为什么—如何复用”三个问题进行总结。 + +### 7.1 Mono 文档 + +#### 1. `skills/mono/SKILL.md` + +- 调整 IM 意图的默认路由和入口说明。 +- 将运行时已具备的统一消息、会话和事件能力呈现给单体 Skill。 +- 强调优先使用稳定 shortcut,底层命令作为高级或兼容入口。 +- 复用价值:任何全产品聚合 Skill 都应只维护意图索引,不复制领域手册。 + +#### 2. `skills/mono/references/best_practices/01-messaging.md` + +- 更新消息发送、回复、读取等最佳实践。 +- 将自然目标解析、身份选择和分页边界纳入标准流程。 +- 复用价值:最佳实践文档应描述决策原则,不只是展示命令样例。 + +#### 3. `skills/mono/references/products/chat.md` + +- 对齐新的 chat shortcut、统一输出和能力边界。 +- 收敛旧脚本路径,避免文档继续引导 Agent 选择已被 Runtime 吸收的脚本。 +- 复用价值:产品 Reference 必须与 Schema 的可发现能力同步更新。 + +#### 4. `skills/mono/references/products/event.md` + +- 引入 `event +listen-im` 及其生命周期说明。 +- 明确事件监听与普通一次性命令的差异。 +- 复用价值:所有长生命周期能力都应提供 start/status/stop 和恢复语义。 + +### 7.2 Chat Skill 与总览文档 + +#### 5. `skills/multi/dingtalk-chat/SKILL.md` + +- 重构 chat 的核心意图路由,突出 Golden Route。 +- 增加统一发送、会话创建、消息回复、列表、导出等入口。 +- 将详细合同和高级示例下沉到 Reference,降低主入口复杂度。 +- 当前版本为 9,928 bytes,已进入 10,000 bytes 上下文预算,但只剩 72 bytes 余量,应继续把细节下沉到 Reference。 +- 复用价值:领域 Skill 应是“路由器”,不是完整 API 百科。 + +#### 6. `skills/multi/dingtalk-chat/references/01-messaging.md` + +- 更新端到端消息操作最佳实践。 +- 强调先解析目标、再做能力判断、最后执行写操作。 +- 补充分页、回复、文件处理等边界。 +- 复用价值:将“解析—计划—执行—回执”固化为跨产品写操作模板。 + +#### 7. `skills/multi/dingtalk-chat/references/chat.md` + +- 大幅重构 chat 总参考,覆盖新的 shortcut 和 Runtime 契约。 +- 统一参数命名、输出结构、错误处理和高级入口定位。 +- 减少与子文档的重复,将细节按主题拆分。 +- 复用价值:领域总览应负责导航和共享概念,具体操作落到子 Reference。 + +#### 8. `skills/multi/dingtalk-chat/references/intent-guide.md` + +- 建立用户意图到 Golden Route 的明确映射。 +- 说明目标是人、群、会话或消息时分别如何选择命令。 +- 降低“多个命令看起来都能完成任务”造成的路由不确定性。 +- 复用价值:通讯录和文档也应维护机器可校验的 intent-route 表。 + +#### 9. `skills/multi/dingtalk-chat/references/contracts.md` + +- 新增集中式契约文档。 +- 固定消息列表、写操作回执、批处理账本和错误 envelope 等公共结构。 +- 将契约与教程分离,便于版本化和自动检查。 +- 复用价值:每个领域都应有唯一的 contract source of truth。 + +#### 10. `skills/multi/dingtalk-chat/references/chat-emoji-list.md` + +- 对齐 emoji 相关命令、目标和消息标识参数。 +- 使轻量功能也遵循统一会话/消息身份语义。 +- 复用价值:边缘功能不能绕过统一 Resolver 和契约。 + +### 7.3 Chat 卡片文档 + +#### 11. `skills/multi/dingtalk-chat/references/card/create.md` + +- 新增卡片创建流程说明。 +- 区分卡片定义、实例创建和消息投递,避免一步调用承载过多隐式行为。 +- 复用价值:复杂对象创建应拆成资源定义、实例化和发布阶段。 + +#### 12. `skills/multi/dingtalk-chat/references/card/update.md` + +- 新增卡片更新操作说明。 +- 强调实例标识、可更新字段和生命周期边界。 +- 复用价值:文档更新、表格更新等都应区分 create contract 与 update contract。 + +#### 13. `skills/multi/dingtalk-chat/references/card/callback.md` + +- 新增卡片回调与交互事件说明。 +- 将回调输入视为结构化事件,而不是普通消息文本。 +- 复用价值:交互式文档组件、审批回调也可复用事件 envelope。 + +#### 14. `skills/multi/dingtalk-chat/references/card/schema.md` + +- 新增卡片 Schema 说明。 +- 集中描述字段结构和约束,减少 create/update 文档重复。 +- 复用价值:把数据模型与操作教程分开,Schema 可单独校验和演进。 + +### 7.4 Chat 领域子文档 + +#### 15. `skills/multi/dingtalk-chat/references/chat/chat-bot.md` + +- 更新机器人发送、广播及身份能力说明。 +- 旧脚本能力被收敛进 Runtime,文档不再要求 Agent 拼装脚本流程。 +- 注意:文档部分位置使用 `results`,而 Runtime 实际批处理字段为 `succeeded`,需统一。 +- 复用价值:文档字段名必须由合同或测试生成,避免手工漂移。 + +#### 16. `skills/multi/dingtalk-chat/references/chat/chat-conversation.md` + +- 更新会话创建、列表和目标标识说明。 +- 补充游标、分页和会话类型边界。 +- 复用价值:任何容器类资源都应区分 canonical ID、display name 与 page cursor。 + +#### 17. `skills/multi/dingtalk-chat/references/chat/chat-group.md` + +- 更新群成员、群信息与群目标解析流程。 +- 统一群名与群 ID 的解析/歧义处理。 +- 复用价值:部门名、空间名、目录名同样需要唯一解析和候选返回。 + +#### 18. `skills/multi/dingtalk-chat/references/chat/chat-message.md` + +- 更新消息读取、回复、导出和资源下载说明。 +- 对齐 `im.message-list.v1` 投影及分页元数据。 +- 复用价值:把列表 contract 与后续动作需要的 canonical ID 同时输出。 + +#### 19. `skills/multi/dingtalk-chat/references/chat/chat-workflows.md`(删除) + +- 删除与 SKILL、intent guide 和领域子文档大量重叠的工作流集合。 +- 旧文档容易形成第二套意图路由源。 +- 复用价值:当工作流可由 Golden Route 与合同组合推导时,不应继续维护重复手册。 + +#### 20. `skills/multi/dingtalk-chat/references/lite-recipes.md`(删除) + +- 删除过短且容易过时的 recipe 集合。 +- 将典型路径并入主 Skill 或更权威的 Reference。 +- 复用价值:减少“看似方便但缺少边界”的示例,避免模型只复制 happy path。 + +### 7.5 Event 文档 + +#### 21. `skills/multi/dingtalk-event/SKILL.md` + +- 增加 IM 监听的意图入口与生命周期路由。 +- 将普通事件命令和长期监听操作区分开。 +- 复用价值:事件领域 Skill 应首先判断用户要“查询配置”还是“启动持续进程”。 + +#### 22. `skills/multi/dingtalk-event/references/event-im.md` + +- 大幅更新 IM 事件总参考。 +- 串联配置、启动、运行、输出、停止和排错。 +- 将细节拆到四个新子文档,自己承担导航职责。 +- 复用价值:长流程总文档应提供生命周期地图而非重复所有参数。 + +#### 23. `skills/multi/dingtalk-event/references/event-im-keys.md` + +- 新增 endpoint、listener、profile 等关键标识说明。 +- 明确 key 的生成、引用和隔离边界。 +- 复用价值:后台任务、同步任务、订阅任务都需要稳定 task key。 + +#### 24. `skills/multi/dingtalk-event/references/event-im-lifecycle.md` + +- 新增启动、运行、状态、停止等生命周期说明。 +- 描述前后台模式及退出后的状态语义。 +- 复用价值:将长任务统一建模为显式状态机。 + +#### 25. `skills/multi/dingtalk-event/references/event-im-operations.md` + +- 新增常见操作和运维流程说明。 +- 将查看状态、诊断、停止等操作从概念说明中拆出。 +- 复用价值:后台服务文档应提供可操作 runbook。 + +#### 26. `skills/multi/dingtalk-event/references/event-im-output.md` + +- 新增监听输出格式及字段解释。 +- 明确事件 envelope 与 IM 消息投影之间的关系。 +- 复用价值:通讯录、文档事件应共享 envelope,仅替换 payload contract。 + +### 7.6 Shared 文档 + +#### 27. `skills/multi/dws-shared/SKILL.md` + +- 更新多产品共享约束和 Reference 导航。 +- 将 Runtime contract 与错误码作为跨领域公共基础设施。 +- 复用价值:共享 Skill 只承载真正跨域的稳定规则,领域语义仍留在各自 Skill。 + +#### 28. `skills/multi/dws-shared/references/error-codes.md` + +- 扩展结构化错误类别、可重试性和建议动作。 +- 支撑 Target Resolver、服务端失败分类和文件流程。 +- 复用价值:错误码应跨产品统一命名,领域细节放入 `details`。 + +#### 29. `skills/multi/dws-shared/references/global-reference.md` + +- 更新全局参数、profile、输出和运行约束。 +- 明确公共规则的权威来源,减少 chat/event 文档重复。 +- 复用价值:全局 Reference 应稳定且少变,产品差异通过链接下沉。 + +#### 30. `skills/multi/dws-shared/references/runtime-contract.md` + +- 新增 Runtime 公共契约说明。 +- 固定 envelope、错误、批处理、分页和 profile 传播等跨产品行为。 +- 复用价值:通讯录、文档、云盘接入时应直接复用,而非重新设计返回结构。 + +## 8. 被删除脚本的意义 + +PR 删除 mono 与 multi chat 下的以下脚本: + +- `bot_broadcast.py` +- `chat_export_messages.py` +- `chat_history_with_user.py` + +删除并不意味着能力减少,而是能力已进入可测试、可发现、可治理的 Runtime。脚本适合验证原型,但长期作为 Agent 主路径会带来参数契约不可发现、错误格式不一致、身份上下文丢失和重复分页逻辑等问题。 + +迁移准则:当一个脚本成为稳定业务能力后,应将核心逻辑提升到 Runtime,生成 Schema,补充契约测试,然后从主 Skill 中撤下脚本入口。 + +## 9. Policy 与测试体系 + +### 9.1 Multi IM Skill Chain Policy + +新增 `scripts/policy/multi-im-skill-chain`,通过意图测试数据检查关键路径是否在 Skill、Reference、Schema 与 Runtime 之间闭环。其价值是防止以下回归: + +- 文档推荐了不存在的命令; +- Runtime 已新增命令但 Schema 未暴露; +- 兼容别名重新成为首选; +- 同一意图再次出现多个 Golden Route; +- 关键参数或输出字段只在某一层存在。 + +当前 intent manifest 包含 9 个关键意图路由;PR 描述记录 `make policy` 通过。本文对代码和配置进行了静态核对,没有把该记录扩展表述为 GitHub CI 全绿。 + +### 9.2 Context Budget Policy + +Skill 上下文预算检查用于控制入口文档大小。渐进披露只有在主 Skill 足够短时才有效。当前 chat Skill 为 9,928 bytes,刚好回到 10,000 bytes 预算内;架构方向正确,但余量很小,内容仍需继续去重或下沉。 + +### 9.3 测试分层 + +本次变更配套测试覆盖: + +- Resolver 的唯一命中、多命中、未命中与输入类型; +- unified send 的目标和身份选择; +- 消息投影字段; +- 分页边界与兼容行为; +- 结构化错误和服务端失败分类; +- event 命令注册与生命周期; +- Schema/shortcut contract; +- mock MCP 端到端冒烟路径。 + +PR 描述记录 `internal/app`、`internal/shortcut/chat` 和 `internal/shortcut/smart` 等重点测试通过。本文分析的是远端 head 的 Git 对象,没有在当前 `main` 工作区切换分支重新运行完整测试;当前 GitHub CI 结论仍应以第 21 章列出的 workflow 状态为准。 + +## 10. 面向通讯录场景的复用蓝图 + +通讯录复用的重点不是照搬 IM 命令,而是把“找群/找人后执行动作”的模式转换为“找用户/找部门后执行动作”。核心差异在于通讯录写操作通常权限更高、影响范围更大,因此 Resolver 的唯一性和执行前计划需要更严格。 + +### 10.1 建议 Golden Routes + +```text +contact +resolve +contact +user-get +contact +user-list +contact +department-list +contact +member-list +contact +invite +contact +update +contact +export +event +listen-contact +``` + +### 10.2 Directory Resolver + +集中接受姓名、手机号、邮箱、userId、unionId、部门名称和 deptId。输出唯一 canonical target 或候选列表,不允许高风险写操作在歧义状态下继续。 + +### 10.3 类型化契约 + +建议定义: + +- `directory.user.v1` +- `directory.user-list.v1` +- `directory.department.v1` +- `directory.member-list.v1` +- `directory.batch-write-result.v1` +- `directory.change-event.v1` + +### 10.4 风险分级 + +- 只读查询:可在唯一命中后自动执行。 +- 邀请/修改:必须显示 preflight plan。 +- 删除/批量调岗:要求显式确认、严格唯一目标和可审计账本。 + +### 10.5 批处理账本 + +每条记录包含 input、resolved target、action、status、error、retryable 和 profile。这样部分失败可安全重试,不需要重跑已成功项目。 + +## 11. 面向文档、云盘和知识库的复用蓝图 + +文档类场景与 IM 的共同点是目标输入多样、读取结果需要稳定投影、长流程需要完整性保证;不同点是文档还涉及版本冲突、层级容器、权限继承和内容覆盖。因此在复用 Resolver 与 Runtime contract 的同时,应额外引入 revision/etag 和并发控制。 + +### 11.1 建议 Golden Routes + +```text +doc +resolve +doc +create +doc +read +doc +update +doc +export +doc +share +drive +download +drive +upload +wiki +resolve-node +event +listen-document +``` + +### 11.2 Document Target Resolver + +统一解析 URL、docId、fileId、spaceId、workspaceId、知识库节点 ID、标题和路径。解析输出必须包含 product/resource type,避免将同形 ID 传给错误产品接口。 + +### 11.3 Plan/Execute 分离 + +写文档、覆盖文件、调整权限、移动知识库节点前先生成计划: + +```json +{ + "target": {"type": "document", "id": "..."}, + "action": "replace-content", + "identity": "user", + "expectedRevision": "...", + "sideEffects": ["overwrite content"], + "recoverability": "revision-history" +} +``` + +计划可以独立验证权限、版本冲突和副作用;确认后才执行。 + +### 11.4 列表与搜索契约 + +搜索结果必须区分 exact、fuzzy 和 candidate,返回 canonical URL/ID、容器、资源类型、权限摘要以及分页状态。不要只返回标题字符串。 + +### 11.5 原子输出与并发控制 + +- 导出使用临时文件和原子替换。 +- 更新使用 revision/etag 防止覆盖他人修改。 +- 上传返回摘要、大小和最终资源 ID。 +- 长迁移返回 checkpoint 与逐项账本。 + +## 12. 可复用的落地模板 + +### 12.1 新领域接入步骤 + +1. 列出用户真实意图,而非先列 API。 +2. 为每个意图指定唯一 Golden Route。 +3. 设计统一 Target Resolver 和 canonical target。 +4. 建立身份/能力矩阵。 +5. 定义版本化输入、列表、写回执和错误契约。 +6. 为长流程增加 maxItems、maxPages、timeout、checkpoint。 +7. 将写操作拆为 plan 和 execute。 +8. 生成或同步 Schema metadata/hints/registry。 +9. 编写短 Skill、意图指南、合同和分主题 Reference。 +10. 增加意图链路、上下文预算、Schema 和契约测试。 + +### 12.2 推荐目录结构 + +```text +skills/multi/dingtalk-/ + SKILL.md + references/ + intent-guide.md + contracts.md + .md + .md + .md + +internal/shortcut/ + / + resolver/ + +scripts/policy/ + -skill-chain/ +``` + +### 12.3 Golden Route 评审问题 + +- 一个自然语言意图是否只有一个默认入口? +- shortcut 是否隐藏了供应商 API 的非必要复杂度? +- 兼容入口是否仍可运行但不会被 Agent 优先发现? +- Resolver 是否统一处理 ID、名称、URL 和多候选? +- 写操作是否在执行前完成身份和能力校验? +- 输出是否有稳定版本? +- 列表是否明确完整、截断与下一页状态? +- 错误是否能指导 Agent 下一步动作? +- Skill、Reference、Schema 与 Runtime 是否有自动一致性检查? + +## 13. PR #860 当前风险收口状态 + +| 评审问题 | 当前整体实现 | 判断 | +|---|---|---| +| `+conversation-list` 单页模式丢失 `nextCursor` | 将 cursor 解析移动到单页退出判断之前,并补充失败边界测试 | **已修复** | +| `hasMore=true` 但 cursor 缺失、无效或不前进 | 增加 missing/invalid/stalled cursor 测试 | **已强化** | +| Chat Shortcut 未全部进入 Schema | 从 50/97 提升到 97/97,删除 47 项临时 exclusion | **已解决** | +| 高风险示例直接展示 `--yes` | 文档和命令示例改为先运行或 `--dry-run`,由 Runtime 要求确认后再追加 `--yes` | **已改善** | +| 跨平台输出路径判断和覆盖替换不完整 | 统一处理 Unix/Windows 路径语义,并以 build tags 分离 `os.Rename` 与 `windows.Rename` | **已加固** | +| chat bot 文档 `results` 与 Runtime `succeeded` 可能不一致 | 当前 diff 中未见由单一 typed source 自动生成这两个名称 | **仍需确认** | +| Chat Skill 上下文预算偏紧 | 已通过拆分 Reference 和删除重复文档缩短主 Skill,仍应持续由 budget policy 约束 | **持续治理** | +| 兼容别名缺少退出观测 | 已增强 alias/canonical payload 等价测试,但未建立使用量观测或移除阈值 | **长期项** | +| 真实线上语义 | 单元测试和 mock MCP 能证明本地契约,不能证明真实权限、MCP 响应和服务端可用性 | **需要受控 E2E** | + +## 14. PR #860 整体架构结论 + +PR #860 的核心贡献,是把 IM 能力从“散落的命令和教程”升级为“可解析、可计划、可执行、可验证的意图系统”。其最重要的架构资产包括: + +- Resolver:统一对象身份; +- Golden Route:统一意图入口; +- Capability Matrix:统一身份与能力判断; +- Versioned Contract:统一机器输出; +- Bounded Execution:统一分页和长流程边界; +- Typed Error Ledger:统一失败处理与恢复; +- Progressive Documentation:统一知识组织; +- Policy Gates:统一防漂移机制。 + +这些模式与 IM 本身无强绑定。通讯录的“找人/找部门”、文档的“找资源/改内容”、云盘的“找文件/迁移”、知识库的“找节点/调整结构”都面临同一类问题。复用时应迁移完整链路,而不是只复制某个 shortcut: + +```text +意图设计 + -> Resolver + -> Golden Route + -> Plan/Capability Check + -> Bounded Runtime + -> Versioned Result/Error + -> Documentation + Schema + Policy +``` + +只有整条链路同时落地,才能真正降低 Agent 的选择成本、减少误操作,并让新领域能力持续演进而不重新陷入命令、文档和实现相互漂移的问题。 + +--- + +## 15. 产品化交付闭环 + +### 15.1 为什么 Runtime 完成不等于 PR 完成 + +PR #860 同时处理两类问题:一类是命令行为本身,另一类是 Agent 能否发现、正确选择并安全执行这些行为。只有 Runtime、Registry、Schema、selection hints、风险 metadata、Skill、Policy 和测试同时闭环,才能把“隐藏在代码里的能力”升级为“可交付产品能力”。 + +大行数主要来自重新生成并提交的 Schema catalog 和 Agent metadata,不能等同于 6 万余行手写业务逻辑。评审时应将 authored source、Runtime、generated artifacts、文档和测试分开看。 + +### 15.2 五个交付收口方向 + +1. **能力发布完整化**:剩余 47 个公开 Chat Shortcut 从临时 exclusion 转为正式 Registry 和 Schema 能力,使 Chat 达到 97/97。 +2. **安全元数据类型化**:为 47 个命令逐一声明 effect、risk、confirmation、idempotency、interface mode 和 runtime gate。 +3. **参数前置校验**:时间、页大小、回溯窗口等明显错误在本地拒绝,不再发起无效远端调用。 +4. **运行时边界收口**:修复单页 cursor、补 `-o`、增强跨平台输出路径并扩大目标消歧测试。 +5. **交付证据闭环**:重新生成 Schema/Agent metadata,强化生成漂移、契约完整性和 changed-code coverage 门禁。 + +## 16. Schema 发布与能力治理 + +### 16.1 从 50/97 到 97/97 + +PR #860 在 `internal/cli/schema_command_registry/products/chat.json` 中补齐 47 个 canonical shortcut,并从 `schema_command_exclusions.json` 删除整个 `chat-shortcuts-pending-schema-curation` 临时排除组。 + +这一步的意义不是单纯“数字补齐”,而是完成以下状态转换: + +```text +公开可执行、但 Schema 暂未评审 + -> authored Registry 有正式记录 + -> selection hints 能指导 Agent 何时用/何时不用 + -> metadata 声明风险和确认方式 + -> generated catalog/Agent metadata 可发现 + -> completeness test 保证不再退回临时排除 +``` + +关键指标变化: + +| 指标 | 改造前 | PR #860 当前 head | +|---|---:|---:| +| Chat Schema Shortcut | 50 | 97 | +| 全部公开 Shortcut Schema 覆盖 | 218 / 265 | 265 / 265 | +| Schema catalog 工具数 | 850 | 898 | +| Agent metadata 覆盖工具数 | 850 | 898 | +| hint tools | 1,852 | 1,948 | +| reviewed Agent examples | — | 1,108(1,084 contract + 24 dry-run) | + +### 16.2 新发布的 47 个 Shortcut + +为便于理解,可按业务域归为五组: + +#### 会话分类(3) + +- `+category-add-conversation` +- `+category-list-conversations` +- `+category-remove-conversation` + +#### 群管理(13) + +- `+chat-add-bot` +- `+chat-audit-join` +- `+chat-get-by-id` +- `+chat-members-get` +- `+chat-mute-member` +- `+chat-quit` +- `+chat-remove-bot` +- `+chat-role-remove` +- `+chat-role-remove-user` +- `+chat-transfer-owner` +- `+chat-update` +- `+chat-update-icon` +- `+chat-update-settings` + +#### 会话状态(7) + +- `+conversation-clear-messages` +- `+conversation-clear-red-point` +- `+conversation-hide` +- `+conversation-mark-read` +- `+conversation-mark-unread` +- `+conversation-mute` +- `+conversation-set-top` + +#### Feed 与收藏(4) + +- `+feed-group-query-item` +- `+flag-cancel` +- `+flag-create` +- `+flag-list` + +#### 消息与资源(20) + +- `+messages-add-emoji` +- `+messages-add-text-emotion` +- `+messages-batch-recall-by-bot` +- `+messages-batch-send-by-bot` +- `+messages-combine-forward` +- `+messages-create-text-emotion` +- `+messages-forward` +- `+messages-forward-topic` +- `+messages-list` +- `+messages-recall` +- `+messages-recall-by-bot` +- `+messages-remove-emoji` +- `+messages-remove-text-emotion` +- `+messages-resource-download` +- `+messages-resource-url` +- `+messages-send-by-bot` +- `+messages-set-pin` +- `+messages-set-top` +- `+messages-unset-pin` +- `+messages-unset-top` + +### 16.3 风险与确认元数据 + +47 个新增 metadata 的分布非常清晰: + +| 类型 | 数量 | confirmation | runtime gate | +|---|---:|---|---| +| 只读 / low | 8 | `not_required` | `none` | +| 写操作 / medium | 36 | `user_required` | `typed_yes` | +| 破坏性 / high | 3 | `user_required` | `typed_yes` | + +三个 high-risk 命令是: + +- `chat.shortcut_chat_remove_bot` +- `chat.shortcut_chat_role_remove` +- `chat.shortcut_conversation_clear_messages` + +八个只读命令包括分类列表、按 ID 查群、成员获取、Feed 查询、收藏列表、消息列表、资源 URL 和资源下载。其余 39 个命令全部要求用户确认并由 Runtime 的 `typed_yes` 门禁执行。 + +这种分类实现了三个层面的一致性: + +1. Agent 在选择工具时能看到风险; +2. 示例不会把确认当成默认参数; +3. Runtime 即使被直接调用,也会阻止未经确认的写操作。 + +### 16.4 Authored Source 与生成物分离 + +PR #860 的权威编辑源主要是: + +- `schema_command_registry/products/chat.json`:命令身份和 canonical path; +- `schema_hints/selection/chat.json`:`use_when`、`avoid_when` 和 source refs; +- `schema_hints/metadata/chat.json`:effect、risk、confirmation 等运行语义。 + +随后由生成流程更新: + +- `schema_catalog/catalog.json` +- `schema_catalog/tools/chat.json` +- `schema_agent_metadata/chat.json` +- `schema_agent_metadata/index.json` +- `schema_agent_metadata_audit.json` +- `runtime-surface-completeness.json` + +复用到其他产品时,应优先评审 authored source 的准确性,再验证生成物无漂移。直接手改大型 catalog 会削弱可追溯性。 + +### 16.5 Selection Hints 的价值 + +每个新 Shortcut 都有独立的 `use_when`、`avoid_when` 和 `source_refs`。三者分别解决: + +- **什么时候用**:把用户意图映射到该工具; +- **什么时候不用**:避免和相近工具误路由; +- **依据是什么**:从生成结果追溯到 Registry 和文档锚点。 + +这比只提供命令描述更适合 Agent,因为工具选择的难点通常不是“工具做什么”,而是“在几个相似工具中为什么选这个”。 + +## 17. Runtime 安全与正确性加固 + +### 17.1 本地参数校验 + +新增 `internal/shortcut/smart/local_chat_validation.go`,集中实现: + +- 默认分页上限归一化; +- RFC3339、`YYYY-MM-DD HH:mm:ss`、`YYYY-MM-DD` 三类时间格式检查; +- 带稳定 reason 和 suggested action 的本地 validation error。 + +落地命令及边界: + +| 命令 | 新增校验 | +|---|---| +| `+chat-messages` | `--time` 格式;`--limit/--size/--page-size > 0` | +| `+thread-replies` | 时间格式;显式 `--limit > 0` | +| `+at-me` | `--days` 为 1~3650;`--limit > 0` | +| `+unread-chats` | 显式 `--count > 0` | + +核心原则是“能够在本地确定错误,就不要把它推给远端”。这样能减少无效请求,并让 Schema 约束、CLI 错误和测试保持一致。 + +### 17.2 破坏性确认门禁 + +`internal/helpers/chat.go` 为以下底层命令补上真实 Runtime 检查: + +- `chat category delete` +- `chat clear-messages` + +缺少 `--yes` 时,命令返回 `confirmation_required`,列出确认目标和再次执行方式,并保证没有任何下游工具调用。补充测试同时验证:确认前零写入、确认后仅发生一次预期调用。 + +此外,群解散和升级外部群等示例不再直接展示 `--yes`;升级示例改用 `--dry-run`。这是一个重要的提示工程原则: + +> 文档示例应该展示安全的第一步,而不是展示绕过交互确认的最终一步。 + +### 17.3 单页会话 cursor 修复 + +`+conversation-list` 的旧实现曾在非 `--page-all` 模式下先退出循环,再解析 `nextCursor`,可能返回 `hasMore=true` 但游标为零。PR #860 将 cursor 解析提前到退出判断之前。 + +修复后的行为: + +- 单页结果保留可执行的下一页游标; +- `hasMore=true` 但游标缺失、无效或停滞时记录 pagination failure; +- `--page-all` 才继续拉下一页; +- `--page-limit` 保持有界。 + +这说明分页契约的完整性不仅适用于“拉完全部”,单页模式同样必须提供可靠 continuation。 + +### 17.4 通用 shorthand 支持 + +`shortcut.Flag` 新增 `Shorthand` 字段,统一注册逻辑从 Cobra 的 `Bool/Int/StringSlice/String` 切换到对应的 `*P` 版本。`+chat-messages --output` 因而获得 `-o`。 + +该改动是框架级能力,不只服务于当前命令。后续复用时需增加同一命令内 shorthand 唯一性检查,避免不同 flag 争用同一个短参数。 + +### 17.5 跨平台资源路径加固 + +原实现主要依赖当前操作系统的 `filepath.IsAbs/Clean`。在 Unix 上,Windows 盘符路径或反斜杠逃逸未必会按预期识别。PR #860 增加 portable 归一化: + +- 将反斜杠转换为 `/` 后再检查; +- 识别 Unix 绝对路径; +- 识别 Windows `C:` 等盘符; +- 统一拒绝 `..` 和 `../` 逃逸; +- 在父目录、相对路径和最终解析阶段复用同一判断。 +- 用 `file_replace.go` 与 `file_replace_windows.go` 的 build tags 分离平台实现:非 Windows 使用 `os.Rename`,Windows 使用 `windows.Rename`,保证覆盖写仍是原子替换。 + +该模式可直接用于文档导出、云盘下载、通讯录 CSV 导出和任何工作区内文件写入能力。 + +### 17.6 目标消歧与 fail-closed 测试 + +Resolver 实现与测试共同固定以下关键语义: + +- 稳定 ID 永远不按名称搜索; +- 自然目标走 Resolver; +- 同名精确匹配仍保持歧义,不能偷偷选一个; +- 必须翻完候选页后才判断是否唯一; +- cursor 无法推进时 fail closed; +- 结构化错误保留候选对象; +- 外部联系人只要有可用 openDingTalkId 也可参与解析。 + +最重要的改进是“跨页后再唯一判定”。如果只看第一页就认为唯一,在第二页存在同名对象时会产生高风险误操作。 + +### 17.7 删除过时 hint 子命令 + +删除 `dws chat send` 和 `dws chat history` 两个仅用于提示的伪子命令。Golden Route 已通过 Shortcut 和 Schema 正式发布后,继续保留 hint 命令会污染命令树并形成第二套发现路径。 + +兼容不等于永远保留所有提示层。若入口已经由结构化 discovery 接管,应删除无法执行真实业务、只输出迁移提示的旧节点。 + +## 18. PR #860 文件分类总结 + +### 18.1 129 个文件按职责归类 + +| 文件组 | 数量 | 主要内容 | +|---|---:|---| +| 生产 Go | 38 | App、错误、Resolver、Chat/Smart Runtime、契约、Policy 与生成别名 | +| Go 测试 | 30 | Resolver、分页、确认、文件安全、事件、契约及 coverage | +| Skill Markdown | 30 | Mono、Chat、Event、Shared 四层知识链路 | +| Schema / metadata / JSON | 17 | Registry、selection、risk metadata、catalog、Agent metadata 与 intent manifest | +| Skill 脚本 | 6 | 删除已被 Runtime 替代的重复 Python 路径 | +| 生成/审计脚本 | 6 | Skill 生成、Policy 检查、live audit 及其测试 | +| CI / Makefile | 2 | 接入 Policy、兼容性、覆盖率及平台 race 验证 | + +### 18.2 Schema 文件逐项总结 + +| 文件 | 总结 | +|---|---| +| `schema_command_registry/products/chat.json` | 补齐 canonical Shortcut,成为 Chat 发布权威源 | +| `schema_command_exclusions.json` | 删除 47 项“待 Schema 评审”临时豁免,反向完整性转为硬约束 | +| `schema_hints/selection/chat.json` | 为 Chat 工具补齐 use/avoid/source refs,强化 Agent 路由 | +| `schema_hints/metadata/chat.json` | 为 Chat 工具声明 effect、risk、confirmation、idempotency 和 runtime gate | +| `schema_hints/runtime-surface-completeness.json` | 更新 Runtime surface 覆盖指纹与统计 | +| `schema_catalog/catalog.json` | 重新生成全局 catalog 索引 | +| `schema_catalog/tools/chat.json` | 重新生成 Chat 的完整工具契约和示例 | +| `schema_agent_metadata/chat.json` | 生成 Chat Agent 决策元数据 | +| `schema_agent_metadata/index.json` | 更新全产品 metadata 索引和 898 工具统计 | +| `schema_agent_metadata_audit.json` | 更新 coverage、source/surface hash 和审计结果 | + +### 18.3 Runtime 文件逐项总结 + +| 文件 | 总结 | +|---|---| +| `internal/helpers/chat.go` | 为删除分类、清空消息补确认门禁;安全化危险示例;删除旧 hint 子命令 | +| `shortcut/chat/chat_conversation.go` | 修复单页模式 nextCursor 丢失 | +| `shortcut/chat/resource_download.go` | 统一 Unix/Windows 绝对路径和 `..` 逃逸判断 | +| `shortcut/chat/file_replace.go` | 非 Windows 构建使用 `os.Rename` 作为原子替换边界 | +| `shortcut/chat/file_replace_windows.go` | Windows 构建使用 `windows.Rename` 支持覆盖已有目标 | +| `shortcut/runner.go` | 所有 flag 类型支持 shorthand 注册 | +| `shortcut/types.go` | `Flag` 契约增加 `Shorthand` | +| `smart/local_chat_validation.go` | 新增共享时间、页大小和结构化本地错误辅助函数 | +| `smart/chat_messages.go` | 新增时间/页大小校验及 `-o`,复用默认 page limit | +| `smart/at_me.go` | 限定 days 和 limit,组合资源下载校验 | +| `smart/thread_replies.go` | 限定时间格式和 limit | +| `smart/unread_chats.go` | 限定 count 为正数 | +| `smart/group_members.go` | 复用统一默认 page limit 逻辑 | + +### 18.4 Policy 文件总结 + +`scripts/policy/multi-im-skill-chain/main.go` 将工作目录、进程退出、文件打开和 Registry build/bind 抽成可注入函数。行为本身没有改变,但各种 setup failure、I/O failure 和 binding failure 现在可在单元测试中稳定覆盖。 + +这是一种值得复用的“测试接缝”设计:对进程边界做最小注入,不为测试重写业务逻辑。 + +### 18.5 安全示例收口示例 + +#### 1. `skills/mono/references/best_practices/01-messaging.md` + +- 将发送、机器人批量发送和消息转发示例中的默认 `--yes` 去掉。 +- 明确 Runtime 会先返回确认要求;用户确认后才以相同参数追加 `--yes`。 +- 保留唯一目标解析、同 profile 稳定 ID 和未知投递状态停止重发等规则。 +- 复用价值:所有写操作最佳实践都应展示“安全第一步 + 确认后续步骤”,不能把确认令牌写进首个示例。 + +#### 2. `skills/mono/references/products/chat.md` + +- 将“普通群升级为外部群”的正式执行示例改为 `--dry-run`。 +- 删除直接带 `--yes` 的示例,保留 extension JSON 的预演方式。 +- 复用价值:不可逆转换、权限变更和内容覆盖应优先展示 dry-run/preflight。 + +### 18.6 测试体系总结 + +PR #860 的 30 个 Go 测试文件主要覆盖: + +- 265/265 public Shortcut Schema 覆盖和 Chat 97/97; +- metadata 风险与 Runtime confirmation truth; +- 时间、页大小、回溯窗口等本地无网络校验; +- natural target、稳定 ID、同名候选和跨页消歧; +- 单页/全量分页、cursor 停滞、边界失败和部分结果保留; +- 资源下载的绝对路径、反斜杠、symlink、原子 no-clobber; +- 确认前零写调用、确认后精确一次调用; +- alias 到 canonical payload 的等价性; +- Multi IM Policy 的配置、I/O 和失败矩阵; +- 跨平台 changed-code coverage。 + +测试名大量增加 `CrossPlatformCoverage` 前缀,是为了让平台覆盖门禁识别相关测试。其积极意义是证据可追踪;长期应避免让命名约定取代真正的覆盖映射机制。 + +## 19. Go 文件优化专项总结 + +### 19.1 总体规模与演进主线 + +PR #860 的 Go 变更应按“生产实现”和“验证实现”分开理解: + +| 类型 | 文件数 | 新增 / 删除 | 主要任务 | +|---|---:|---:|---| +| 生产 Go | 38 | +4,028 / -737 | 建立 Resolver、IM Runtime 契约、事件监听、错误/Profile、平台文件替换和 Policy 基座 | +| Go 测试 | 30 | +4,343 / -88 | 固定正常路径、失败边界、安全不变量、Schema/Runtime 一致性与跨平台覆盖 | + +整体演进不是把更多逻辑堆进单个命令,而是逐步形成以下 Go 代码分层: + +```text +Cobra / App 层 + 负责命令注册、参数装配、profile 与进程生命周期 + | + v +Shortcut / Smart 层 + 负责 Golden Route、业务校验、计划和结果组织 + | + v +Target Resolver / Contract 层 + 负责规范目标、身份能力、版本化结果和错误语义 + | + v +Helper / Transport 调用层 + 负责下游工具调用,不重复业务决策 + | + v +Policy + Tests + 验证 Runtime、Schema、文档和安全元数据保持一致 +``` + +### 19.2 App、错误与上下文文件 + +| Go 文件 | 主要优化 | 设计价值 | +|---|---|---| +| `internal/app/event_command.go` | 注册新的 `event +listen-im` 入口 | 将 IM 事件监听纳入正式内建命令树 | +| `internal/app/event_listen_im.go` | 新增监听计划编译、自然目标解析、事件类型映射及 consume 生命周期委托 | 把长期事件订阅从参数拼装提升为可验证的计划执行 | +| `internal/app/runner.go` | 增强多 profile 执行和错误结果组织 | 批量或多身份运行时保留逐 profile 的失败语义 | +| `internal/app/server_failure_classifier.go` | 新增服务端业务失败分类器 | 将后端文本/元数据转换为稳定 reason、retryable 和行动建议 | +| `internal/auth/profiles.go` | 收敛 profile 选择和传播方式 | 减少解析与执行使用不同租户身份的风险 | +| `internal/profilectx/profile.go` | 新增轻量 profile context | 让 Resolver、Shortcut 和调用层共享明确身份上下文 | +| `internal/errors/errors.go` | 扩展 typed error、server diagnostics 和结构化输出字段 | 使错误可被 Agent 解析并驱动后续动作 | +| `internal/cli/param_aliases_generated.go` | 发布新增 IM 参数别名 | 兼容旧参数拼写,同时维持 canonical payload | +| `internal/helpers/aisearch.go` | 补齐搜索目标兼容与参数映射 | 为自然人名解析提供更稳定的下层搜索入口 | +| `internal/helpers/chat.go` | 对齐 Chat 底层命令边界、参数与确认相关行为 | 使 shortcut 与底层 helper 的真实能力一致 | + +这一组文件的核心优化是:把 profile、错误和生命周期从“隐式全局状态”变为显式上下文与类型化结果。通讯录、文档等领域若只复用 Resolver 而不复用这些基础设施,仍可能出现跨租户误操作或不可恢复错误。 + +### 19.3 Chat Runtime 与契约文件 + +| Go 文件 | 主要优化 | 设计价值 | +|---|---|---| +| `internal/shortcut/chat/identity_contract.go` | 新增用户、机器人、webhook 等身份契约 | 把“谁在执行”从命令分支提升为显式类型 | +| `internal/shortcut/chat/im_contract.go` | 新增 IM 列表、写结果与公共字段契约 | 为消息、会话和批处理提供稳定域模型 | +| `internal/shortcut/chat/unified_send.go` | 重构统一发送的目标解析、身份路由、批量发送和能力拒绝 | 用户表达发送意图,Runtime 决定合法执行器 | +| `internal/shortcut/chat/batch_write.go` | 统一批量写去重、成功/失败账本和未知状态 | 支持部分失败审计与安全重试 | +| `internal/shortcut/chat/chat_conversation.go` | 增强会话列表分页、投影和完整性字段 | 单页与全量模式都返回可执行 continuation | +| `internal/shortcut/chat/chat_group.go` | 对齐群目标、成员和群操作的输入输出 | 群名与稳定 ID 共用同一解析语义 | +| `internal/shortcut/chat/chat_message.go` | 统一消息读取、回复及消息字段投影 | 后续回复、转发、下载使用 canonical message identity | +| `internal/shortcut/chat/message_export.go` | 新增消息 ledger 的临时写入、校验和原子发布 | 失败时不留下伪成功的半成品文件 | +| `internal/shortcut/chat/resource_download.go` | 对齐资源下载与安全输出流程 | 下载与导出共享工作区相对路径和原子提交原则 | +| `internal/shortcut/chat/file_replace.go` | 非 Windows 平台使用 `os.Rename` 提供覆盖替换 | 保持平台默认的原子替换语义 | +| `internal/shortcut/chat/file_replace_windows.go` | Windows 平台使用 `windows.Rename` | 修复 `os.Rename` 无法覆盖既有目标的差异 | +| `internal/shortcut/chat/lark_alignment.go` | 补齐创建群、回复等对齐型 Shortcut | 将常用意图封装成稳定 Golden Route | +| `internal/shortcut/chatmsg/chatmsg.go` | 大幅增强 `im.message-list.v1` 投影、消息身份、引用、资源、reaction 和分页 | 隔离下游消息响应差异,为 Agent 提供稳定机器结构 | + +这组文件形成了一条清晰的数据流: + +```text +Target + Identity + -> Unified Route + -> 下层调用 + -> Message/Conversation Projection + -> Typed Ledger + -> Atomic Export / Resource Reference +``` + +其中 `identity_contract.go`、`im_contract.go` 和 `chatmsg.go` 是最值得跨领域复用的部分:前两者展示如何建立类型化边界,后者展示如何把供应商响应投影成稳定领域模型。 + +### 19.4 Smart Shortcut 与 Resolver 文件 + +| Go 文件 | 主要优化 | 设计价值 | +|---|---|---| +| `internal/shortcut/targetresolver/resolver.go` | 新增统一用户/群目标解析、候选去重、精确匹配、跨页搜索和结构化歧义错误 | 消除各命令独立猜测 ID/名称的根因 | +| `internal/shortcut/smart/resolve.go` | 将旧的局部解析逻辑收敛到共享 Resolver | 降低重复实现和规则漂移 | +| `internal/shortcut/smart/chat_messages.go` | 重构自然群/用户目标、typed 分页、去重、结果上限与导出 | 将消息历史变为有界、可证明完整的读取流程 | +| `internal/shortcut/smart/group_members.go` | 统一群解析、用户/机器人分桶、用户分页和稳定 ID 去重 | 对复杂成员列表公开完整性和失败信息 | +| `internal/shortcut/smart/at_me.go` | 对齐群目标解析、消息投影与资源引用 | 特殊消息查询仍复用公共契约 | +| `internal/shortcut/smart/search_msg.go` | 统一群与发送者自然目标解析 | 多条件搜索在远端调用前完成消歧 | +| `internal/shortcut/smart/dm.go` | 将单聊发送收敛到统一身份和目标规则 | 避免单聊维护另一套发送实现 | +| `internal/shortcut/smart/send_to_group.go` | 将群发送收敛到 unified send | 群名、群 ID 和身份选择走同一路由 | +| `internal/shortcut/smart/thread_replies.go` | 对齐线程回复的消息投影和资源字段 | 线程场景不再产生独立返回结构 | +| `internal/shortcut/runner.go` | 增强 shortcut flag、约束、隐藏兼容参数和确认处理 | Runtime metadata 能真实映射到 Cobra 行为 | +| `internal/shortcut/types.go` | 扩展 Shortcut/Flag 类型信息 | 为 Schema 生成和 Runtime 装配提供统一源数据 | + +这一层最重要的代码优化是“业务适配器变薄”。Smart 命令不再负责全部搜索、能力选择、分页和投影,而是组合共享 Resolver、contract 和执行器。这种组合方式比复制命令模板更适合通讯录和文档扩展。 + +### 19.5 Policy Go 文件 + +#### `scripts/policy/multi-im-skill-chain/main.go` + +该文件新增了一套可执行的一致性检查器,读取意图 manifest、有效 CommandRegistry、selection hints 和 Markdown marker,验证: + +- 每个关键意图是否有唯一 preferred tool; +- fallback 是否有明确 reason code; +- forbidden default 是否不会重新成为首选; +- source refs 和文档 marker 是否存在; +- typed contract 与 event handoff 参考是否漂移; +- 已退休脚本是否被重新发布; +- confirmation metadata 是否被降级。 + +它把“评审规则”实现成 Go 程序,能够直接加载项目真实 Registry,而不是靠文本搜索猜测命令是否存在。这种 Policy-as-Code 模式可抽象成通讯录、文档等产品共用的领域 manifest 校验器。 + +### 19.6 边界收口类生产 Go 文件逐项总结 + +除新增核心抽象外,PR #860 还对既有 Runtime 的关键边界做了系统收口: + +| Go 文件 | 主要优化 | 整体价值 | +|---|---|---| +| `internal/helpers/chat.go` | 为删除会话分类、清空消息增加真实 `--yes` 门禁;安全化示例;删除旧 hint 命令 | 补齐 Runtime confirmation truth 和 Golden Route 收口 | +| `internal/shortcut/chat/chat_conversation.go` | 在单页退出前解析 `nextCursor` | 修复 #860 的 continuation 缺陷 | +| `internal/shortcut/chat/resource_download.go` | 兼容 Windows 盘符、反斜杠和 portable `..` 逃逸检查 | 将 #860 文件安全扩展到跨平台 | +| `internal/shortcut/runner.go` | 所有 flag 类型改用 Cobra `*P` 注册 | 提供通用 shorthand 基础能力 | +| `internal/shortcut/types.go` | `Flag` 新增 `Shorthand` | 让 Runtime、Schema 和 CLI 共享短参数定义 | +| `internal/shortcut/smart/local_chat_validation.go` | 新增时间格式、默认 page limit 和结构化本地错误辅助函数 | 将散落校验收敛为公共层 | +| `internal/shortcut/smart/chat_messages.go` | 增加时间/页大小校验和 `-o`;复用默认分页函数 | 阻止无效远端调用,改善常用导出体验 | +| `internal/shortcut/smart/at_me.go` | 校验 1~3650 天及正数 limit | 为特殊读取命令建立明确资源边界 | +| `internal/shortcut/smart/thread_replies.go` | 校验时间格式和正数 limit | 与消息列表使用相同本地校验语义 | +| `internal/shortcut/smart/unread_chats.go` | 校验显式 count 大于 0 | 避免负数/零值被静默当作默认值 | +| `internal/shortcut/smart/group_members.go` | 复用统一默认 page limit | 去除重复默认值分支 | +| `scripts/policy/multi-im-skill-chain/main.go` | 对 getwd、exit、open、Registry build/bind 增加可注入接缝 | 不改变生产行为的前提下完整覆盖失败分支 | + +这组代码体现了三种“小而关键”的优化: + +1. **修顺序**:cursor 必须先提取,再决定是否继续翻页。 +2. **提公共函数**:时间和分页校验不在多个 Smart 命令中复制。 +3. **建测试接缝**:只对进程/I/O 边界注入,不侵入业务逻辑。 + +### 19.7 Go 测试文件优化总结 + +#### 核心架构行为测试 + +首先建立新能力的行为基线: + +- `targetresolver/resolver_test.go`:唯一命中、多命中、稳定 ID 和结构化候选; +- `unified_send_resolution_test.go`:身份、自然群目标、批量发送和写入前失败; +- `chatmsg_test.go`:消息身份、正文、引用、reaction、资源和分页投影; +- conversation/message/group 相关测试:typed list、分页和兼容字段; +- event listen 测试:计划编译、事件映射、生命周期和清理回滚; +- failure classifier/errors 测试:JSON、人类输出和服务端诊断; +- Multi IM Policy 测试:意图路由、Reference、retired script 和 contract marker。 + +#### 边界与交付证据测试 + +其次将测试从“新增功能验证”扩展到“边界与交付证据”: + +- 将 public Shortcut Schema 覆盖固定为 265/265、Chat 固定为 97/97; +- 验证 47 个新增 Schema 项的风险分类与 Runtime gate; +- 验证确认前零调用、确认后精确一次调用; +- 覆盖 cursor 缺失、无效、停滞、page limit 和后续页失败; +- 覆盖跨页同名目标仍保持歧义; +- 覆盖 Unix/Windows 路径、symlink、原子写和 no-clobber; +- 覆盖 shorthand、alias payload 等价和本地参数失败; +- 通过 `CrossPlatformCoverage` 测试集合证明 changed-code coverage。 + +测试策略可概括为: + +```text +行为测试:证明新架构“能正确工作” +交付测试:证明所有公开入口“都被发布、都受约束、所有失败边界都不会越权执行” +``` + +### 19.8 Go 层面的核心可复用模式 + +| 模式 | Go 实现方式 | 适用场景 | +|---|---|---| +| Typed Resolver | 独立 package + canonical result + structured candidates | 通讯录用户/部门、文档/空间、云盘文件 | +| Thin Adapter | Smart Shortcut 组合 Resolver、contract 和 executor | 所有自然语言 Golden Route | +| Contract Projection | 独立 projector 将 `map[string]any` 转为稳定 v1 envelope | 列表、搜索、事件、批处理结果 | +| Fail Closed | cursor/目标/身份不完整时停止,不猜测默认值 | 写操作和需要完整集合的读取 | +| Bounded Collector | page limit、max results、seen cursor、stable dedupe | 成员、文档块、文件、审批记录 | +| Atomic Publisher | temp file、close/check、rename、no-clobber | 导出、下载、备份和迁移 | +| Typed Confirmation | metadata 声明 + Runtime 强制 gate + 零调用测试 | 删除、覆盖、转移、权限修改 | +| Injectable Boundary | 将 getwd/open/exit/build 等边界封装为函数变量 | CLI、Policy 和后台任务的失败分支测试 | +| Policy as Code | Go 加载真实 Registry/manifest 并做语义校验 | 跨产品 Schema、Skill 和 Runtime 一致性 | + +Go 文件层面的核心结论是:PR #860 同时完成“抽象公共机制”“收紧运行边界”和“补齐验证证据”。复用到其他领域时,应优先复制 package 边界、类型契约和测试不变量,而不是复制具体命令函数。 + +## 20. 当前 #860 的整体价值与跨场景复用 + +### 20.1 从“运行时能力”到“可交付产品能力” + +PR #860 定义了一个更完整的 Done: + +| 完成层级 | 判断标准 | 对应 PR | +|---|---|---| +| 能执行 | Runtime 有真实实现 | #860 | +| 行为稳定 | Resolver、分页、typed result/error 有契约 | #860 | +| 可发现 | Registry 和 Schema 正式发布 | #860 | +| 会选择 | selection hints 有 use/avoid 语义 | #860 | +| 执行安全 | local validation、capability 和 typed confirmation 生效 | #860 | +| 可维护 | 文档分层、生成漂移、Policy 和覆盖门禁闭环 | #860 | + +因此,新领域不能把“代码合入”当成交付完成。一个通讯录或文档 Shortcut 只有同时满足上表六层,才真正适合 Agent 使用。 + +### 20.2 通讯录复用重点 + +在 `DirectoryResolver` 蓝图之上,应补充 PR #860 展示的发布治理: + +- 每个 user/department Shortcut 都进入 authored Registry; +- selection hints 区分“查人”“查关注的人”“查某人最近消息”等易混意图; +- 删除、调岗、批量邀请标记为 write/high 并使用 typed confirmation; +- 手机号、邮箱格式和分页大小在本地校验; +- 联系人导出路径使用 portable workspace-relative 规则; +- completeness test 禁止公开 Shortcut 依赖长期 exclusion。 + +### 20.3 文档与云盘复用重点 + +在原有 `DocumentTargetResolver`、revision/etag 和原子导出蓝图之上,应补充: + +- read、write、destructive 风险 metadata; +- 覆盖、删除、权限回收必须 typed confirmation; +- 示例默认使用 dry-run 或 plan,不直接带确认参数; +- 文档 URL、路径和目标 revision 在本地尽可能校验; +- 导出/下载同时识别 Unix 与 Windows 路径语义; +- 生成 catalog 必须能追溯到 authored Registry 和 contract Reference。 + +### 20.4 推荐的跨产品发布清单 + +1. Runtime 已实现且有单元测试。 +2. 目标解析支持稳定 ID 和自然目标,并对歧义 fail closed。 +3. 列表返回完整性与 continuation。 +4. 写操作声明 effect/risk/idempotency。 +5. Runtime gate 与 metadata 的 confirmation 完全一致。 +6. Registry 有 canonical path。 +7. Selection hints 有 use/avoid/source refs。 +8. Schema 和 Agent metadata 已生成且 drift clean。 +9. 文档第一示例不绕过确认。 +10. public shortcut completeness、policy 和 changed-code coverage 通过。 + +## 21. 综合验证、剩余风险与最终结论 + +### 21.1 验证事实需要分层理解 + +PR #860 当前描述中记录的验证包括: + +- `DWS_PACKAGE_VERSION=0.0.0-test go test ./internal/app -count=1` +- `DWS_PACKAGE_VERSION=0.0.0-test go test ./internal/shortcut/chat ./internal/shortcut/smart -count=1` +- `make policy` +- generated drift clean +- Schema compatibility 与 authoritative interface integrity 通过 +- 26 products / 898 tools +- 176 个 Runtime confirmation gate +- 1,108 个 reviewed Agent examples + +上述是 PR 作者提供的本地验证记录。本报告通过 GitHub 状态另行确认:head `33ceab60...` 的 `AI Behavior` 为 success,但 PR-triggered `CI` workflow run #1802 已完成且结论为 failure。因此不能把 PR 描述中的本地验证等同于“GitHub CI 全绿”;失败原因需结合 Actions job/log 单独排查。 + +### 21.2 仍需关注的风险 + +1. **CI 未全绿**:当前 PR 仍为 Open,CI run #1802 显示 failure,应追踪失败 job,确认是否为基础设施、覆盖门禁或真实回归。 +2. **大型生成物审查成本**:5 万余行增量主要是生成物,应持续保证 authored source、hash 和 drift check 可重建。 +3. **Skill 上下文预算**:当前 Chat Skill 为 9,928 bytes,已回到 10,000 bytes 预算内,但余量仅 72 bytes,应继续把细节下沉到 Reference。 +4. **字段文档漂移**:`results` / `succeeded` 等批处理字段仍需合同化校验。 +5. **Shorthand 冲突**:框架支持 shorthand 后,应增加每个命令内唯一性和保留字符检查。 +6. **确认不是幂等**:39 个写命令的 idempotency 仍为 `unknown`;确认只能降低误触发,不能解决重试重复写入。 +7. **命名式覆盖标记**:大量测试依赖 `CrossPlatformCoverage` 前缀,长期可考虑用测试清单或覆盖映射替代命名耦合。 + +### 21.3 最终结论 + +PR #860 将“可靠执行 IM 意图”和“完整、安全、可发现地交付能力”合并为同一套工程系统。其核心方法是: + +```text +Intent First + -> Golden Route + -> Typed Target Resolver + -> Local Validation + Preflight Capability Check + -> Typed Confirmation + -> Bounded Runtime + -> Versioned Result / Error Ledger + -> Authored Registry + Selection/Metadata + -> Generated Schema / Agent Metadata + -> Policy + Drift + Coverage Gates +``` + +这条链路可以直接作为通讯录、文档、云盘、知识库等新领域的标准交付模板。最关键的认知是:**Runtime 正确只是起点;只有发现、选择、安全、契约和门禁同时闭环,能力才真正完成产品化。** diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index 1a4ed074..8267390c 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin copyArgs[key] = value } c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs}) - text := paramAliasResponseForTool(tool) + text := c.paramAliasResponseForTool(tool) return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } @@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin // print the transport result and need an empty object; smart shortcuts that // inspect a read response receive the smallest shape that lets their full RunE // complete without falling back to a validation error. -func paramAliasResponseForTool(tool string) string { +func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string { switch tool { case "list_calendar_events": return `{"result":{"events":[]}}` @@ -62,9 +62,22 @@ func paramAliasResponseForTool(tool string) string { return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}` case "list_doc_versions": return `{"result":{"items":[{"version":3}]}}` + case "revert_doc_version": + return `{"version":3}` case "search_doc_templates": return `{"result":[{"templateId":"fixture-template-id"}]}` + case "create_document": + return `{"nodeId":"fixture-node"}` case "get_document_content": + for index := len(c.calls) - 2; index >= 0; index-- { + call := c.calls[index] + for _, key := range []string{"jsonml", "markdown"} { + if content, ok := call.args[key].(string); ok { + encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content}) + return string(encoded) + } + } + } return `{"revision":1}` default: return `{}` diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 8aff408d..494a8b6c 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -123,7 +123,7 @@ var paramAliasCompleteCommands = map[string][]string{ "doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"}, "doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"}, "doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"}, - "doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"}, + "doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"}, "doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"}, "doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"}, "doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"}, diff --git a/internal/app/schema_agent_examples_dry_run_test.go b/internal/app/schema_agent_examples_dry_run_test.go index dec8f1c2..a3b8906f 100644 --- a/internal/app/schema_agent_examples_dry_run_test.go +++ b/internal/app/schema_agent_examples_dry_run_test.go @@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { t.Fatalf("write dry-run fixture %s: %v", path, err) } } - return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image} + return agentExampleFiles{ + root: root, + markdown: "./" + filepath.Base(markdown), + json: "./" + filepath.Base(jsonFile), + batch: "./" + filepath.Base(batch), + binary: "./" + filepath.Base(binary), + image: "./" + filepath.Base(image), + } } func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string { diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 9da3c5ce..5146dcb1 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -129,6 +129,75 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) } } +func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) { + leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update") + if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want { + t.Fatalf("confirmation = %q, want %q", got, want) + } + parameters := schemaContractMap(leaf["parameters"]) + if got, want := len(parameters), 11; got != want { + t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters) + } + if required, _ := parameters["node"]["required"].(bool); !required { + t.Errorf("--node required = %#v, want true", parameters["node"]["required"]) + } + if required, _ := parameters["command"]["required"].(bool); required { + t.Errorf("--command required = true, want runtime custom validation") + } + wantProperties := map[string]string{ + "node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat", + "block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new", + "expected-revision": "expectedRevision", + } + for name, want := range wantProperties { + if got := schemaContractString(parameters[name]["property"]); got != want { + t.Errorf("--%s property = %q, want %q", name, got, want) + } + } + for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} { + parameter := parameters[name] + if required, _ := parameter["required"].(bool); required { + t.Errorf("--%s required = true, want runtime custom validation", name) + } + if got := schemaContractString(parameter["required_when"]); got != "" { + t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got) + } + } + if constraints, exists := leaf["constraints"]; exists && constraints != nil { + t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints) + } +} + +func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) { + comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create") + commentParameters := schemaContractMap(comment["parameters"]) + for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} { + if _, ok := commentParameters[name]; !ok { + t.Errorf("comment-create missing --%s: %#v", name, commentParameters) + } + } + for name, want := range map[string]string{"node": "node", "mention": "mention"} { + if got := schemaContractString(commentParameters[name]["property"]); got != want { + t.Errorf("comment-create --%s property = %q, want %q", name, got, want) + } + } + + export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export") + exportFormat := schemaContractMap(export["parameters"])["export-format"] + if required, _ := exportFormat["required"].(bool); required { + t.Fatalf("export --export-format required = true, want compatibility default") + } + if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" { + t.Fatalf("export --export-format default = %q, want docx", defaultValue) + } + + importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import") + constraints, _ := importLeaf["constraints"].(map[string]any) + if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) { + t.Fatalf("import unexpectedly requires a target: %#v", constraints) + } +} + func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any { t.Helper() root := NewRootCommand() diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 65dd8e4e..d5229552 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -2003,7 +2003,6 @@ var generatedParamAliases = []ParamAliasEntry{ "next-page-token": "cursor", "next-token": "cursor", "node-id": "node", - "page-size": "limit", "page-token": "cursor", "per-page": "limit", "size": "limit", @@ -2459,25 +2458,15 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc +version-list", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "file-id": "node", - "max-result": "limit", - "max-results": "limit", - "next-cursor": "cursor", - "next-page-token": "cursor", - "next-token": "cursor", - "node-id": "node", - "page-size": "limit", - "page-token": "cursor", - "per-page": "limit", - "size": "limit", - "take": "limit", - "top": "limit", - "url": "node", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "node-id": "node", + "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"}, }, { CLIPath: "doc +version-revert", diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 7fc3145d..db93b507 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -44,6 +44,7 @@ }, "command_overrides": { + "doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."}, "chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."}, "chat group members": {"bind": {"id": "open_conversation_id"}}, "chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."}, diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 01dac46c..c1aa8b1e 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -532,16 +532,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { fileSize := fileInfo.Size() if deps.Caller.DryRun() { - deps.Out.PrintKeyValue("操作", "上传附件并插入文档") - deps.Out.PrintKeyValue("文档", nodeID) - deps.Out.PrintKeyValue("文件", filePath) - deps.Out.PrintKeyValue("名称", fileName) - deps.Out.PrintKeyValue("类型", mimeType) - deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize)) - return nil + return deps.Out.PrintJSON(map[string]any{ + "contractVersion": "doc.operation.v1", + "dry_run": true, + "preview_kind": "plan", + "ok": true, + "status": "success", + "complete": true, + "operation": "doc.media_insert", + "data": map[string]any{ + "executed": false, "nodeId": nodeID, "file": filePath, + "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, + }, + "steps": []map[string]any{{"name": "validate_local_file", "status": "success"}}, + }) } - ctx := context.Background() + ctx := cmd.Context() // Step 1: get upload credentials (uploadUrl + resourceId) deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize)) @@ -568,7 +575,20 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { "Content-Type": mimeType, } if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil { - return err + return apperrors.NewAPI( + "附件上传结果未知;尚未确认正文 block 已插入,请先检查文档媒体列表,禁止改用手写 HTTP", + apperrors.WithOperation("doc.media_insert"), + apperrors.WithReason("doc_media_upload_unknown"), + apperrors.WithFailureStage("upload_oss"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithActions("运行 dws doc +media-list 检查当前文档", "确认没有对应媒体后才重新执行 +media-insert", "不要 curl 上传地址或安装本地依赖"), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.operation.v1", "status": "unknown", "nodeId": nodeID, + "resourceId": resourceID, "fileName": fileName, "stage": "upload_oss", + }), + apperrors.WithCause(err), + ) } // Step 3: insert block into document @@ -625,15 +645,43 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { } if err := callMCPTool("insert_document_block", insertArgs); err != nil { - return err + return apperrors.NewAPI( + "附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入", + apperrors.WithOperation("doc.media_insert"), + apperrors.WithReason("doc_media_insert_partial"), + apperrors.WithFailureStage("insert_block"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithActions("运行 dws doc +media-list 检查 resourceId/blockId", "不要直接重试 +media-insert,不要使用 resourceUrl 手写请求"), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID, + "resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, + "steps": []map[string]any{ + {"name": "resolve_upload", "status": "success"}, + {"name": "upload_oss", "status": "success"}, + {"name": "insert_block", "status": "unknown"}, + }, + }), + apperrors.WithCause(err), + ) } - if strings.HasPrefix(mimeType, "image/") { - deps.Out.PrintInfo(fmt.Sprintf("图片已插入文档: %s (resourceUrl=%s)", fileName, resourceURL)) - } else { - deps.Out.PrintInfo(fmt.Sprintf("附件已插入文档: %s (resourceId=%s)", fileName, resourceID)) - } - return nil + return deps.Out.PrintJSON(map[string]any{ + "contractVersion": "doc.operation.v1", + "ok": true, + "status": "success", + "complete": true, + "operation": "doc.media_insert", + "data": map[string]any{ + "nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL, + "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true, + }, + "steps": []map[string]any{ + {"name": "resolve_upload", "status": "success"}, + {"name": "upload_oss", "status": "success"}, + {"name": "insert_block", "status": "success"}, + }, + }) } // parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response. @@ -3831,8 +3879,8 @@ CLI 内部自动完成全部流程: }, } importCmd.Flags().String("file", "", "本地文件路径 (必填)") - importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)") - importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)") + importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)") + importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)") importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)") importCmd.Flags().String("folder-id", "", "") _ = importCmd.Flags().MarkHidden("folder-id") diff --git a/internal/helpers/doc_write_pipeline.go b/internal/helpers/doc_write_pipeline.go index f90303d0..460f3ec9 100644 --- a/internal/helpers/doc_write_pipeline.go +++ b/internal/helpers/doc_write_pipeline.go @@ -9,6 +9,7 @@ import ( "syscall" "unicode/utf8" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -17,9 +18,6 @@ const ( // Server-side OSS delta resolution is now fixed, so large chunks are safe. initialChunkSize = 10000 - // minChunkSize is the floor; below this we report an error instead of retrying. - minChunkSize = 5000 - // longContentWarningThreshold triggers a hint to use --content-file. longContentWarningThreshold = 2048 ) @@ -95,10 +93,11 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a // Single write path nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs) chunksWritten = 1 - if writeErr != nil && isTimeoutError(writeErr.Error()) && runeCount > minChunkSize { - // Timeout on single write — fallback to chunked with halved size - deps.Out.PrintInfo("[INFO] 单次写入超时,自动切换为分片写入...") - nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize/2) + if writeErr != nil && isTimeoutError(writeErr.Error()) { + // The server may have committed the write before the client observed the + // timeout. Replaying create/append here can duplicate a document or + // content, so fail closed and require inspection before any retry. + writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr) } } else { // Chunked write path @@ -156,6 +155,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any, len(chunks), utf8.RuneCountInString(chunks[0]))) resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs) if err != nil { + if isTimeoutError(err.Error()) { + return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err) + } return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err) } nodeID = extractNodeIDFromResult(resultText) @@ -181,6 +183,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any, len(chunks), utf8.RuneCountInString(chunks[0]), firstMode)) resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs) if err != nil { + if isTimeoutError(err.Error()) { + return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err) + } return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err) } lastResponse = resultText @@ -209,29 +214,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any, resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs) if err != nil { if isTimeoutError(err.Error()) { - newSize := chunkSize / 2 - if newSize < minChunkSize { - return nodeID, writtenCount, resultText, &CLIError{ - Code: CodeContentTruncated, - Message: fmt.Sprintf("分片写入持续超时,已写入 %d 片。当前分片大小 %d 字符已低于最小阈值 %d", - writtenCount, chunkSize, minChunkSize), - Suggestion: fmt.Sprintf("后端写入超时无法恢复。已成功写入部分内容到 nodeId=%s,请使用 dws doc read --node %s 查看已写入部分", - nodeID, nodeID), - Operation: operation, - } - } - deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入超时,分片大小减半为 %d 字符后重试...", newSize)) - chunkSize = newSize - - var remaining strings.Builder - remaining.WriteString(chunk) - for j := i + 1; j < len(chunks); j++ { - remaining.WriteString(chunks[j]) - } - newChunks := splitMarkdownSafe(remaining.String(), chunkSize) - chunks = append(chunks[:i], newChunks...) - i-- // retry current index - continue + return nodeID, writtenCount, resultText, docWriteUnknownStateError( + operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err, + ) } return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err) } @@ -243,6 +228,27 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any, return nodeID, writtenCount, lastResponse, nil } +func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error { + details := map[string]any{ + "status": "unknown", + "nodeId": nodeID, + "chunksWritten": written, + "chunksTotal": total, + "failedStage": stage, + } + return apperrors.NewAPI( + "文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试", + apperrors.WithOperation(operation), + apperrors.WithReason("doc_write_commit_unknown"), + apperrors.WithFailureStage(stage), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithActions("先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"), + apperrors.WithDetails(details), + apperrors.WithCause(cause), + ) +} + // isTimeoutError checks if an error message indicates a server-side timeout. func isTimeoutError(msg string) bool { lower := strings.ToLower(msg) diff --git a/internal/helpers/doc_write_pipeline_coverage_more_test.go b/internal/helpers/doc_write_pipeline_coverage_more_test.go index 22a10334..c9ee14d1 100644 --- a/internal/helpers/doc_write_pipeline_coverage_more_test.go +++ b/internal/helpers/doc_write_pipeline_coverage_more_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -40,10 +41,12 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi t.Fatalf("long literal single write: %v", err) } - fallback := strings.Repeat("x", minChunkSize+100) - installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}}) - if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil { - t.Fatalf("single timeout fallback: %v", err) + fallback := strings.Repeat("x", 5100) + timeoutCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}}} + installScriptedCaller(t, timeoutCaller) + err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update") + if err == nil || !strings.Contains(err.Error(), "提交状态未知") || timeoutCaller.calls != 1 { + t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls) } chunked := strings.Repeat("x", initialChunkSize+100) @@ -58,9 +61,43 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test os.Args = []string{"dws", "doc"} t.Cleanup(func() { os.Args = oldArgs }) markdown := strings.Repeat("x", 24000) - installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}) + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}} + installScriptedCaller(t, caller) _, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000) - if err != nil || written < 3 { - t.Fatalf("adaptive retry written=%d err=%v", written, err) + if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") { + t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err) + } +} + +func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) { + oldArgs := os.Args + os.Args = []string{"dws", "doc"} + t.Cleanup(func() { os.Args = oldArgs }) + markdown := strings.Repeat("x", 24000) + tests := []struct { + name string + tool string + args map[string]any + wantNode string + }{ + {name: "create", tool: "create_document", args: map[string]any{"name": "doc"}}, + {name: "update", tool: "update_document", args: map[string]any{"nodeId": "node", "mode": "overwrite"}, wantNode: "node"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}} + installScriptedCaller(t, caller) + nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000) + var typed *apperrors.Error + if err == nil || !errors.As(err, &typed) { + t.Fatalf("error = %#v", err) + } + if nodeID != tc.wantNode || written != 0 || caller.calls != 1 { + t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls) + } + if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable { + t.Fatalf("unknown commit metadata = %#v", typed) + } + }) } } diff --git a/internal/shortcut/builtin/doc_semantic_catalog_test.go b/internal/shortcut/builtin/doc_semantic_catalog_test.go index 0c21e688..0cac29e5 100644 --- a/internal/shortcut/builtin/doc_semantic_catalog_test.go +++ b/internal/shortcut/builtin/doc_semantic_catalog_test.go @@ -71,8 +71,8 @@ func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t } wantPrimaries := map[string]string{ - "+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save", - "+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share", + "+find-doc": "+search", "+doc-append": "+update", "+history-save": "+version-save", + "+history-list": "+version-list", "+history-revert": "+version-revert", "+share-doc": "+share", } for command, primary := range wantPrimaries { item := registered[command] diff --git a/internal/shortcut/doc/common.go b/internal/shortcut/doc/common.go index 8e66f8fe..bb904533 100644 --- a/internal/shortcut/doc/common.go +++ b/internal/shortcut/doc/common.go @@ -5,6 +5,7 @@ package doc import ( "encoding/json" + "errors" "fmt" "io" "path/filepath" @@ -16,7 +17,11 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract." +const ( + compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract." + docContentInputDescription = "内容字面量、@工作目录相对文件或 - 表示 stdin" + docRequiredContentInputDescription = docContentInputDescription + ";相关动作要求时不能为空" +) func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl { name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") @@ -66,7 +71,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro } path := strings.TrimSpace(strings.TrimPrefix(raw, "@")) if path == "" || filepath.IsAbs(path) { - return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag)) + return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag)) } cwd, err := docGetwd() if err != nil { @@ -82,7 +87,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro } rel, err := docRel(realBase, realPath) if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") { - return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag)) + return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag)) } data, err := docReadFile(realPath) if err != nil { @@ -91,30 +96,198 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro return string(data), nil } +func validateWorkspaceInputPath(flag, raw string) error { + path := strings.TrimSpace(raw) + if path == "" || filepath.IsAbs(path) { + return apperrors.NewValidation(fmt.Sprintf("--%s 只接受工作目录内已暂存文件的相对路径", flag)) + } + cwd, err := docGetwd() + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err)) + } + realBase, err := docEvalSymlinks(cwd) + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err)) + } + realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path))) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err)) + } + rel, err := docRel(realBase, realPath) + if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") { + return apperrors.NewValidation(fmt.Sprintf("--%s 不能逃逸工作目录;请先把文件暂存到工作目录", flag)) + } + return nil +} + func validateJSONML(raw string) (string, error) { var value any if err := json.Unmarshal([]byte(raw), &value); err != nil { return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err)) } - if _, ok := value.([]any); !ok { + root, ok := value.([]any) + if !ok { return "", apperrors.NewValidation("JSONML 顶层必须是数组") } + if len(root) == 0 { + return "", apperrors.NewValidation("JSONML 顶层数组不能为空") + } + if _, nestedRoot := root[0].([]any); nestedRoot { + return "", apperrors.NewValidation("JSONML 顶层必须是单个元素,不能使用元素数组包裹;请传 [\"root\", {...}, ...] 或单个 block 元素") + } + if tag, ok := root[0].(string); !ok || strings.TrimSpace(tag) == "" { + return "", apperrors.NewValidation("JSONML 第一个元素必须是非空标签名") + } normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable return string(normalized), nil } func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any { return map[string]any{ - "ok": true, - "status": "success", - "operation": operation, - "steps": steps, - "data": data, - "warnings": []string{}, - "compensation": map[string]any{"available": false, "reason": ""}, + "contractVersion": "doc.operation.v1", + "ok": true, + "status": "success", + "complete": true, + "operation": operation, + "steps": steps, + "data": data, + "warnings": []string{}, + "compensation": map[string]any{"available": false, "reason": ""}, } } +type docFailureState string + +const ( + docFailureFailed docFailureState = "failed" + docFailureRetryable docFailureState = "retryable" + docFailureUnknown docFailureState = "unknown" +) + +type docFailureTransition struct { + state docFailureState + reason string + retryable bool + executionStarted bool + message string + actions []string +} + +func classifyDocWriteFailure(cause error) docFailureTransition { + transition := docFailureTransition{ + state: docFailureUnknown, + reason: "doc_write_commit_unknown", + retryable: false, + executionStarted: true, + message: "文档写入结果未知;为避免重复创建或重复追加,已停止自动重试", + actions: []string{"先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"}, + } + var typed *apperrors.Error + if !errors.As(cause, &typed) { + return transition + } + reason := strings.ToLower(strings.TrimSpace(typed.Reason)) + message := strings.ToLower(typed.Message) + if typed.Category == apperrors.CategoryValidation { + if reason == "" { + reason = "invalid_input" + } + return docFailureTransition{ + state: docFailureFailed, + reason: reason, + retryable: false, + executionStarted: false, + message: typed.Message, + actions: []string{"根据错误中的参数约束修正输入后重新执行"}, + } + } + permissionDenied := strings.Contains(reason, "403") || strings.Contains(reason, "permission") || + strings.Contains(message, "permission") || strings.Contains(message, "forbidden") || strings.Contains(message, "权限") + if permissionDenied { + return docFailureTransition{ + state: docFailureFailed, + reason: "permission_denied", + retryable: false, + executionStarted: false, + message: "当前身份没有执行该文档操作的权限;已终止,不会重试或改走同义命令", + actions: []string{"检查当前 profile 与文档权限", "获得权限后重新发起一次原操作"}, + } + } + if typed.Category == apperrors.CategoryAuth { + return docFailureTransition{ + state: docFailureFailed, + reason: "authentication_required", + retryable: false, + executionStarted: false, + message: "当前身份认证不可用;已终止文档写入", + actions: []string{"检查当前 profile 与登录状态", "认证恢复后重新发起一次原操作"}, + } + } + if typed.ExecutionStarted != nil && !*typed.ExecutionStarted { + transition.state = docFailureFailed + transition.executionStarted = false + transition.retryable = typed.RetryableSet && typed.Retryable + if transition.retryable { + transition.state = docFailureRetryable + } + if reason != "" { + transition.reason = reason + } else { + transition.reason = "doc_write_not_started" + } + transition.message = "文档写入已确认尚未开始" + transition.actions = []string{"仅在 retryable=true 时按服务端 retry-after 有界重试一次"} + } + return transition +} + +func docUnknownWriteError(operation, stage, nodeID string, cause error) error { + var existing *apperrors.Error + if errors.As(cause, &existing) { + if existing.Category == apperrors.CategoryValidation { + return cause + } + if status, ok := existing.Details["status"].(string); ok && (status == "unknown" || status == "partial_success") { + return cause + } + } + transition := classifyDocWriteFailure(cause) + details := map[string]any{ + "contractVersion": "doc.operation.v1", + "status": string(transition.state), + "state": string(transition.state), + "nodeId": nodeID, + "stage": stage, + } + options := []apperrors.Option{ + apperrors.WithOperation(operation), + apperrors.WithReason(transition.reason), + apperrors.WithFailureStage(stage), + apperrors.WithExecutionStarted(transition.executionStarted), + apperrors.WithRetryable(transition.retryable), + apperrors.WithActions(transition.actions...), + apperrors.WithDetails(details), + apperrors.WithCause(cause), + } + if transition.reason == "permission_denied" || transition.reason == "authentication_required" { + return apperrors.NewAuth(transition.message, options...) + } + return apperrors.NewAPI(transition.message, options...) +} + +func docVerificationError(operation, stage, nodeID string, cause error, steps []map[string]any) error { + return docPartialWriteError( + operation, + "doc_write_verification_failed", + stage, + "文档写入已经执行,但回读验证失败;请先检查当前内容,不要直接重试写入", + cause, + map[string]any{"nodeId": nodeID, "verified": false}, + steps, + map[string]any{"available": false, "reason": "inspect the current document before choosing recovery"}, + ) +} + func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error { return apperrors.NewAPI( message, @@ -125,10 +298,11 @@ func docPartialWriteError(operation, reason, stage, message string, cause error, apperrors.WithRetryable(false), apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"), apperrors.WithDetails(map[string]any{ - "status": "partial_success", - "data": data, - "steps": steps, - "compensation": compensation, + "contractVersion": "doc.operation.v1", + "status": "partial_success", + "data": data, + "steps": steps, + "compensation": compensation, }), apperrors.WithCause(cause), ) diff --git a/internal/shortcut/doc/content_shortcuts.go b/internal/shortcut/doc/content_shortcuts.go index 46686b02..6d3d781d 100644 --- a/internal/shortcut/doc/content_shortcuts.go +++ b/internal/shortcut/doc/content_shortcuts.go @@ -17,6 +17,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/docresolver" ) var ( @@ -48,7 +49,7 @@ var Create = shortcut.Shortcut{ ), Flags: []shortcut.Flag{ {Name: "name", Type: shortcut.FlagString, Desc: "新文档名称", Required: true}, - {Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@工作目录相对文件或 - 表示 stdin"}, + {Name: "content", Type: shortcut.FlagString, Desc: docContentInputDescription}, {Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文档文件夹 ID"}, {Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"}, @@ -73,29 +74,31 @@ var Create = shortcut.Shortcut{ if rt.Str("workspace") != "" { params["workspaceId"] = rt.Str("workspace") } + contentChunks := []string{content} if format == "markdown" && content != "" { - params["markdown"] = content + contentChunks = splitDocMarkdown(content, 10000) + params["markdown"] = contentChunks[0] } if rt.DryRun() { return rt.Output(docEnvelope("doc.create", map[string]any{"executed": false, "previewKind": "plan", "create": params, "docFormat": format, "contentBytes": len(content)})) } created, err := rt.CallMCPWriteData(productDoc, "create_document", params) if err != nil { - return err + return docUnknownWriteError("doc.create", "create_document", "", err) } nodeID := nestedString(created, "nodeId", "documentId", "id") steps := []map[string]any{{"name": "create_document", "status": "success"}} + if nodeID == "" { + return docPartialWriteError( + "doc.create", "doc_create_missing_node_id", "resolve_created_document", + "创建文档成功但响应缺少 nodeId;无法验证新文档,请先在钉钉中定位,不要直接重试", + nil, + map[string]any{"nodeId": "", "docFormat": format, "verified": false}, + append(steps, map[string]any{"name": "verify", "status": "not_started"}), + map[string]any{"available": false, "reason": "create_document did not return nodeId; locate the new document in DingTalk"}, + ) + } if format == "jsonml" && content != "" { - if nodeID == "" { - return docPartialWriteError( - "doc.create", "doc_create_missing_node_id", "resolve_created_document", - "创建文档成功但响应缺少 nodeId;JSONML 尚未写入,请先在钉钉中定位新文档,不要直接重试", - nil, - map[string]any{"nodeId": "", "docFormat": format}, - append(steps, map[string]any{"name": "write_jsonml", "status": "not_started"}), - map[string]any{"available": false, "reason": "create_document did not return nodeId; locate the new document in DingTalk"}, - ) - } if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": nodeID, "format": "jsonml", "jsonml": content, "mode": "overwrite"}); err != nil { return docPartialWriteError( "doc.create", "doc_create_initial_content_failed", "write_jsonml", @@ -108,26 +111,60 @@ var Create = shortcut.Shortcut{ } steps = append(steps, map[string]any{"name": "write_jsonml", "status": "success"}) } - return rt.Output(docEnvelope("doc.create", map[string]any{"nodeId": nodeID, "result": created}, steps...)) + if format == "markdown" && len(contentChunks) > 1 { + for index, chunk := range contentChunks[1:] { + stepName := fmt.Sprintf("append_chunk_%d", index+2) + if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": nodeID, "markdown": chunk, "mode": "append"}); err != nil { + return docPartialWriteError( + "doc.create", "doc_create_chunk_commit_unknown", stepName, + fmt.Sprintf("文档已创建,但第 %d/%d 个内容分片失败或提交状态未知;请先回读,不要重试整个创建", index+2, len(contentChunks)), + err, + map[string]any{"nodeId": nodeID, "chunksWritten": index + 1, "chunksTotal": len(contentChunks), "verified": false}, + append(steps, map[string]any{"name": stepName, "status": "unknown"}), + map[string]any{"available": false, "reason": "inspect the current document and resume only confirmed missing content"}, + ) + } + steps = append(steps, map[string]any{"name": stepName, "status": "success"}) + } + } + verifyTool := "get_document_info" + verifyParams := map[string]any{"nodeId": nodeID} + if content != "" { + verifyTool = "get_document_content" + verifyParams["format"] = format + } + verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams) + if err != nil { + return docVerificationError("doc.create", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"})) + } + if content != "" && !verifyUpdatedDocumentContent(verification, content, "overwrite", format) { + return docVerificationError("doc.create", "verify", nodeID, fmt.Errorf("回读结果与完整初始内容不一致"), append(steps, map[string]any{"name": "verify", "status": "failed"})) + } + steps = append(steps, map[string]any{"name": "verify", "status": "success"}) + return rt.Output(docEnvelope("doc.create", map[string]any{"nodeId": nodeID, "result": created, "verified": true, "verification": verification}, steps...)) }, } +const fetchTargetConstraint = "--node 与 --query 必须且只能提供一个" + var Fetch = shortcut.Shortcut{ Service: "doc", Command: "+fetch", Product: productDoc, Description: "读取完整或局部文档内容,并按 detail 控制保真度", - Intent: "当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。", + Intent: "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, Contract: docContract( "+fetch", "读取完整或局部文档内容,并按 detail 控制保真度", - "当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。", - []string{`dws doc +fetch --node `, `dws doc +fetch --node --detail with-ids --scope keyword --keyword "结论"`}, + "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。", + []string{`dws doc +fetch --node `, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`}, contract.ParamDecl{Name: "node", Property: "nodeId"}, + contract.ParamDecl{Name: "query", Property: "keyword"}, ), Flags: []shortcut.Flag{ - {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, + {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL;" + fetchTargetConstraint}, + {Name: "query", Type: shortcut.FlagString, Desc: "文档标题或关键词;跨页唯一解析后读取;" + fetchTargetConstraint}, {Name: "detail", Type: shortcut.FlagString, Default: "simple", Desc: "输出细节", Enum: []string{"simple", "with-ids", "full"}}, {Name: "scope", Type: shortcut.FlagString, Default: "full", Desc: "读取范围;keyword 时 --keyword 不能为空", Enum: []string{"full", "outline", "range", "section", "keyword", "tags"}}, {Name: "start-block-id", Type: shortcut.FlagString, Desc: "range/section 起始块 ID"}, @@ -139,7 +176,7 @@ var Fetch = shortcut.Shortcut{ {Name: "max-depth", Type: shortcut.FlagInt, Desc: "outline/section 最大深度"}, {Name: "revision", Type: shortcut.FlagInt, Desc: "只接受当前最新版;历史 revision 暂不支持"}, }, - Tips: []string{`dws doc +fetch --node `, `dws doc +fetch --node --detail with-ids --scope keyword --keyword "结论"`}, + Tips: []string{`dws doc +fetch --node `, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Changed("revision") { return apperrors.NewValidation("HISTORICAL_READ_UNSUPPORTED: 当前接口不能读取指定历史 revision") @@ -149,13 +186,20 @@ var Fetch = shortcut.Shortcut{ } return nil }, - Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"scope", "keyword"}, Description: "--scope keyword 时 --keyword 不能为空"}}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"node", "query"}, Description: fetchTargetConstraint}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"scope", "keyword"}, Description: "--scope keyword 时 --keyword 不能为空"}, + }, Execute: func(rt *shortcut.RuntimeContext) error { + target, err := docresolver.Resolve(rt, rt.Str("node"), rt.Str("query")) + if err != nil { + return err + } format := "markdown" if rt.Str("detail") != "simple" || rt.Str("scope") != "full" { format = "jsonml" } - params := map[string]any{"nodeId": rt.Str("node"), "format": format} + params := map[string]any{"nodeId": target.Selected.CanonicalID, "format": format} scope := rt.Str("scope") if scope != "keyword" && scope != "full" { params["scope"] = scope @@ -176,10 +220,17 @@ var Fetch = shortcut.Shortcut{ if err != nil { return err } + content := any(data) if scope == "keyword" { - return rt.Output(projectKeywordMatches(data, rt.Str("keyword"), rt.Int("context-before"), rt.Int("context-after"))) + content = projectKeywordMatches(data, rt.Str("keyword"), rt.Int("context-before"), rt.Int("context-after")) } - return rt.Output(data) + return rt.Output(map[string]any{ + "contractVersion": "doc.content.v1", + "status": "success", + "complete": true, + "target": target.Selected, + "content": content, + }) }, } @@ -221,17 +272,40 @@ var Inspect = shortcut.Shortcut{ {"include-media", "media", productDoc, "list_document_blocks", map[string]any{"nodeId": node, "format": "jsonml"}}, {"include-comments", "comments", productComment, "list_comments", map[string]any{"nodeId": node}}, } + steps := []map[string]any{{"name": "get_document_info", "status": "success"}} + failures := []map[string]any{} for _, read := range reads { if !rt.Bool(read.flag) { continue } - value, callErr := rt.CallMCPData(read.product, read.tool, read.params) + value, callErr := rt.CallMCPReadData(read.product, read.tool, read.params) if callErr != nil { - return callErr + failures = append(failures, map[string]any{"tool": read.tool, "error": callErr.Error()}) + steps = append(steps, map[string]any{"name": read.tool, "status": "failed"}) + continue } result[read.key] = value + steps = append(steps, map[string]any{"name": read.tool, "status": "success"}) } - return rt.Output(docEnvelope("doc.inspect", result, map[string]any{"name": "inspect", "status": "success"})) + if len(failures) > 0 { + return apperrors.NewAPI( + "文档聚合检查只完成了部分读取", + apperrors.WithOperation("doc.inspect"), + apperrors.WithReason("doc_inspect_partial"), + apperrors.WithFailureStage("optional_reads"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(true), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.operation.v1", + "status": "partial_success", + "complete": false, + "data": result, + "steps": steps, + "failures": failures, + }), + ) + } + return rt.Output(docEnvelope("doc.inspect", result, steps...)) }, } @@ -246,42 +320,67 @@ var Update = shortcut.Shortcut{ Contract: docContract("+update", "追加、覆盖或按 block 精确更新文档内容", "当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、block 插入/替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。", []string{`dws doc +update --node --command append --content "补充说明"`, `dws doc +update --node --command block_replace --block-id --content "新内容"`}, + contract.ParamDecl{Name: "node", Property: "node"}, + contract.ParamDecl{Name: "command", Property: "command"}, + contract.ParamDecl{Name: "content", Property: "content"}, + contract.ParamDecl{Name: "doc-format", Property: "docFormat"}, + contract.ParamDecl{Name: "block-id", Property: "blockId"}, + contract.ParamDecl{Name: "after-block-id", Property: "afterBlockId"}, + contract.ParamDecl{Name: "old", Property: "old"}, + contract.ParamDecl{Name: "new", Property: "new"}, + contract.ParamDecl{Name: "expected-revision", Property: "expectedRevision"}, contract.ParamDecl{Name: "doc", Property: "node"}, contract.ParamDecl{Name: "text", Property: "content"}), Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true, Aliases: []string{"doc"}, AliasesVisible: true}, {Name: "command", Type: shortcut.FlagString, Desc: "更新动作;不能为空", Enum: []string{"append", "overwrite", "block_insert_after", "block_replace", "block_delete", "str_replace", "block_copy_insert_after"}}, - {Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@相对文件或 - 表示 stdin;相关动作要求时不能为空", Aliases: []string{"text"}, AliasesVisible: true}, + {Name: "content", Type: shortcut.FlagString, Desc: docRequiredContentInputDescription, Aliases: []string{"text"}, AliasesVisible: true}, {Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}}, {Name: "block-id", Type: shortcut.FlagString, Desc: "目标或源 block ID;相关动作要求时不能为空"}, - {Name: "after-block-id", Type: shortcut.FlagString, Desc: "插入位置参考 block ID"}, + {Name: "after-block-id", Type: shortcut.FlagString, Desc: "插入位置参考 block ID;相关动作要求时不能为空"}, {Name: "old", Type: shortcut.FlagString, Desc: "str_replace 原文字,不能为空"}, {Name: "new", Type: shortcut.FlagString, Desc: "str_replace 新文字;--old 不能为空,新值可为空但参数必须显式提供"}, - {Name: "expected-revision", Type: shortcut.FlagInt, Desc: "best-effort 乐观 revision 检查"}, + {Name: "expected-revision", Type: shortcut.FlagInt, Desc: "仅 overwrite+jsonml:传给服务端执行原子 revision 条件写"}, }, Tips: []string{`dws doc +update --node --command append --content "补充说明"`, `dws doc +update --node --command block_replace --block-id --content "新内容"`}, Validate: func(rt *shortcut.RuntimeContext) error { command := rt.Str("command") - if command == "" { - return apperrors.NewValidation("缺少 --command") - } if rt.StrFirst("node", "doc") == "" { return apperrors.NewValidation("缺少 --node") } - if command == "append" || command == "overwrite" || command == "block_insert_after" || command == "block_replace" { + if command == "" { + return apperrors.NewValidation("缺少 --command") + } + switch command { + case "append", "overwrite", "block_insert_after", "block_replace": if rt.StrFirst("content", "text") == "" { return apperrors.NewValidation("该更新动作的 --content 不能为空") } } - if strings.HasPrefix(command, "block_") && command != "block_insert_after" && rt.Str("block-id") == "" { - return apperrors.NewValidation("该 block 操作必须提供 --block-id") + switch command { + case "block_replace", "block_delete", "block_copy_insert_after": + if rt.Str("block-id") == "" { + return apperrors.NewValidation("该 block 操作必须提供 --block-id") + } + } + switch command { + case "block_insert_after", "block_copy_insert_after": + if rt.Str("after-block-id") == "" { + return apperrors.NewValidation("该 block 操作必须提供 --after-block-id") + } } if command == "str_replace" && (rt.Str("old") == "" || !rt.Changed("new")) { return apperrors.NewValidation("--command str_replace 必须同时提供 --old 和 --new") } + if command == "append" && rt.Str("doc-format") == "jsonml" { + return apperrors.NewValidation("JSONML 当前不支持 append") + } + if rt.Changed("expected-revision") && (command != "overwrite" || rt.Str("doc-format") != "jsonml") { + return apperrors.NewValidation("--expected-revision 仅支持 --command overwrite --doc-format jsonml;其他写入接口没有服务端原子 revision 契约") + } return nil }, - Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"command", "content", "block-id", "old", "new"}, Description: "依 command 校验,所需文本参数不能为空"}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"command", "content", "block-id", "after-block-id", "old", "new"}, Description: "依 command 校验,所需文本或 block 参数不能为空"}}, Execute: executeUpdate, } @@ -299,7 +398,7 @@ var CheckpointUpdate = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, {Name: "mode", Type: shortcut.FlagString, Default: "append", Desc: "更新模式", Enum: []string{"append", "overwrite"}}, - {Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@相对文件或 - 表示 stdin", Required: true}, + {Name: "content", Type: shortcut.FlagString, Desc: docContentInputDescription, Required: true}, }, Tips: []string{`dws doc +checkpoint-update --node --mode append --content @section.md`, `dws doc +checkpoint-update --node --mode overwrite --content @document.md`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -323,13 +422,17 @@ var CheckpointUpdate = shortcut.Shortcut{ append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"})) } steps = append(steps, map[string]any{"name": "update", "status": "success"}) - verified, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}) + verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}) if err != nil { return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err, append(steps, map[string]any{"name": "verify", "status": "failed"})) } + if !verifyUpdatedDocumentContent(verification, content, rt.Str("mode"), "markdown") { + return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", fmt.Errorf("回读结果未匹配预期变更"), + append(steps, map[string]any{"name": "verify", "status": "failed"})) + } steps = append(steps, map[string]any{"name": "verify", "status": "success"}) - return rt.Output(docEnvelope("doc.checkpoint_update", map[string]any{"verified": verified}, steps...)) + return rt.Output(docEnvelope("doc.checkpoint_update", map[string]any{"nodeId": rt.Str("node"), "verified": true, "verification": verification}, steps...)) }, } @@ -338,15 +441,15 @@ var Export = shortcut.Shortcut{ Command: "+export", Product: productDoc, Description: "提交、轮询并安全下载在线文档导出文件", - Intent: "当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载。", + Intent: "当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载;失败后保留 jobId 通过 +export-get 恢复,不改用 curl 或本地生成。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, Contract: docContract("+export", "提交、轮询并安全下载在线文档导出文件", - "当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载。", + "当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载;失败后保留 jobId 通过 +export-get 恢复,不改用 curl 或本地生成。", []string{`dws doc +export --node --export-format docx --output ./exports/`, `dws doc +export --node --export-format markdown --output ./document.md`}), Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, - {Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}}, + {Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式;省略时默认为 docx,不能用全局 --format 代替", Enum: []string{"docx", "markdown", "pdf"}}, {Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"}, {Name: "max-polls", Type: shortcut.FlagInt, Default: "30", Desc: "最大轮询次数"}, }, @@ -361,21 +464,27 @@ var Import = shortcut.Shortcut{ Command: "+import", Product: productDoc, Description: "上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库", - Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。白名单外格式(html/pdf 等)不报错,自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。", + Intent: "当用户要把工作目录内的 doc/docx/xls/xlsx/md/txt/xmind/mark 相对路径文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用;白名单外格式(html/pdf 等)自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}, Contract: docContract("+import", "上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库", - "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。白名单外格式(html/pdf 等)不报错,自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。", - []string{`dws doc +import --file ./report.docx --folder `, `dws doc +import --file ./notes.md --workspace --name "会议纪要"`}), + "当用户要把工作目录内的 doc/docx/xls/xlsx/md/txt/xmind/mark 相对路径文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用;白名单外格式(html/pdf 等)自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。", + []string{`dws doc +import --file ./report.docx`, `dws doc +import --file ./notes.md --workspace --name "会议纪要"`}), Flags: []shortcut.Flag{ - {Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true}, - {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}, - {Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"}, + {Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在文件的相对路径", Required: true}, + {Name: "folder", Type: shortcut.FlagString, Desc: "可选目标文件夹 ID;folder/workspace 都省略时导入默认根目录"}, + {Name: "workspace", Type: shortcut.FlagString, Desc: "可选目标知识库 ID;folder/workspace 都省略时导入默认根目录"}, {Name: "name", Type: shortcut.FlagString, Desc: "导入后名称"}, }, - Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"folder", "workspace"}, Description: "--folder 与 --workspace 至少提供一个导入目标"}}, - Tips: []string{`dws doc +import --file ./report.docx --folder `, `dws doc +import --file ./notes.md --workspace --name "会议纪要"`}, - Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocImportShortcut(rt.Command()) }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "--file 必须是工作目录内已存在且不通过符号链接逃逸的相对路径"}}, + Tips: []string{`dws doc +import --file ./report.docx`, `dws doc +import --file ./notes.md --workspace --name "会议纪要"`}, + Validate: func(rt *shortcut.RuntimeContext) error { return validateWorkspaceInputPath("file", rt.Str("file")) }, + Execute: func(rt *shortcut.RuntimeContext) error { + if err := helpers.RunDocImportShortcut(rt.Command()); err != nil { + return docUnknownWriteError("doc.import", "import", "", err) + } + return nil + }, } func executeUpdate(rt *shortcut.RuntimeContext) error { @@ -395,26 +504,10 @@ func executeUpdate(rt *shortcut.RuntimeContext) error { } } nodeID := rt.StrFirst("node", "doc") - currentRevision := 0 - if rt.Changed("expected-revision") { - current, revisionErr := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": "jsonml"}) - if revisionErr != nil { - return revisionErr - } - var found bool - currentRevision, found = nestedRevision(current) - if !found { - return apperrors.NewAPI("REVISION_CONFLICT: 服务响应缺少当前 revision,无法安全执行乐观更新") - } - if expected := rt.Int("expected-revision"); currentRevision != expected { - return apperrors.NewValidation(fmt.Sprintf("REVISION_CONFLICT: 期望 revision %d,当前为 %d", expected, currentRevision)) - } - } plan := map[string]any{"nodeId": nodeID, "command": command, "blockId": rt.Str("block-id"), "afterBlockId": rt.Str("after-block-id"), "contentBytes": len(content)} if rt.Changed("expected-revision") { plan["expectedRevision"] = rt.Int("expected-revision") - plan["currentRevision"] = currentRevision - plan["optimisticCheck"] = "best_effort" + plan["optimisticCheck"] = "server_enforced" } if rt.DryRun() { plan["executed"] = false @@ -429,28 +522,46 @@ func executeUpdate(rt *shortcut.RuntimeContext) error { return apperrors.NewValidation("JSONML 当前不支持 append") } params["format"], params["jsonml"] = "jsonml", content + if rt.Changed("expected-revision") { + params["revision"] = rt.Int("expected-revision") + } } else { params["markdown"] = content } - return rt.CallMCP("update_document", params) + return executeVerifiedDocContentMutation(rt, params, node, content, command, rt.Str("doc-format")) case "block_insert_after": + verificationFormat := blockVerificationFormat(rt.Str("doc-format")) params := map[string]any{"nodeId": node, "referenceBlockId": rt.Str("after-block-id"), "where": "after"} if rt.Str("doc-format") == "jsonml" { params["format"], params["jsonml"] = "jsonml", content } else { params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}} } - return rt.CallMCP("insert_document_block", params) + referenceBlockID := rt.Str("after-block-id") + return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", params, node, + "list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat}, + func(result, data map[string]any) bool { + return verifyInsertedBlock(result, data, referenceBlockID, content, rt.Str("doc-format")) + }) case "block_replace": + blockID := rt.Str("block-id") + verificationFormat := blockVerificationFormat(rt.Str("doc-format")) params := map[string]any{"nodeId": node, "blockId": rt.Str("block-id")} if rt.Str("doc-format") == "jsonml" { params["format"], params["jsonml"] = "jsonml", content } else { params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}} } - return rt.CallMCP("update_document_block", params) + return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", params, node, + "list_document_blocks", map[string]any{"nodeId": node, "blockId": blockID, "format": verificationFormat}, + func(_, data map[string]any) bool { + return blockContentEquals(data, blockID, content, rt.Str("doc-format")) + }) case "block_delete": - return rt.CallMCP("delete_document_block", map[string]any{"nodeId": node, "blockId": rt.Str("block-id")}) + blockID := rt.Str("block-id") + return executeVerifiedDocMutation(rt, "doc.update", "delete_document_block", map[string]any{"nodeId": node, "blockId": blockID}, node, + "list_document_blocks", map[string]any{"nodeId": node, "format": "element"}, + func(_, data map[string]any) bool { return findBlock(data, blockID) == nil }) case "str_replace": return executePlainTextReplace(rt, node) case "block_copy_insert_after": @@ -529,7 +640,11 @@ func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error { return apperrors.NewValidation(fmt.Sprintf("UNSAFE_RICH_TEXT_REPLACE: 需要唯一普通文本块匹配,实际 %d 处", len(matches))) } updated := strings.Replace(matches[0].text, oldText, rt.Str("new"), 1) - return rt.CallMCP("update_document_block", map[string]any{"nodeId": nodeID, "blockId": matches[0].blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}}) + blockID := matches[0].blockID + return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", + map[string]any{"nodeId": nodeID, "blockId": blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}}, nodeID, + "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": blockID, "format": "element"}, + func(_, data map[string]any) bool { return blockContentEquals(data, blockID, updated, "markdown") }) } func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error { @@ -544,8 +659,463 @@ func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error { if containsResourceReference(block) { return apperrors.NewValidation("UNSUPPORTED_RESOURCE_TYPE: 含资源引用的 block 暂不支持复制") } + expectedContent := canonicalBlockContent(block, "markdown") stripBlockIDs(block) - return rt.CallMCP("insert_document_block", map[string]any{"nodeId": nodeID, "referenceBlockId": rt.Str("after-block-id"), "where": "after", "element": block}) + referenceBlockID := rt.Str("after-block-id") + return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", + map[string]any{"nodeId": nodeID, "referenceBlockId": referenceBlockID, "where": "after", "element": block}, nodeID, + "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"}, + func(result, data map[string]any) bool { + return verifyInsertedCanonicalBlock(result, data, referenceBlockID, expectedContent, "markdown") + }) +} + +func executeVerifiedDocMutation( + rt *shortcut.RuntimeContext, + operation, tool string, + params map[string]any, + nodeID, verifyTool string, + verifyParams map[string]any, + verify func(map[string]any, map[string]any) bool, +) error { + steps := []map[string]any{{"name": tool, "status": "started"}} + result, err := rt.CallMCPWriteData(productDoc, tool, params) + if err != nil { + return docUnknownWriteError(operation, tool, nodeID, err) + } + steps[0]["status"] = "success" + verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams) + if err != nil { + return docVerificationError(operation, "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"})) + } + if verify != nil && !verify(result, verification) { + return docVerificationError(operation, "verify", nodeID, fmt.Errorf("回读结果未匹配预期变更"), append(steps, map[string]any{"name": "verify", "status": "failed"})) + } + steps = append(steps, map[string]any{"name": "verify", "status": "success"}) + return rt.Output(docEnvelope(operation, map[string]any{ + "nodeId": nodeID, + "verified": true, + "result": result, + "verification": verification, + }, steps...)) +} + +func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams map[string]any, nodeID, content, mode, format string) error { + chunks := []string{content} + if format == "markdown" { + chunks = splitDocMarkdown(content, 10000) + firstParams["markdown"] = chunks[0] + } + steps := make([]map[string]any, 0, len(chunks)+1) + for index, chunk := range chunks { + params := firstParams + if index > 0 { + params = map[string]any{"nodeId": nodeID, "mode": "append", "markdown": chunk} + } + stepName := "update_document" + if len(chunks) > 1 { + stepName = fmt.Sprintf("write_chunk_%d", index+1) + } + result, err := rt.CallMCPWriteData(productDoc, "update_document", params) + if err != nil { + if index == 0 { + return docUnknownWriteError("doc.update", stepName, nodeID, err) + } + return docPartialWriteError( + "doc.update", "doc_update_chunk_commit_unknown", stepName, + fmt.Sprintf("文档已写入 %d/%d 个分片,但当前分片失败或提交状态未知;请先回读,不要重放已完成分片", index, len(chunks)), + err, + map[string]any{"nodeId": nodeID, "mode": mode, "chunksWritten": index, "chunksTotal": len(chunks), "lastResult": result, "verified": false}, + append(steps, map[string]any{"name": stepName, "status": "unknown"}), + map[string]any{"available": false, "reason": "inspect current content before resuming from a confirmed missing boundary"}, + ) + } + steps = append(steps, map[string]any{"name": stepName, "status": "success"}) + } + verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}) + if err != nil { + return docVerificationError("doc.update", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"})) + } + if !verifyUpdatedDocumentContent(verification, content, mode, format) { + return docVerificationError("doc.update", "verify", nodeID, fmt.Errorf("回读结果未包含预期内容"), append(steps, map[string]any{"name": "verify", "status": "failed"})) + } + steps = append(steps, map[string]any{"name": "verify", "status": "success"}) + return rt.Output(docEnvelope("doc.update", map[string]any{ + "nodeId": nodeID, "mode": mode, "chunksWritten": len(chunks), "verified": true, "verification": verification, + }, steps...)) +} + +func splitDocMarkdown(content string, maxRunes int) []string { + if maxRunes <= 0 { + return []string{content} + } + runes := []rune(content) + if len(runes) <= maxRunes { + return []string{content} + } + chunks := make([]string, 0, (len(runes)+maxRunes-1)/maxRunes) + for start := 0; start < len(runes); { + end := start + maxRunes + if end >= len(runes) { + end = len(runes) + } else { + for split := end; split > start; split-- { + if runes[split-1] == '\n' { + end = split + break + } + } + } + chunks = append(chunks, string(runes[start:end])) + start = end + } + return chunks +} + +func containsText(value any, needle string) bool { + if needle == "" { + return true + } + switch typed := value.(type) { + case string: + return strings.Contains(typed, needle) + case map[string]any: + for _, child := range typed { + if containsText(child, needle) { + return true + } + } + case []any: + for _, child := range typed { + if containsText(child, needle) { + return true + } + } + } + return false +} + +func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool { + expected = normalizeDocumentContentForVerification(expected, format) + for _, candidate := range documentContentCandidates(value, format) { + actual := normalizeDocumentContentForVerification(candidate, format) + if mode == "overwrite" { + if actual == expected { + return true + } + continue + } + if actual == expected || strings.HasSuffix(actual, "\n"+expected) { + return true + } + } + return false +} + +func verifyInsertedBlock(result, data map[string]any, referenceBlockID, expected, format string) bool { + return verifyInsertedCanonicalBlock(result, data, referenceBlockID, normalizeDocumentContentForVerification(expected, format), format) +} + +func blockVerificationFormat(format string) string { + if format == "jsonml" { + return "jsonml" + } + return "element" +} + +func verifyInsertedCanonicalBlock(result, data map[string]any, referenceBlockID, expected, format string) bool { + if insertedID := nestedString(result, "blockId", "elementId", "id"); insertedID != "" { + if blockContentEquals(data, insertedID, expected, format) { + return true + } + } + blocks := orderedCanonicalBlocks(data, format) + for index, block := range blocks { + if canonicalBlockIdentity(block, format) != referenceBlockID || index+1 >= len(blocks) { + continue + } + return canonicalBlockContent(blocks[index+1], format) == expected + } + return false +} + +func blockContentEquals(data map[string]any, blockID, expected, format string) bool { + block := findCanonicalBlock(data, blockID, format) + if block == nil { + return false + } + return canonicalBlockContent(block, format) == normalizeDocumentContentForVerification(expected, format) +} + +func canonicalBlockContent(value any, format string) string { + if format == "jsonml" { + if values, ok := value.(map[string]any); ok { + if encoded, ok := values["jsonml"].(string); ok { + return normalizeJSONMLForVerification(encoded) + } + } + if encoded, ok := value.(string); ok { + return normalizeJSONMLForVerification(encoded) + } + if encoded, err := json.Marshal(value); err == nil { + return normalizeJSONMLForVerification(string(encoded)) + } + } + texts := make([]string, 0, 4) + var walk func(any) + walk = func(current any) { + switch typed := current.(type) { + case map[string]any: + if text, ok := typed["text"].(string); ok { + texts = append(texts, text) + return + } + for key, child := range typed { + if key == "id" || key == "blockId" || key == "uuid" { + continue + } + walk(child) + } + case []any: + start := 0 + if len(typed) > 0 { + if _, isTag := typed[0].(string); isTag { + start = 1 + } + } + for _, child := range typed[start:] { + walk(child) + } + case string: + texts = append(texts, typed) + } + } + walk(value) + return normalizeMarkdownForVerification(strings.Join(texts, "\n")) +} + +func orderedCanonicalBlocks(value any, format string) []any { + if format == "jsonml" { + blocks := orderedJSONMLBlocks(value) + result := make([]any, len(blocks)) + for index := range blocks { + result[index] = blocks[index] + } + return result + } + blocks := orderedDocumentBlocks(value) + result := make([]any, len(blocks)) + for index := range blocks { + result[index] = blocks[index] + } + return result +} + +func canonicalBlockIdentity(value any, format string) string { + if format == "jsonml" { + if element, ok := value.([]any); ok { + return jsonMLBlockIdentity(element) + } + return "" + } + if block, ok := value.(map[string]any); ok { + return blockIdentity(block, "") + } + return "" +} + +func findCanonicalBlock(value any, target, format string) any { + if format == "jsonml" { + block := findJSONMLBlock(value, target) + if block == nil { + return nil + } + return block + } + block := findBlock(value, target) + if block == nil { + return nil + } + return block +} + +func orderedJSONMLBlocks(value any) [][]any { + blocks := [][]any{} + var walk func(any) + walk = func(current any) { + switch typed := current.(type) { + case map[string]any: + for key, child := range typed { + if encoded, ok := child.(string); ok && isJSONMLPayloadKey(key) { + var decoded any + if json.Unmarshal([]byte(encoded), &decoded) == nil { + walk(decoded) + } + continue + } + walk(child) + } + case []any: + if jsonMLBlockIdentity(typed) != "" { + blocks = append(blocks, typed) + } + start := 0 + if len(typed) > 0 { + if _, ok := typed[0].(string); ok { + start = 1 + } + } + for _, child := range typed[start:] { + walk(child) + } + } + } + walk(value) + return blocks +} + +func findJSONMLBlock(value any, target string) []any { + for _, block := range orderedJSONMLBlocks(value) { + if jsonMLBlockIdentity(block) == target { + return block + } + } + return nil +} + +func jsonMLBlockIdentity(element []any) string { + if len(element) < 2 { + return "" + } + attributes, ok := element[1].(map[string]any) + if !ok { + return "" + } + return nestedString(attributes, "uuid", "blockId", "elementId", "id") +} + +func isJSONMLPayloadKey(key string) bool { + normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", "")) + return normalized == "jsonml" || normalized == "content" +} + +func orderedDocumentBlocks(value any) []map[string]any { + blocks := []map[string]any{} + var walk func(any) + walk = func(current any) { + switch typed := current.(type) { + case map[string]any: + if blockIdentity(typed, "") != "" { + blocks = append(blocks, typed) + return + } + for _, child := range typed { + walk(child) + } + case []any: + for _, child := range typed { + walk(child) + } + } + } + walk(value) + return blocks +} + +func documentContentCandidates(value any, format string) []string { + wanted := map[string]bool{"content": true} + if format == "jsonml" { + wanted["jsonml"] = true + } else { + wanted["markdown"] = true + } + var candidates []string + var walk func(any, bool) + walk = func(current any, root bool) { + switch typed := current.(type) { + case string: + if root { + candidates = append(candidates, typed) + } + case map[string]any: + for key, child := range typed { + normalizedKey := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", "")) + if text, ok := child.(string); ok && wanted[normalizedKey] { + candidates = append(candidates, text) + } + walk(child, false) + } + case []any: + for _, child := range typed { + walk(child, false) + } + } + } + walk(value, true) + return candidates +} + +func normalizeDocumentContentForVerification(raw, format string) string { + if format == "jsonml" { + return normalizeJSONMLForVerification(raw) + } + return normalizeMarkdownForVerification(raw) +} + +func normalizeMarkdownForVerification(raw string) string { + raw = strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") + lines := make([]string, 0, strings.Count(raw, "\n")+1) + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.Contains(line, "|") { + parts := strings.Split(line, "|") + for index := range parts { + parts[index] = strings.Join(strings.Fields(parts[index]), " ") + } + line = strings.Join(parts, "|") + } else { + line = strings.Join(strings.Fields(line), " ") + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +func normalizeJSONMLForVerification(raw string) string { + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return normalizeMarkdownForVerification(raw) + } + var tokens []string + var walk func(any) + walk = func(current any) { + switch typed := current.(type) { + case []any: + start := 0 + if len(typed) > 0 { + if tag, ok := typed[0].(string); ok { + tokens = append(tokens, "<"+strings.ToLower(strings.TrimSpace(tag))+">") + start = 1 + } + } + for _, child := range typed[start:] { + walk(child) + } + case map[string]any: + // JSONML maps contain element attributes. Server-generated UUIDs and + // default attributes do not change the authored document content. + return + case string: + if text := normalizeMarkdownForVerification(typed); text != "" { + tokens = append(tokens, text) + } + } + } + walk(value) + return strings.Join(tokens, "\n") } func executeExport(rt *shortcut.RuntimeContext) error { @@ -555,13 +1125,13 @@ func executeExport(rt *shortcut.RuntimeContext) error { plan["steps"] = []string{"submit_export_job", "query_export_job", "safe_atomic_download"} return rt.Output(docEnvelope("doc.export", plan)) } - submit, err := rt.CallMCPData(productDoc, "submit_export_job", map[string]any{"nodeId": rt.Str("node"), "exportFormat": rt.Str("export-format")}) + submit, err := rt.CallMCPWriteData(productDoc, "submit_export_job", map[string]any{"nodeId": rt.Str("node"), "exportFormat": rt.Str("export-format")}) if err != nil { - return err + return docUnknownWriteError("doc.export", "submit_export_job", rt.Str("node"), err) } jobID := nestedString(submit, "jobId", "jobID") if jobID == "" { - return apperrors.NewAPI("导出任务响应缺少 jobId") + return docExportRecoveryError("doc_export_missing_job_id", "submit", "导出任务已提交但响应缺少 jobId;禁止重新提交", "", nil) } maxPolls := rt.Int("max-polls") if maxPolls <= 0 { @@ -571,29 +1141,29 @@ func executeExport(rt *shortcut.RuntimeContext) error { for attempt := 1; attempt <= maxPolls; attempt++ { query, err = rt.CallMCPData(productDoc, "query_export_job", map[string]any{"jobId": jobID}) if err != nil { - return err + return docExportRecoveryError("doc_export_poll_failed", "poll", "导出任务轮询失败;请使用现有 jobId 恢复查询,不要重新提交", jobID, err) } status := strings.ToUpper(nestedString(query, "status")) if status == "SUCCESS" { break } - if status != "PROCESSING" { - return apperrors.NewAPI(fmt.Sprintf("导出任务失败 (jobId=%s, status=%s): %s", jobID, status, nestedString(query, "message"))) + if !docExportStatusPollable(status) { + return docExportRecoveryError("doc_export_job_failed", "poll", fmt.Sprintf("导出任务失败 (status=%s): %s", status, nestedString(query, "message")), jobID, nil) } if attempt == maxPolls { - return apperrors.NewAPI(fmt.Sprintf("导出任务超时 (jobId=%s),可用 doc +export-get 恢复查询", jobID)) + return docExportRecoveryError("doc_export_poll_timeout", "poll", "导出任务仍在处理中;请使用现有 jobId 恢复查询,不要重新提交", jobID, nil) } timer := time.NewTimer(time.Duration(min(attempt, 5)) * time.Second) select { case <-rt.Command().Context().Done(): timer.Stop() - return rt.Command().Context().Err() + return docExportRecoveryError("doc_export_poll_cancelled", "poll", "导出等待被中断;请使用现有 jobId 恢复查询,不要重新提交", jobID, rt.Command().Context().Err()) case <-timer.C: } } downloadURL := nestedString(query, "downloadUrl", "resourceUrl") if downloadURL == "" { - return apperrors.NewAPI(fmt.Sprintf("导出成功但响应缺少 downloadUrl (jobId=%s)", jobID)) + return docExportRecoveryError("doc_export_missing_download_url", "download", "导出任务已完成但响应缺少下载地址;请使用现有 jobId 重新查询", jobID, nil) } cwd, err := docGetwd() if err != nil { @@ -603,12 +1173,102 @@ func executeExport(rt *shortcut.RuntimeContext) error { preferred := "document" + ext result, err := docDownload(rt.Command().Context(), downloadURL, localio.DownloadOptions{BaseDir: cwd, Output: rt.Str("output"), PreferredName: preferred}) if err != nil { - return err + return docExportRecoveryError("doc_export_download_failed", "download", fmt.Sprintf("导出任务已完成但安全下载失败(output=%s)", rt.Str("output")), jobID, err) } return rt.Output(docEnvelope("doc.export", map[string]any{"jobId": jobID, "localPath": result.RelativePath, "sizeBytes": result.SizeBytes}, map[string]any{"name": "submit", "status": "success"}, map[string]any{"name": "poll", "status": "success"}, map[string]any{"name": "download", "status": "success"})) } +func docExportStatusPollable(status string) bool { + switch strings.ToUpper(strings.TrimSpace(status)) { + case "INIT", "PROCESSING": + return true + default: + return false + } +} + +func executeExportGet(rt *shortcut.RuntimeContext) error { + jobID := rt.Str("job-id") + query, err := rt.CallMCPData(productDoc, "query_export_job", map[string]any{"jobId": jobID}) + if err != nil { + return docExportRecoveryError("doc_export_query_failed", "query", "查询导出任务失败;保留 jobId 后停止", jobID, err) + } + status := strings.ToUpper(nestedString(query, "status")) + if status == "" { + status = "UNKNOWN" + } + if status == "FAILED" || status == "CANCELLED" { + return docExportRecoveryError("doc_export_job_failed", "query", fmt.Sprintf("导出任务状态为 %s: %s", status, nestedString(query, "message")), jobID, nil) + } + if rt.Str("output") == "" || status != "SUCCESS" { + return rt.Output(map[string]any{ + "contractVersion": "doc.operation.v1", + "ok": true, + "status": strings.ToLower(status), + "complete": status == "SUCCESS", + "operation": "doc.export_get", + "data": map[string]any{"jobId": jobID, "result": query}, + }) + } + downloadURL := nestedString(query, "downloadUrl", "resourceUrl") + if downloadURL == "" { + return docExportRecoveryError("doc_export_missing_download_url", "download", "导出任务已完成但响应缺少下载地址", jobID, nil) + } + cwd, err := docGetwd() + if err != nil { + return err + } + result, err := docDownload(rt.Command().Context(), downloadURL, localio.DownloadOptions{ + BaseDir: cwd, Output: rt.Str("output"), PreferredName: "document", + }) + if err != nil { + return docExportRecoveryError("doc_export_download_failed", "download", fmt.Sprintf("导出任务已完成但安全下载失败(output=%s)", rt.Str("output")), jobID, err) + } + return rt.Output(docEnvelope("doc.export_get", map[string]any{ + "jobId": jobID, "localPath": result.RelativePath, "sizeBytes": result.SizeBytes, "verified": result.SizeBytes > 0, + }, map[string]any{"name": "query", "status": "success"}, map[string]any{"name": "download", "status": "success"})) +} + +func docExportRecoveryError(reason, stage, message, jobID string, cause error) error { + status := "incomplete" + if jobID == "" { + status = "unknown" + } + details := map[string]any{ + "contractVersion": "doc.operation.v1", + "status": status, + "jobId": jobID, + "stage": stage, + } + options := []apperrors.Option{ + apperrors.WithOperation("doc.export"), + apperrors.WithReason(reason), + apperrors.WithFailureStage(stage), + apperrors.WithRetryable(false), + apperrors.WithDetails(details), + } + if jobID != "" { + options = append(options, + apperrors.WithExecutionStarted(true), + apperrors.WithActions( + fmt.Sprintf("dws doc +export-get --job-id %s", jobID), + "需要保存文件时给 +export-get 追加工作目录内的 --output 相对路径", + "不要重新提交导出,不要 curl 临时下载地址,不要安装本地转换依赖", + ), + ) + } else { + options = append(options, + apperrors.WithExecutionStarted(true), + apperrors.WithActions("先检查文档空间中是否已有导出任务;不要直接重新提交"), + ) + } + if cause != nil { + options = append(options, apperrors.WithCause(cause)) + } + return apperrors.NewAPI(message, options...) +} + func projectKeywordMatches(data map[string]any, rawQuery string, before, after int) map[string]any { queries := stringSliceNonEmpty(strings.Split(rawQuery, "|")) if before <= 0 { diff --git a/internal/shortcut/doc/coverage_final_test.go b/internal/shortcut/doc/coverage_final_test.go new file mode 100644 index 00000000..13c12c9e --- /dev/null +++ b/internal/shortcut/doc/coverage_final_test.go @@ -0,0 +1,339 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.WriteFile("staged.md", []byte("body"), 0o600); err != nil { + t.Fatal(err) + } + if err := validateWorkspaceInputPath("file", "staged.md"); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + stub func(*testing.T) + }{ + {"getwd", func(t *testing.T) { + testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") }) + }}, + {"base symlink", func(t *testing.T) { + testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("base") }) + }}, + {"file symlink", func(t *testing.T) { + calls := 0 + testseam.Swap(t, &docEvalSymlinks, func(path string) (string, error) { + calls++ + if calls == 2 { + return "", errors.New("file") + } + return path, nil + }) + }}, + {"relative escape", func(t *testing.T) { + testseam.Swap(t, &docRel, func(string, string) (string, error) { return "../escape", nil }) + }}, + {"relative error", func(t *testing.T) { + testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") }) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + tc.stub(t) + if err := validateWorkspaceInputPath("file", "staged.md"); err == nil { + t.Fatal("expected validation failure") + } + }) + } + if _, err := validateJSONML(`["",{}]`); err == nil { + t.Fatal("empty JSONML tag succeeded") + } + + validation := classifyDocWriteFailure(apperrors.NewValidation("bad")) + if validation.reason != "invalid_input" || validation.executionStarted { + t.Fatalf("validation transition = %#v", validation) + } + permission := classifyDocWriteFailure(apperrors.NewAPI("forbidden")) + if permission.reason != "permission_denied" { + t.Fatalf("permission transition = %#v", permission) + } + auth := classifyDocWriteFailure(apperrors.NewAuth("login")) + if auth.reason != "authentication_required" { + t.Fatalf("auth transition = %#v", auth) + } + notStarted := classifyDocWriteFailure(apperrors.NewAPI("later", apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true))) + if notStarted.state != docFailureRetryable || notStarted.reason != "doc_write_not_started" { + t.Fatalf("retryable transition = %#v", notStarted) + } + for _, cause := range []error{apperrors.NewAPI("permission denied"), apperrors.NewAuth("login")} { + var typed *apperrors.Error + if err := docUnknownWriteError("doc.test", "write", "n", cause); !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth { + t.Fatalf("auth-classified error = %#v", err) + } + } + for _, existing := range []error{ + apperrors.NewValidation("existing"), + apperrors.NewAPI("existing", apperrors.WithDetails(map[string]any{"status": "partial_success"})), + } { + if got := docUnknownWriteError("doc.test", "write", "n", existing); got != existing { + t.Fatalf("existing classified error was wrapped: %p != %p", got, existing) + } + } + + if !containsText(nil, "") || !containsText("alpha", "ph") || containsText("alpha", "z") || + !containsText(map[string]any{"nested": "alpha"}, "alpha") || containsText(map[string]any{"nested": "beta"}, "alpha") || + !containsText([]any{"alpha"}, "alpha") || containsText([]any{"beta"}, "alpha") || containsText(3, "alpha") { + t.Fatal("containsText branch contract failed") + } + + jsonElement := []any{"p", map[string]any{"uuid": "b"}, "text"} + jsonPayload := map[string]any{"jsonml": `["p",{"uuid":"b"},"text"]`} + for _, value := range []any{jsonPayload, `["p",{"uuid":"b"},"text"]`, jsonElement} { + if canonicalBlockContent(value, "jsonml") == "" { + t.Fatalf("empty canonical JSONML for %#v", value) + } + } + for _, value := range []any{ + map[string]any{"text": "alpha"}, + map[string]any{"id": "ignored", "nested": map[string]any{"text": "beta"}}, + []any{"p", map[string]any{"text": "gamma"}}, + []any{map[string]any{"text": "delta"}}, + "epsilon", + } { + if canonicalBlockContent(value, "markdown") == "" { + t.Fatalf("empty canonical markdown for %#v", value) + } + } + + jsonTree := map[string]any{ + "jsonml": `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"new"},"after"]]`, + "content": "not-json", + "nested": []any{"p", map[string]any{"uuid": "direct"}, "direct"}, + } + if len(orderedCanonicalBlocks(jsonTree, "jsonml")) < 3 || findJSONMLBlock(jsonTree, "new") == nil { + t.Fatalf("JSONML traversal failed: %#v", orderedJSONMLBlocks(jsonTree)) + } + if findJSONMLBlock(jsonTree, "missing") != nil || jsonMLBlockIdentity([]any{"p"}) != "" || jsonMLBlockIdentity([]any{"p", "attrs"}) != "" { + t.Fatal("JSONML identity negative contract failed") + } + if canonicalBlockIdentity(jsonElement, "jsonml") != "b" || canonicalBlockIdentity("bad", "jsonml") != "" { + t.Fatal("JSONML canonical identity failed") + } + + elementTree := map[string]any{"wrapper": []any{map[string]any{"id": "ref", "text": "before"}, map[string]any{"id": "new", "text": "after"}}} + if len(orderedCanonicalBlocks(elementTree, "element")) != 2 || canonicalBlockIdentity(elementTree, "element") != "" || canonicalBlockIdentity("bad", "element") != "" { + t.Fatal("element canonical traversal failed") + } + if !verifyInsertedCanonicalBlock(map[string]any{"blockId": "new"}, elementTree, "ref", "after", "element") || + !verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "ref", "after", "element") || + verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "new", "after", "element") { + t.Fatal("inserted block verification branch contract failed") + } + if blockContentEquals(map[string]any{}, "missing", "after", "element") { + t.Fatal("missing block matched") + } + if blockContentEquals(nil, "missing", "after", "element") { + t.Fatal("nil block tree matched") + } + if blockContentEquals(map[string]any{}, "missing", "after", "jsonml") { + t.Fatal("missing JSONML block matched") + } + if got := documentContentCandidates("root", "markdown"); len(got) != 1 || got[0] != "root" { + t.Fatalf("root content candidates = %#v", got) + } + if got := documentContentCandidates([]any{map[string]any{"content": "nested"}}, "markdown"); len(got) != 1 || got[0] != "nested" { + t.Fatalf("nested content candidates = %#v", got) + } + if got := splitDocMarkdown("a\nb", 0); len(got) != 1 { + t.Fatalf("disabled split = %#v", got) + } + if got := splitDocMarkdown("ab\ncd", 4); len(got) != 2 || got[0] != "ab\n" { + t.Fatalf("newline split = %#v", got) + } +} + +func TestCrossPlatformCoverageDocFinalExecutionFailureBranches(t *testing.T) { + longContent := strings.Repeat("x", 10001) + if err := runDocCoverage(t, Create, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}, "--name", "n", "--content", longContent); err == nil { + t.Fatal("partial chunk create succeeded") + } + for _, failAt := range []int{1, 2} { + err := runDocCoverage(t, Update, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, + "--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes") + if err == nil { + t.Fatalf("block mutation failAt=%d succeeded", failAt) + } + } + mismatch := &docCoverageCaller{responses: map[string][]map[string]any{ + "insert_document_block": {{"blockId": "new"}}, + "list_document_blocks": {{"blocks": []any{map[string]any{"id": "new", "text": "wrong"}}}}, + }} + if err := runDocCoverage(t, Update, mismatch, "--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes"); err == nil { + t.Fatal("mismatched block verification succeeded") + } + + for _, tc := range []struct { + name string + responses map[string][]map[string]any + failAt int + args []string + }{ + {"query error", map[string][]map[string]any{}, 1, []string{"--job-id", "j"}}, + {"unknown", map[string][]map[string]any{"query_export_job": {{}}}, 0, []string{"--job-id", "j"}}, + {"failed", map[string][]map[string]any{"query_export_job": {{"status": "CANCELLED"}}}, 0, []string{"--job-id", "j"}}, + {"missing url", map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}, 0, []string{"--job-id", "j", "--output", "out"}}, + } { + t.Run(tc.name, func(t *testing.T) { + _ = runDocCoverage(t, ExportGet, &docCoverageCaller{failAt: tc.failAt, responses: tc.responses}, tc.args...) + }) + } + t.Run("export getwd", func(t *testing.T) { + testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") }) + _ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out") + }) + t.Run("export download", func(t *testing.T) { + testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) { + return localio.DownloadResult{}, errors.New("download") + }) + _ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out") + }) + + searchCaller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {{"documents": []any{}, "hasMore": false}}}} + if err := runDocCoverage(t, Search, searchCaller, "--query", "q", "--creator-uids", "u", "--editor-uids", "e", "--mentioned-uids", "m", "--workspace-ids", "w"); err != nil { + t.Fatal(err) + } + if got := searchCaller.history[0].params["pageSize"]; got != 10 { + t.Fatalf("default search page size = %#v", got) + } + listCaller := &docCoverageCaller{responses: map[string][]map[string]any{"list_nodes": {{"nodes": []any{}, "hasMore": false}}}} + if err := runDocCoverage(t, List, listCaller, "--folder", "f", "--workspace", "w"); err != nil { + t.Fatal(err) + } + if got := listCaller.history[0].params["pageSize"]; got != 50 { + t.Fatalf("default list page size = %#v", got) + } + + _ = runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--emoji", "--mention", "[bad", "--yes") + if err := runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u", "--yes"); err != nil { + t.Fatal(err) + } + _ = runDocCoverage(t, CommentUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "[bad") + _ = runDocCoverage(t, ExportSubmit, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx") + if err := runDocCoverage(t, ExportSubmit, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx"); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + responses map[string][]map[string]any + failAt int + args []string + }{ + {"template search error", map[string][]map[string]any{}, 1, []string{"--query", "q"}}, + {"template not found", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}, "hasMore": false}}}, 0, []string{"--query", "q"}}, + {"template resolved", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false}}}, 0, []string{"--query", "q", "--source", "PUBLIC"}}, + {"template continue", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "nextCursor": "p2"}}}, 0, []string{"--query", "q", "--cursor", "p1"}}, + } { + t.Run(tc.name, func(t *testing.T) { + _ = runDocCoverage(t, TemplateSearch, &docCoverageCaller{responses: tc.responses, failAt: tc.failAt}, tc.args...) + }) + } + + for _, raw := range [][]string{{"[bad"}, {"", " "}} { + if _, err := normalizeMentionUserIDs(raw); err == nil { + t.Fatalf("invalid mentions %#v succeeded", raw) + } + } + if _, ok := sliceUTF16Range("A😀B", 1, 2); ok { + t.Fatal("selection ending inside surrogate pair succeeded") + } + if _, ok := sliceUTF16Range("abc", -1, 1); ok { + t.Fatal("negative selection range succeeded") + } + + commentCases := []struct { + name string + caller *docCoverageCaller + args []string + wantError bool + }{ + {"block read", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true}, + {"range", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": ""}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true}, + {"selected mismatch", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": "alpha"}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "z", "--yes"}, true}, + {"write error", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--yes"}, true}, + {"mention", &docCoverageCaller{responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--mention", "[bad", "--yes"}, true}, + } + for _, tc := range commentCases { + t.Run(tc.name, func(t *testing.T) { + err := runDocCoverage(t, CommentCreate, tc.caller, tc.args...) + if (err != nil) != tc.wantError { + t.Fatalf("err=%v wantError=%v", err, tc.wantError) + } + }) + } + + if !versionEvidenceMatches([]any{map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) { + t.Fatal("array version evidence did not match") + } + if !versionEvidenceMatches(map[string]any{"wrapper": map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) { + t.Fatal("nested version evidence did not match") + } + withDescription := collectTemplateCandidates(map[string]any{"templateId": "t", "description": "d"}) + if len(withDescription) != 1 || withDescription[0]["description"] != "d" { + t.Fatalf("template description = %#v", withDescription) + } + + duplicateTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": { + {"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"}, + {"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false}, + }}} + _ = runDocCoverage(t, CreateFromTemplate, duplicateTemplates, "--query", "q") + stalledTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": { + {"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"}, + {"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"}, + }}} + if err := runDocCoverage(t, CreateFromTemplate, stalledTemplates, "--query", "q"); err == nil { + t.Fatal("stalled template cursor succeeded") + } + maxPages := make([]map[string]any, 20) + for index := range maxPages { + maxPages[index] = map[string]any{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p" + strings.Repeat("x", index+1)} + } + if err := runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": maxPages}}, "--query", "q"); err == nil { + t.Fatal("template max-pages succeeded") + } + + t.Chdir(t.TempDir()) + if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil { + t.Fatal(err) + } + _ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes") + if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run"); err != nil { + t.Fatal(err) + } + + t.Run("empty preview", func(t *testing.T) { + testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) { + return localio.DownloadResult{}, nil + }) + _ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r") + }) + t.Run("empty download", func(t *testing.T) { + testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) { + return localio.DownloadResult{}, nil + }) + _ = runDocCoverage(t, MediaDownload, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r", "--output", "out") + }) +} diff --git a/internal/shortcut/doc/doc.go b/internal/shortcut/doc/doc.go index bb3ad63c..494ee29a 100644 --- a/internal/shortcut/doc/doc.go +++ b/internal/shortcut/doc/doc.go @@ -17,8 +17,11 @@ package doc import ( + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -77,6 +80,9 @@ var Search = shortcut.Shortcut{ {Name: "workspace-ids", Type: shortcut.FlagStringSlice, Desc: "按知识库 ID 过滤"}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 10,最大 30)"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"}, + {Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"}, + {Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回文档数"}, }, Tips: []string{`dws doc +search --query "会议纪要"`, `dws doc +search --extensions pdf,docx`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -111,18 +117,17 @@ var Search = shortcut.Shortcut{ if rt.Changed("workspace-ids") { params["workspaceIds"] = rt.StrSlice("workspace-ids") } - if rt.Changed("limit") { - params["pageSize"] = rt.Int("limit") + pageSize := rt.Int("limit") + if pageSize == 0 { + pageSize = 10 } - if rt.Changed("cursor") { - params["pageToken"] = rt.Str("cursor") - } - data, err := rt.CallMCPData(productDoc, "search_documents", params) + result, err := collectDocPages(rt, "search_documents", "documents", params, searchDocsProject, docPageOptions{ + PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"), + }) if err != nil { return err } - docs := searchDocsProject(data) - return rt.Output(map[string]any{"count": len(docs), "documents": docs}) + return rt.Output(result) }, } @@ -241,6 +246,9 @@ var List = shortcut.Shortcut{ {Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID"}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"}, + {Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"}, + {Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回节点数"}, }, Tips: []string{`dws doc +list --folder DOC_FOLDER_NODE_ID`, `dws doc +list --workspace WS_ID --limit 20`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -251,18 +259,17 @@ var List = shortcut.Shortcut{ if rt.Changed("workspace") { params["workspaceId"] = rt.Str("workspace") } - if rt.Changed("limit") { - params["pageSize"] = rt.Int("limit") + pageSize := rt.Int("limit") + if pageSize == 0 { + pageSize = 50 } - if rt.Changed("cursor") { - params["pageToken"] = rt.Str("cursor") - } - data, err := rt.CallMCPData(productDoc, "list_nodes", params) + result, err := collectDocPages(rt, "list_nodes", "nodes", params, listNodesProject, docPageOptions{ + PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"), + }) if err != nil { return err } - nodes := listNodesProject(data) - return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes}) + return rt.Output(result) }, } @@ -414,7 +421,7 @@ var CommentList = shortcut.Shortcut{ Command: "+comment-list", Product: productComment, Description: "查询文档评论列表", - Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。", + Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -436,26 +443,26 @@ var CommentList = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "查询文档评论列表", - UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。"}, + UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{ - "dws doc +comment-list --node DOC_ID", - "dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved", + "dws doc +comment-list --node DOC_ID --limit 20", + "dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN", }, }, }, Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)", Aliases: []string{"page-size"}}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一页返回的 nextToken)"}, {Name: "type", Type: shortcut.FlagString, Desc: "评论类型: global (全文) / inline (划词)", Enum: []string{"global", "inline"}}, {Name: "resolve-status", Type: shortcut.FlagString, Desc: "解决状态: resolved / unresolved", Enum: []string{"resolved", "unresolved"}}, }, - Tips: []string{`dws doc +comment-list --node DOC_ID`, `dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved`}, + Tips: []string{`dws doc +comment-list --node DOC_ID --limit 20`, `dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN`}, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{"nodeId": rt.Str("node")} - if rt.Changed("limit") { - params["pageSize"] = rt.Int("limit") + if rt.Changed("limit") || rt.Changed("page-size") { + params["pageSize"] = rt.IntFirst("limit", "page-size") } if v := rt.Str("cursor"); v != "" { params["nextToken"] = v @@ -470,54 +477,44 @@ var CommentList = shortcut.Shortcut{ }, } +const commentCreateTargetConstraint = "selection 与 block-id/start/end 高级通道互斥;block-id/start/end 必须一起提供;selection 必须唯一匹配" + var CommentCreate = shortcut.Shortcut{ Service: "doc", Command: "+comment-create", Product: productComment, - Description: "在文档上创建一条评论", - Intent: "当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。", + Description: "创建全文评论,或按 selection 创建划词评论", + Intent: "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown", }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "doc", - Name: "shortcut_comment_create", - CanonicalPath: "doc.shortcut_comment_create", - CLIPath: "doc +comment-create", - PrimaryCLIPath: "doc +comment-create", - }, - Description: "在文档上创建一条评论", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "在文档上创建一条评论", - UseWhen: []string{"当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws doc +comment-create --node DOC_ID --content \"这里需要修改\""}, - }, - }, + Contract: docContract("+comment-create", "创建全文评论,或按 selection 创建划词评论", + "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。", + []string{`dws doc +comment-create --node --content "请补充数据来源"`, `dws doc +comment-create --node --selection "计划下周发布" --content "请确认日期"`}, + contract.ParamDecl{Name: "node", Property: "node"}, + contract.ParamDecl{Name: "content", Property: "content"}, + contract.ParamDecl{Name: "selection", Property: "selection"}, + contract.ParamDecl{Name: "block-id", Property: "blockId"}, + contract.ParamDecl{Name: "start", Property: "start"}, + contract.ParamDecl{Name: "end", Property: "end"}, + contract.ParamDecl{Name: "selected-text", Property: "selectedText"}, + contract.ParamDecl{Name: "mention", Property: "mention"}), Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, - {Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true}, - {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"}, - }, - Tips: []string{`dws doc +comment-create --node DOC_ID --content "这里需要修改"`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "nodeId": rt.Str("node"), - "content": rt.Str("content"), - } - if rt.Changed("mention") { - params["mentionedUserIds"] = rt.StrSlice("mention") - } - return rt.CallMCP("create_comment", params) + {Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true}, + {Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀;" + commentCreateTargetConstraint}, + {Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;" + commentCreateTargetConstraint}, + {Name: "start", Type: shortcut.FlagInt, Desc: "块内 UTF-16 起始偏移;" + commentCreateTargetConstraint}, + {Name: "end", Type: shortcut.FlagInt, Desc: "块内 UTF-16 结束偏移;" + commentCreateTargetConstraint}, + {Name: "selected-text", Type: shortcut.FlagString, Desc: "可选引用原文;CLI 会从 block 回读并交叉校验"}, + {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: commentCreateTargetConstraint}}, + Tips: []string{`dws doc +comment-create --node --content "请补充数据来源"`, `dws doc +comment-create --node --selection "计划下周发布" --content "请确认日期"`}, + Validate: validateCommentCreate, + Execute: executeCommentCreate, } var CommentReply = shortcut.Shortcut{ @@ -557,7 +554,7 @@ var CommentReply = shortcut.Shortcut{ {Name: "content", Type: shortcut.FlagString, Desc: "回复文字内容 (表情回复时填表情名称)", Required: true}, {Name: "comment-key", Type: shortcut.FlagString, Desc: "被回复评论的 commentKey (从 list/create 获取)", Required: true}, {Name: "emoji", Type: shortcut.FlagBool, Desc: "作为表情贴图回复"}, - {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"}, + {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"}, }, Tips: []string{`dws doc +comment-reply --node DOC_ID --comment-key COMMENT_KEY --content "同意"`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -570,7 +567,11 @@ var CommentReply = shortcut.Shortcut{ params["emoji"] = true } if rt.Changed("mention") { - params["mentionedUserIds"] = rt.StrSlice("mention") + mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention")) + if err != nil { + return err + } + params["mentionedUserIds"] = mentions } return rt.CallMCP("reply_comment", params) }, @@ -580,9 +581,10 @@ var CommentCreateInline = shortcut.Shortcut{ Service: "doc", Command: "+comment-create-inline", Product: productComment, - Description: "在文档选中文本区域上创建划词评论", - Intent: "当你想针对文档里某段具体文字(而非整篇)留评论、做精确批注时使用;需先用 +block-list 定位块,再输入 node、block-id 及该块内的 start/end 字符偏移量,会实际在选中文本上创建划词评论。", + Description: "兼容入口:按 block/start/end 创建划词评论", + Intent: "仅兼容既有调用;新任务统一使用 +comment-create 的 selection 或 block/start/end 通道。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}, Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true}, @@ -590,25 +592,12 @@ var CommentCreateInline = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagInt, Desc: "块内文本起始字符偏移量 (从 0 开始)", Required: true}, {Name: "end", Type: shortcut.FlagInt, Desc: "块内文本结束字符偏移量 (须大于 start)", Required: true}, {Name: "selected-text", Type: shortcut.FlagString, Desc: "选中文本内容 (展示引用原文)"}, - {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"}, - }, - Tips: []string{`dws doc +comment-create-inline --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "nodeId": rt.Str("node"), - "content": rt.Str("content"), - "blockId": rt.Str("block-id"), - "start": rt.Int("start"), - "end": rt.Int("end"), - } - if v := rt.Str("selected-text"); v != "" { - params["selectedText"] = v - } - if rt.Changed("mention") { - params["mentionedUserIds"] = rt.StrSlice("mention") - } - return rt.CallMCP("create_inline_comment", params) + {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"block-id", "start", "end"}, Description: "block-id/start/end 必须一起提供,CLI 回读并校验 selectedText"}}, + Tips: []string{`dws doc +comment-create --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`}, + Validate: validateCommentCreate, + Execute: executeCommentCreate, } // ── 协作权限 ───────────────────────────────────────────────── @@ -620,7 +609,7 @@ var ExportSubmit = shortcut.Shortcut{ Command: "+export-submit", Product: productDoc, Description: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId", - Intent: "当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。", + Intent: "仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -642,25 +631,25 @@ var ExportSubmit = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId", - UseWhen: []string{"当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + UseWhen: []string{"仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。"}, + AvoidWhen: []string{"正常导出和保存本地文件使用 doc +export;不要手工编排 submit/get,也不要用它绕过 +export 的安全下载"}, Examples: []string{"dws doc +export-submit --node DOC_ID --export-format markdown"}, }, }, Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "要导出的文档 ID 或 URL", Required: true}, - {Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}}, + {Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式;省略时默认为 docx", Enum: []string{"docx", "markdown", "pdf"}}, }, Tips: []string{`dws doc +export-submit --node DOC_ID --export-format markdown`}, Execute: func(rt *shortcut.RuntimeContext) error { - format := rt.Str("export-format") - if format == "" { - format = "docx" - } - return rt.CallMCP("submit_export_job", map[string]any{ + result, err := rt.CallMCPWriteData(productDoc, "submit_export_job", map[string]any{ "nodeId": rt.Str("node"), - "exportFormat": format, + "exportFormat": rt.Str("export-format"), }) + if err != nil { + return docUnknownWriteError("doc.export_submit", "submit_export_job", rt.Str("node"), err) + } + return rt.Output(docEnvelope("doc.export_submit", map[string]any{"nodeId": rt.Str("node"), "result": result}, map[string]any{"name": "submit_export_job", "status": "success"})) }, } @@ -669,7 +658,7 @@ var ExportGet = shortcut.Shortcut{ Command: "+export-get", Product: productDoc, Description: "根据 jobId 查询文档导出任务结果", - Intent: "当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。", + Intent: "当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -691,18 +680,24 @@ var ExportGet = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "根据 jobId 查询文档导出任务结果", - UseWhen: []string{"当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws doc +export-get --job-id JOB_ID"}, + UseWhen: []string{"当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。"}, + AvoidWhen: []string{"尚未提交导出或没有真实 jobId 时不要使用;正常首次导出使用 doc +export"}, + Examples: []string{"dws doc +export-get --job-id JOB_ID", "dws doc +export-get --job-id JOB_ID --output ./exports/"}, }, }, Flags: []shortcut.Flag{ {Name: "job-id", Type: shortcut.FlagString, Desc: "导出任务 ID", Required: true}, + {Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "可选:任务完成后安全下载到工作目录内相对路径"}, }, - Tips: []string{`dws doc +export-get --job-id JOB_ID`}, - Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("query_export_job", map[string]any{"jobId": rt.Str("job-id")}) + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("output") == "" { + return nil + } + return localio.ValidateOutput(rt.Str("output")) }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "提供 --output 时必须是工作目录内相对路径;默认 no-clobber"}}, + Tips: []string{`dws doc +export-get --job-id JOB_ID`, `dws doc +export-get --job-id JOB_ID --output ./exports/`}, + Execute: executeExportGet, } // ── 历史版本 (server: doc) ─────────────────────────────────── @@ -938,17 +933,57 @@ var TemplateSearch = shortcut.Shortcut{ }, Tips: []string{`dws doc +template-search --query "周报"`}, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"searchName": rt.Str("query")} + pageSize := rt.Int("limit") + if pageSize <= 0 { + pageSize = 50 + } + params := map[string]any{"searchName": rt.Str("query"), "maxResults": pageSize} if v := rt.Str("source"); v != "" { params["templateSource"] = v } - if rt.Changed("limit") { - params["maxResults"] = rt.Int("limit") - } if v := rt.Str("cursor"); v != "" { params["nextCursor"] = v } - return rt.CallMCP("search_doc_templates", params) + found, err := rt.CallMCPData(productDoc, "search_doc_templates", params) + if err != nil { + return err + } + candidates := collectTemplateCandidates(found) + hasMore, hasMoreKnown, nextCursor := docPageState(found) + nextCursor = strings.TrimSpace(nextCursor) + complete := hasMoreKnown && !hasMore + if !hasMoreKnown && nextCursor == "" && len(candidates) < pageSize { + complete = true + } + globalComplete := complete && rt.Str("cursor") == "" + status := "selection_required" + if globalComplete && len(candidates) == 0 { + status = "not_found" + } else if globalComplete && len(candidates) == 1 { + status = "resolved" + } + selectedTemplateID := "" + if status == "resolved" { + selectedTemplateID = candidates[0]["templateId"].(string) + } + nextAction := map[string]string{"resolved": "create_once", "not_found": "stop", "selection_required": "ask_user"}[status] + if !complete { + nextAction = "continue_search" + } + return rt.Output(docEnvelope("doc.template_search", map[string]any{ + "query": rt.Str("query"), + "source": rt.Str("source"), + "count": len(candidates), + "candidates": candidates, + "complete": complete, + "hasMore": !complete, + "nextCursor": nextCursor, + "selection": map[string]any{ + "status": status, + "templateId": selectedTemplateID, + "nextAction": nextAction, + }, + })) }, } @@ -986,7 +1021,6 @@ func init() { CommentCreateInline.Contract = corecmd.ContractDecl{} TemplateApply.Contract = corecmd.ContractDecl{} canonicalizeHistoryShortcuts() - canonicalizeCommentShortcuts() shortcut.Register( Search, List, @@ -998,9 +1032,9 @@ func init() { CommentCreateInline, ExportSubmit, ExportGet, - legacyVersionSave, - legacyVersionList, - legacyVersionRevert, + compatHistorySave, + compatHistoryList, + compatHistoryRevert, VersionSave, VersionList, VersionRevert, diff --git a/internal/shortcut/doc/doc_shortcuts_test.go b/internal/shortcut/doc/doc_shortcuts_test.go index 92174cde..0ef06fab 100644 --- a/internal/shortcut/doc/doc_shortcuts_test.go +++ b/internal/shortcut/doc/doc_shortcuts_test.go @@ -7,15 +7,18 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" "reflect" "strings" + "sync" "testing" "unicode/utf8" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" @@ -26,8 +29,10 @@ import ( ) type docCoverageCaller struct { + mu sync.Mutex failAt int calls int + dryRun bool responses map[string][]map[string]any ctx context.Context history []docCoverageCall @@ -43,12 +48,17 @@ type docCoverageErrorReader struct{} func (docCoverageErrorReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") } func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) { + f.mu.Lock() + defer f.mu.Unlock() f.calls++ f.history = append(f.history, docCoverageCall{tool: tool, params: params}) if f.failAt == f.calls { return nil, errors.New("injected doc coverage failure") } value := docCoveragePayload(tool) + if tool == "revert_doc_version" { + value = map[string]any{"version": params["version"]} + } if queue := f.responses[tool]; len(queue) > 0 { value = queue[0] f.responses[tool] = queue[1:] @@ -61,7 +71,7 @@ func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params m } func (f *docCoverageCaller) Format() string { return "json" } -func (f *docCoverageCaller) DryRun() bool { return false } +func (f *docCoverageCaller) DryRun() bool { return f.dryRun } func (f *docCoverageCaller) Fields() string { return "" } func (f *docCoverageCaller) JQ() string { return "" } @@ -70,9 +80,9 @@ func docCoveragePayload(tool string) map[string]any { case "create_document": return map[string]any{"data": map[string]any{"nodeId": "node-1"}} case "get_document_content": - return map[string]any{"data": map[string]any{"revision": 1, "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta"]]`}} + return map[string]any{"data": map[string]any{"revision": 1, "markdown": "alpha beta x body gamma legacy []", "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta x body gamma legacy []"]]`}} case "list_document_blocks": - return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}} + return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta x body gamma []"}}}}} case "submit_export_job": return map[string]any{"jobId": "job-1"} case "query_export_job": @@ -156,6 +166,224 @@ func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testin } } +func TestCrossPlatformCoverageFetchResolvesUniqueTitleBeforeRead(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "search_documents": {{ + "documents": []any{map[string]any{"nodeId": "resolved-node", "name": "项目周报", "docType": "adoc"}}, + "hasMore": false, + }}, + }} + if err := runDocCoverage(t, Fetch, caller, "--query", "项目周报"); err != nil { + t.Fatal(err) + } + if len(caller.history) != 2 || caller.history[0].tool != "search_documents" || caller.history[1].tool != "get_document_content" { + t.Fatalf("fetch resolution calls = %#v", caller.history) + } + if caller.history[1].params["nodeId"] != "resolved-node" { + t.Fatalf("resolved fetch params = %#v", caller.history[1].params) + } + + ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{ + "search_documents": {{ + "documents": []any{ + map[string]any{"nodeId": "a", "name": "项目周报", "docType": "adoc"}, + map[string]any{"nodeId": "b", "name": "项目周报", "docType": "adoc"}, + }, + "hasMore": false, + }}, + }} + if err := runDocCoverage(t, Fetch, ambiguous, "--query", "项目周报"); err == nil { + t.Fatal("ambiguous title unexpectedly succeeded") + } + if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_documents" { + t.Fatalf("ambiguous fetch reached content read: %#v", ambiguous.history) + } +} + +func TestCrossPlatformCoverageTemplateGoldenRouteStopsBeforeAmbiguousCreate(t *testing.T) { + payload := map[string]any{ + "templates": []any{ + map[string]any{"templateId": "template-b", "templateName": "团队周报", "templateSource": "PUBLIC"}, + map[string]any{"templateId": "template-a", "templateName": "运营周报", "templateSource": "PUBLIC"}, + }, + } + candidates := collectTemplateCandidates(payload) + if len(candidates) != 2 || candidates[0]["templateId"] != "template-b" || candidates[1]["templateId"] != "template-a" { + t.Fatalf("template candidates = %#v", candidates) + } + + search := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}} + if err := runDocCoverage(t, TemplateSearch, search, "--query", "周报", "--source", "PUBLIC"); err != nil { + t.Fatal(err) + } + if len(search.history) != 1 || search.history[0].tool != "search_doc_templates" { + t.Fatalf("template search calls = %#v", search.history) + } + + ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}} + err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报", "--source", "PUBLIC") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "template_selection_required" || typed.ExecutionStarted == nil || *typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable { + t.Fatalf("ambiguous template error = %#v", err) + } + if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_doc_templates" { + t.Fatalf("ambiguous template reached write: %#v", ambiguous.history) + } + if got, ok := typed.Details["candidates"].([]map[string]any); !ok || len(got) != 2 { + t.Fatalf("ambiguous template details = %#v", typed.Details) + } + + notFound := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}}}}} + err = runDocCoverage(t, CreateFromTemplate, notFound, "--query", "不存在") + if !errors.As(err, &typed) || typed.Reason != "template_not_found" { + t.Fatalf("not-found template error = %#v", err) + } + + if selection := CreateFromTemplate.Contract.Selection; len(selection.AvoidWhen) < 2 || !strings.Contains(strings.Join(selection.AvoidWhen, " "), "+template-search") { + t.Fatalf("create-from-template selection = %#v", selection) + } + mutualRoutes := []struct { + name string + selection contract.SelectionSpec + siblings []string + }{ + {name: "+template-list", selection: TemplateList.Contract.Selection, siblings: []string{"+template-search", "+create-from-template"}}, + {name: "+template-search", selection: TemplateSearch.Contract.Selection, siblings: []string{"+template-list", "+create-from-template"}}, + {name: "+create-from-template", selection: CreateFromTemplate.Contract.Selection, siblings: []string{"+template-list", "+template-search"}}, + } + for _, route := range mutualRoutes { + avoid := strings.Join(route.selection.AvoidWhen, " ") + for _, sibling := range route.siblings { + if !strings.Contains(avoid, sibling) { + t.Errorf("%s Selection does not exclude sibling %s: %#v", route.name, sibling, route.selection) + } + } + } +} + +func TestCrossPlatformCoverageTemplateCreateProvesUniquenessAcrossPages(t *testing.T) { + firstPage := map[string]any{ + "templates": []any{map[string]any{"templateId": "template-a", "templateName": "周报"}}, + "hasMore": true, + "nextCursor": "page-2", + } + + ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{ + "search_doc_templates": { + firstPage, + {"templates": []any{map[string]any{"templateId": "template-b", "templateName": "周报二"}}, "hasMore": false}, + }, + }} + err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "template_selection_required" { + t.Fatalf("cross-page ambiguity = %#v", err) + } + if len(ambiguous.history) != 2 || ambiguous.history[1].params["nextCursor"] != "page-2" { + t.Fatalf("cross-page search calls = %#v", ambiguous.history) + } + + unique := &docCoverageCaller{responses: map[string][]map[string]any{ + "search_doc_templates": { + firstPage, + {"templates": []any{}, "hasMore": false}, + }, + }} + if err := runDocCoverage(t, CreateFromTemplate, unique, "--query", "周报"); err != nil { + t.Fatal(err) + } + if len(unique.history) != 3 || unique.history[2].tool != "apply_doc_template" || unique.history[2].params["templateId"] != "template-a" { + t.Fatalf("unique cross-page create calls = %#v", unique.history) + } + + missingCursor := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {firstPage}}} + missingCursor.responses["search_doc_templates"][0] = map[string]any{ + "templates": []any{map[string]any{"templateId": "template-a"}}, + "hasMore": true, + } + err = runDocCoverage(t, CreateFromTemplate, missingCursor, "--query", "周报") + if !errors.As(err, &typed) || typed.Reason != "template_pagination_missing_next_cursor" || len(missingCursor.history) != 1 { + t.Fatalf("missing template cursor = %#v history=%#v", err, missingCursor.history) + } +} + +func TestCrossPlatformCoverageExportRecoveryReusesJobAndSafeDownloader(t *testing.T) { + for _, status := range []string{"INIT", "init", " PROCESSING "} { + if !docExportStatusPollable(status) { + t.Errorf("export status %q should remain pollable", status) + } + } + for _, status := range []string{"", "FAILED", "CANCELLED", "SUCCESS"} { + if docExportStatusPollable(status) { + t.Errorf("export status %q should not remain pollable", status) + } + } + init := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "INIT"}}}} + err := runDocCoverage(t, Export, init, "--node", "n", "--export-format", "docx", "--output", "export.docx", "--max-polls", "1") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_timeout" { + t.Fatalf("INIT export status = %#v, want poll timeout", err) + } + + pollFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}} + err = runDocCoverage(t, Export, pollFailure, "--node", "n", "--export-format", "docx", "--output", "export.docx") + if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_failed" || typed.Details["jobId"] != "job-1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted { + t.Fatalf("export poll error = %#v", err) + } + if !strings.Contains(strings.Join(typed.Actions, " "), "+export-get --job-id job-1") { + t.Fatalf("export recovery actions = %#v", typed.Actions) + } + + t.Chdir(t.TempDir()) + testseam.Swap(t, &docDownload, func(_ context.Context, _ string, opts localio.DownloadOptions) (localio.DownloadResult, error) { + if opts.Output != "recovered.docx" { + t.Fatalf("recovery output = %#v", opts) + } + return localio.DownloadResult{RelativePath: "recovered.docx", SizeBytes: 42}, nil + }) + recovery := &docCoverageCaller{responses: map[string][]map[string]any{ + "query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/recovered.docx"}}, + }} + if err := runDocCoverage(t, ExportGet, recovery, "--job-id", "job-1", "--output", "recovered.docx"); err != nil { + t.Fatal(err) + } + if len(recovery.history) != 1 || recovery.history[0].tool != "query_export_job" { + t.Fatalf("export recovery calls = %#v", recovery.history) + } + if err := runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--job-id", "job-1", "--output", "/tmp/unsafe.docx"); err == nil { + t.Fatal("absolute recovery output unexpectedly accepted") + } +} + +func TestCrossPlatformCoverageCommentListPaginationFlagsAndAlias(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + if err := runDocCoverage(t, CommentList, caller, "--node", "n", "--page-size", "20", "--cursor", "next-1"); err != nil { + t.Fatal(err) + } + if len(caller.history) != 1 || caller.history[0].tool != "list_comments" || caller.history[0].params["pageSize"] != 20 || caller.history[0].params["nextToken"] != "next-1" { + t.Fatalf("comment pagination call = %#v", caller.history) + } +} + +func TestCrossPlatformCoverageMediaFailuresKeepStableIDsAndForbidPathEscape(t *testing.T) { + if err := runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "/tmp/media.bin", "--dry-run", "--yes"); err == nil { + t.Fatal("absolute media input unexpectedly accepted") + } + if err := runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "/tmp/report.docx", "--workspace", "workspace-1"); err == nil { + t.Fatal("absolute import input unexpectedly accepted") + } + + resolveFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}} + err := runDocCoverage(t, MediaDownload, resolveFailure, "--node", "node-1", "--resource-id", "resource-1", "--output", "media.bin") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "doc_media_resolve_failed" || typed.Details["nodeId"] != "node-1" || typed.Details["resourceId"] != "resource-1" { + t.Fatalf("media recovery error = %#v", err) + } + if !strings.Contains(strings.Join(typed.Actions, " "), "不要 curl/wget") { + t.Fatalf("media recovery actions = %#v", typed.Actions) + } +} + func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) { assertPartial := func(t *testing.T, err error, reason, stage, nodeID string, wantSteps int) *apperrors.Error { t.Helper() @@ -184,7 +412,7 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) { } create := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}} - err := runDocCoverage(t, Create, create, "--name", "n", "--content", `[]`, "--doc-format", "jsonml") + err := runDocCoverage(t, Create, create, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml") typed := assertPartial(t, err, "doc_create_initial_content_failed", "write_jsonml", "node-1", 2) compensation, _ := typed.Details["compensation"].(map[string]any) if compensation["available"] != true || compensation["nodeId"] != "node-1" || len(create.history) != 2 { @@ -206,6 +434,13 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) { err = runDocCoverage(t, CheckpointUpdate, checkpointVerify, "--node", "n", "--content", "body", "--yes") assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3) + checkpointMismatch := &docCoverageCaller{responses: map[string][]map[string]any{ + "save_doc_version": {{"version": 8.0}}, + "get_document_content": {{"markdown": "different content"}}, + }} + err = runDocCoverage(t, CheckpointUpdate, checkpointMismatch, "--node", "n", "--content", "body", "--yes") + assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3) + historyVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}} err = runDocCoverage(t, VersionRevert, historyVerify, "--node", "n", "--version", "3", "--yes") assertPartial(t, err, "doc_history_revert_verification_failed", "verify", "n", 3) @@ -215,22 +450,31 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T) tests := []struct { name string args []string + responses map[string][]map[string]any wantTools []string }{ { - name: "plain text replace", - args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}, - wantTools: []string{"list_document_blocks", "update_document_block"}, + name: "plain text replace", + args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}, + responses: map[string][]map[string]any{"list_document_blocks": { + {"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}}, + {"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "gamma beta"}}}}}, + }}, + wantTools: []string{"list_document_blocks", "update_document_block", "list_document_blocks"}, }, { - name: "block copy", - args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"}, - wantTools: []string{"list_document_blocks", "insert_document_block"}, + name: "block copy", + args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"}, + responses: map[string][]map[string]any{"list_document_blocks": { + {"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha"}}}}}, + {"blocks": []any{map[string]any{"element": map[string]any{"id": "id-1", "paragraph": map[string]any{"text": "alpha"}}}}}, + }}, + wantTools: []string{"list_document_blocks", "insert_document_block", "list_document_blocks"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + caller := &docCoverageCaller{responses: tc.responses} if err := runDocCoverage(t, Update, caller, tc.args...); err != nil { t.Fatal(err) } @@ -246,6 +490,246 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T) } } +func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) { + unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}} + err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes") + var typed *apperrors.Error + if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_commit_unknown" || !typed.RetryableSet || typed.Retryable { + t.Fatalf("unknown write error = %#v", err) + } + if len(unknown.history) != 1 || unknown.history[0].tool != "update_document" { + t.Fatalf("unknown write was replayed or verified: %#v", unknown.history) + } + + verificationFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}} + err = runDocCoverage(t, Update, verificationFailure, "--node", "n", "--command", "append", "--content", "x", "--yes") + if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.Details["status"] != "partial_success" { + t.Fatalf("verification failure = %#v", err) + } + if len(verificationFailure.history) != 2 || verificationFailure.history[1].tool != "get_document_content" { + t.Fatalf("verification calls = %#v", verificationFailure.history) + } +} + +func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "get_document_content": {{"markdown": "truncated"}}, + }} + err := runDocCoverage(t, Create, caller, "--name", "n", "--content", "complete body") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.FailureStage != "verify" { + t.Fatalf("create mismatch error = %#v, want verification failure", err) + } + if len(caller.history) != 2 || caller.history[0].tool != "create_document" || caller.history[1].tool != "get_document_content" { + t.Fatalf("create mismatch calls = %#v, want one write followed by one read", caller.history) + } +} + +func TestCrossPlatformCoverageDocJSONMLBlockVerificationUsesJSONMLReadback(t *testing.T) { + for _, test := range []struct { + name string + command string + blockArg string + response string + }{ + {name: "insert", command: "block_insert_after", blockArg: "--after-block-id", response: `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"id-1"},"after"]]`}, + {name: "replace", command: "block_replace", blockArg: "--block-id", response: `["root",{},["p",{"uuid":"target"},"after"]]`}, + } { + t.Run(test.name, func(t *testing.T) { + blockID := "ref" + if test.command == "block_replace" { + blockID = "target" + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "list_document_blocks": {{"jsonml": test.response}}, + }} + err := runDocCoverage(t, Update, caller, + "--node", "n", "--command", test.command, test.blockArg, blockID, + "--content", `["p",{},"after"]`, "--doc-format", "jsonml", "--yes") + if err != nil { + t.Fatal(err) + } + if got := caller.history[len(caller.history)-1].params["format"]; got != "jsonml" { + t.Fatalf("verification format = %#v, want jsonml", got) + } + }) + } +} + +func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testing.T) { + t.Run("target on second page", func(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "list_doc_versions": { + {"versions": []any{map[string]any{"version": 1.0}}, "hasMore": true, "nextCursor": "page-2"}, + {"versions": []any{map[string]any{"version": 3.0}}, "hasMore": false}, + }, + }} + if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil { + t.Fatal(err) + } + if len(caller.history) != 4 || caller.history[1].params["nextCursor"] != "page-2" { + t.Fatalf("version pagination calls = %#v", caller.history) + } + }) + + t.Run("empty write response and ordinary document info", func(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "revert_doc_version": {{}}, + "get_document_info": {{"nodeId": "n", "revision": 99.0}}, + }} + if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil { + t.Fatal(err) + } + }) + + for _, test := range []struct { + name string + responses []map[string]any + want string + }{ + {name: "not found", responses: []map[string]any{{"versions": []any{}, "hasMore": false}}, want: "目标版本 3 不存在"}, + {name: "missing cursor", responses: []map[string]any{{"versions": []any{}, "hasMore": true}}, want: "无法证明分页已经完整"}, + {name: "stalled cursor", responses: []map[string]any{ + {"versions": []any{}, "hasMore": true, "nextCursor": "same"}, + {"versions": []any{}, "hasMore": true, "nextCursor": "same"}, + }, want: "无法证明分页已经完整"}, + } { + t.Run(test.name, func(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": test.responses}} + err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes") + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("version preflight error = %v, want %q", err, test.want) + } + for _, call := range caller.history { + if call.tool == "revert_doc_version" { + t.Fatalf("preflight failure executed revert: %#v", caller.history) + } + } + }) + } + + pages := make([]map[string]any, 20) + for index := range pages { + pages[index] = map[string]any{"versions": []any{}, "hasMore": true, "nextCursor": fmt.Sprintf("page-%d", index+1)} + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": pages}} + if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err == nil || !strings.Contains(err.Error(), "无法证明分页已经完整") { + t.Fatalf("max-pages preflight error = %v", err) + } + + for _, value := range []any{json.Number("3"), 3, "3"} { + if !versionNumberMatches(value, 3) { + t.Errorf("version value %#v did not match", value) + } + } + for _, value := range []any{json.Number("bad"), 4, "bad", true} { + if versionNumberMatches(value, 3) { + t.Errorf("version value %#v unexpectedly matched", value) + } + } + if !versionEvidenceMatches([]any{map[string]any{"target-version": "3"}}, 3, map[string]bool{"targetversion": true}) { + t.Fatal("nested version evidence did not match") + } + if versionEvidenceMatches(map[string]any{"version": 4}, 3, map[string]bool{"version": true}) { + t.Fatal("mismatched version evidence unexpectedly matched") + } +} + +func TestCrossPlatformCoverageDocWriteErrorStateMachine(t *testing.T) { + tests := []struct { + name string + cause error + wantState string + wantReason string + wantRetry bool + }{ + { + name: "permission is terminal", + cause: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"), apperrors.WithRetryable(false)), + wantState: "failed", + wantReason: "permission_denied", + }, + { + name: "confirmed not started may retry", + cause: apperrors.NewAPI("rate limited", apperrors.WithReason("http_429"), + apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)), + wantState: "retryable", + wantReason: "http_429", + wantRetry: true, + }, + { + name: "unknown write never replays", + cause: errors.New("connection reset"), + wantState: "unknown", + wantReason: "doc_write_commit_unknown", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := docUnknownWriteError("doc.update", "update_document", "n", tc.cause) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error = %#v", err) + } + if typed.Details["state"] != tc.wantState || typed.Reason != tc.wantReason || typed.Retryable != tc.wantRetry { + t.Fatalf("transition = reason=%q retry=%v details=%#v", typed.Reason, typed.Retryable, typed.Details) + } + }) + } +} + +func TestCrossPlatformCoverageDocLongWritesChunkOnceAndVerify(t *testing.T) { + long := strings.Repeat("段落😀", 4000) + chunks := splitDocMarkdown(long, 10000) + if len(chunks) < 2 || strings.Join(chunks, "") != long { + t.Fatalf("split chunks=%d roundtrip=%v", len(chunks), strings.Join(chunks, "") == long) + } + + update := &docCoverageCaller{responses: map[string][]map[string]any{ + "get_document_content": {{"markdown": long}}, + }} + if err := runDocCoverage(t, Update, update, "--node", "n", "--command", "overwrite", "--content", long, "--yes"); err != nil { + t.Fatal(err) + } + if len(update.history) != len(chunks)+1 || update.history[0].params["mode"] != "overwrite" || update.history[1].params["mode"] != "append" || update.history[len(update.history)-1].tool != "get_document_content" { + t.Fatalf("long update calls = %#v", update.history) + } + + partial := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}} + err := runDocCoverage(t, Update, partial, "--node", "n", "--command", "append", "--content", long, "--yes") + var typed *apperrors.Error + if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_update_chunk_commit_unknown" || len(partial.history) != 2 { + t.Fatalf("partial long update err=%#v calls=%#v", err, partial.history) + } + + create := &docCoverageCaller{responses: map[string][]map[string]any{ + "get_document_content": {{"markdown": long}}, + }} + if err := runDocCoverage(t, Create, create, "--name", "long", "--content", long); err != nil { + t.Fatal(err) + } + if len(create.history) != len(chunks)+1 || create.history[0].tool != "create_document" || create.history[1].tool != "update_document" || create.history[len(create.history)-1].tool != "get_document_content" { + t.Fatalf("long create calls = %#v", create.history) + } +} + +func TestCrossPlatformCoverageUpdateVerificationNormalizesMarkdownRoundTrip(t *testing.T) { + expected := "# 值班表\r\n\r\n| 姓名 | 班次 |\r\n| --- | --- |\r\n| 小王 | 早班 |\r\n" + serverMarkdown := "# 值班表\n\n|姓名|班次|\n|---|---|\n|小王|早班|\n" + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "get_document_content": {{"data": map[string]any{"markdown": serverMarkdown}}}, + }} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "overwrite", "--content", expected, "--yes"); err != nil { + t.Fatalf("normalized Markdown verification failed: %v", err) + } + if !verifyUpdatedDocumentContent(map[string]any{"markdown": "已有内容\n" + serverMarkdown}, expected, "append", "markdown") { + t.Fatal("normalized append verification did not find the appended content") + } + if verifyUpdatedDocumentContent(map[string]any{"markdown": "# 值班表\n|姓名|班次|"}, expected, "overwrite", "markdown") { + t.Fatal("incomplete overwrite content passed normalized verification") + } +} + func TestCrossPlatformCoverageSelectionMatchesEnumerateEveryCandidate(t *testing.T) { tests := []struct { name string @@ -378,20 +862,20 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing. {"inspect base", Inspect, []string{"--node", "n"}}, {"inspect all", Inspect, []string{"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}}, {"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}}, - {"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `[]`, "--doc-format", "jsonml", "--yes"}}, + {"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}}, {"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}}, - {"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}}, + {"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}}, {"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}}, - {"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}}, + {"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}}, {"update delete", Update, []string{"--node", "n", "--command", "block_delete", "--block-id", "b", "--yes"}}, {"update replace", Update, []string{"--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}}, {"update copy", Update, []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "b", "--yes"}}, - {"update revision", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}}, + {"update revision", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"}}, {"update dry", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--dry-run", "--yes"}}, {"checkpoint dry", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--dry-run", "--yes"}}, {"checkpoint success", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--yes"}}, - {"export dry", Export, []string{"--node", "n", "--output", "out.docx", "--dry-run"}}, - {"export success", Export, []string{"--node", "n", "--output", "out.docx"}}, + {"export dry", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx", "--dry-run"}}, + {"export success", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}}, } testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) { @@ -400,6 +884,43 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing. for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + switch tc.name { + case "create markdown": + caller.responses["get_document_content"] = []map[string]any{{"markdown": "body"}} + case "create jsonml file": + caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{},"body"]`}} + case "create stdin": + caller.responses["get_document_content"] = []map[string]any{{"markdown": "stdin body"}} + } + if tc.name == "update overwrite jsonml" || tc.name == "update revision" { + caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{}]`}} + } + switch tc.name { + case "update append": + caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}} + case "update insert text": + caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "x"}}}} + case "update insert jsonml": + caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"reference"],["p",{"uuid":"id-1"},"x"]]`}} + case "update replace text": + caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "x"}}}} + case "update replace jsonml": + caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"x"]]`}} + case "update delete": + caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "other", "text": "x"}}}} + case "update replace": + caller.responses["list_document_blocks"] = []map[string]any{ + {"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}}, + {"items": []any{map[string]any{"id": "block-1", "text": "gamma beta"}}}, + } + case "update copy": + caller.responses["list_document_blocks"] = []map[string]any{ + {"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}}, + {"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "alpha beta"}}}, + } + case "checkpoint success": + caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}} + } var err error if tc.name == "create stdin" { err = runDocCoverageInput(t, tc.cmd, caller, strings.NewReader("stdin body"), tc.args...) @@ -415,18 +936,73 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing. for _, command := range []shortcut.Shortcut{Create, Fetch, Inspect, Update, CheckpointUpdate, Export} { for failAt := 1; failAt <= 7; failAt++ { args := map[string][]string{ - "+create": {"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}, + "+create": {"--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"}, "+fetch": {"--node", "n", "--scope", "keyword", "--keyword", "x"}, "+inspect": {"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}, - "+update": {"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}, + "+update": {"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"}, "+checkpoint-update": {"--node", "n", "--content", "x", "--yes"}, - "+export": {"--node", "n", "--output", "out.docx"}, + "+export": {"--node", "n", "--export-format", "docx", "--output", "out.docx"}, }[command.Command] _ = runDocCoverage(t, command, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...) } } } +func TestCrossPlatformCoverageUpdateContractAndPreflight(t *testing.T) { + flags := make(map[string]shortcut.Flag, len(Update.Flags)) + for _, flag := range Update.Flags { + flags[flag.Name] = flag + } + if !flags["node"].Required || flags["command"].Required { + t.Fatalf("unconditional required flags: node=%v command=%v", flags["node"].Required, flags["command"].Required) + } + for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} { + if got := flags[name].RequiredWhen; got != "" { + t.Errorf("--%s RequiredWhen = %q, want compatibility-safe custom constraint", name, got) + } + } + if len(Update.Constraints) != 1 || Update.Constraints[0].Kind != shortcut.ConstraintCustom || + !strings.Contains(Update.Constraints[0].Description, "依 command 校验") { + t.Fatalf("update custom constraint = %#v", Update.Constraints) + } + cmd := corecmd.New(shortcut.FromShortcut(Update)) + for _, alias := range []string{"doc", "text"} { + flag := cmd.Flags().Lookup(alias) + if flag == nil { + t.Errorf("compatibility alias --%s is not mounted", alias) + } + } + + invalid := []struct { + name string + args []string + want string + }{ + {name: "missing command", args: []string{"--node", "n"}, want: "--command"}, + {name: "insert missing reference", args: []string{"--node", "n", "--command", "block_insert_after", "--content", "x"}, want: "--after-block-id"}, + {name: "copy missing reference", args: []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "b"}, want: "--after-block-id"}, + {name: "jsonml append", args: []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml"}, want: "JSONML 当前不支持 append"}, + {name: "revision without server CAS path", args: []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1"}, want: "仅支持 --command overwrite --doc-format jsonml"}, + } + for _, test := range invalid { + t.Run(test.name, func(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + err := runDocCoverage(t, Update, caller, test.args...) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + if strings.Contains(err.Error(), "confirmation_required") || caller.calls != 0 { + t.Fatalf("invalid input reached confirmation or execution: err=%v calls=%d", err, caller.calls) + } + }) + } + + if err := runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, + "--node", "n", "--command", "str_replace", "--old", "x", "--new", "", "--dry-run"); err != nil { + t.Fatalf("explicit empty --new should satisfy str_replace contract: %v", err) + } +} + func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) { t.Chdir(t.TempDir()) if err := os.WriteFile("body.md", []byte("body"), 0o600); err != nil { @@ -442,6 +1018,8 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) { {Create, []string{"--name", "n", "--content", "@/absolute"}}, {Create, []string{"--name", "n", "--content", "not-json", "--doc-format", "jsonml"}}, {Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}}, + {Create, []string{"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}}, + {Create, []string{"--name", "n", "--content", `[["p",{},"x"]]`, "--doc-format", "jsonml"}}, {Fetch, []string{"--node", "n", "--revision", "1"}}, {Fetch, []string{"--node", "n", "--scope", "keyword"}}, {Update, []string{"--node", "n"}}, @@ -449,25 +1027,28 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) { {Update, []string{"--node", "n", "--command", "append", "--yes"}}, {Update, []string{"--node", "n", "--command", "block_delete", "--yes"}}, {Update, []string{"--node", "n", "--command", "str_replace", "--old", "x", "--yes"}}, - {Update, []string{"--node", "n", "--command", "append", "--content", `[]`, "--doc-format", "jsonml", "--yes"}}, + {Update, []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}}, } for _, tc := range badCases { if err := runDocCoverage(t, tc.cmd, &docCoverageCaller{responses: map[string][]map[string]any{}}, tc.args...); err == nil { t.Errorf("%s %#v unexpectedly succeeded", tc.cmd.Command, tc.args) } } + absoluteInputErr := runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@/absolute") + if absoluteInputErr == nil || !strings.Contains(absoluteInputErr.Error(), "暂存到工作目录") || !strings.Contains(absoluteInputErr.Error(), "stdin") { + t.Fatalf("absolute @file guidance = %v", absoluteInputErr) + } createNoNode := &docCoverageCaller{responses: map[string][]map[string]any{"create_document": {{"ok": true}}}} - if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `[]`, "--doc-format", "jsonml"); err == nil { + if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"); err == nil { t.Fatal("jsonml create without node id succeeded") } - conflict := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"revision": 2}}}} - if err := runDocCoverage(t, Update, conflict, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil { - t.Fatal("revision conflict succeeded") + revision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"jsonml": `["root",{}]`}}}} + if err := runDocCoverage(t, Update, revision, "--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "7", "--yes"); err != nil { + t.Fatalf("server revision update failed: %v", err) } - missingRevision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"ok": true}}}} - if err := runDocCoverage(t, Update, missingRevision, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil { - t.Fatal("missing revision succeeded") + if len(revision.history) < 1 || revision.history[0].tool != "update_document" || revision.history[0].params["revision"] != 7 { + t.Fatalf("expected revision was not forwarded atomically: %#v", revision.history) } for _, value := range []any{ @@ -482,6 +1063,9 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) { if _, err := validateJSONML(`{}`); err == nil { t.Fatal("object jsonml succeeded") } + if _, err := validateJSONML(`[["p",{},"x"]]`); err == nil { + t.Fatal("nested element-array jsonml succeeded") + } if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 { t.Fatal("nestedMap did not unwrap") } @@ -496,8 +1080,11 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) { _ = containsResourceReference([]any{map[string]any{"x": "y"}}) stripBlockIDs(blocks) - if err := runDocCoveragePath(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil { - t.Fatalf("visible update flag aliases: %v", err) + aliasCaller := &docCoverageCaller{responses: map[string][]map[string]any{ + "get_document_content": {{"markdown": "legacy"}}, + }} + if err := runDocCoveragePath(t, Update, aliasCaller, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil { + t.Fatalf("compatibility aliases --doc/--text must remain executable: %v", err) } missingNode := Update missingNode.Flags = append([]shortcut.Flag(nil), Update.Flags...) @@ -562,19 +1149,19 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) { {"jobId": "j"}, } { caller := &docCoverageCaller{responses: map[string][]map[string]any{"submit_export_job": {response}, "query_export_job": {{"status": "FAILED", "message": "bad"}}}} - _ = runDocCoverage(t, Export, caller, "--node", "n", "--output", "x", "--max-polls", "1") + _ = runDocCoverage(t, Export, caller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1") } timeout := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}} - _ = runDocCoverage(t, Export, timeout, "--node", "n", "--output", "x", "--max-polls", "1") + _ = runDocCoverage(t, Export, timeout, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1") processingThenSuccess := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}, {"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/x"}}}} - _ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--output", "x", "--max-polls", "2") + _ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2") cancelled, cancel := context.WithCancel(context.Background()) cancel() cancelledCaller := &docCoverageCaller{ctx: cancelled, responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}} - _ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--output", "x", "--max-polls", "2") - _ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--output", "x", "--max-polls", "0") + _ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2") + _ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "0") missingURL := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}} - _ = runDocCoverage(t, Export, missingURL, "--node", "n", "--output", "x") + _ = runDocCoverage(t, Export, missingURL, "--node", "n", "--export-format", "docx", "--output", "x") } func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) { @@ -596,7 +1183,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) { {CommentUpdate, []string{"--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u"}}, {CommentDelete, []string{"--node", "n", "--comment-key", "c", "--dry-run", "--yes"}}, {CommentCreate, []string{"--node", "n", "--content", "x", "--yes"}}, - {CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}}, + {CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "block-1", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}}, {CommentCreate, []string{"--node", "n", "--content", "x", "--selection", "alpha", "--yes"}}, {MediaList, []string{"--node", "n"}}, {MediaPreview, []string{"--node", "n", "--resource-id", "r"}}, @@ -620,7 +1207,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) { for _, declaration := range []shortcut.Shortcut{VersionRevert, CreateFromTemplate, Review, MediaList, MediaDownload, ResourceDownload} { args := map[string][]string{ - "+history-revert": {"--node", "n", "--version", "3", "--yes"}, + "+version-revert": {"--node", "n", "--version", "3", "--yes"}, "+create-from-template": {"--query", "q"}, "+review": {"--node", "n"}, "+media-list": {"--node", "n"}, @@ -676,6 +1263,27 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) { _ = runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes") _ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--image", "https://example.com/cover.png", "--dry-run", "--yes") _ = runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run") + if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--dry-run"); err != nil { + t.Fatalf("import to default root failed: %v", err) + } + if err := runDocCoverage(t, Export, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--node", "n", "--output", "out.docx", "--dry-run"); err != nil { + t.Fatalf("export default format failed: %v", err) + } + if selected, ok := sliceUTF16Range("A😀B", 1, 3); !ok || selected != "😀" { + t.Fatalf("UTF-16 selection = %q/%v", selected, ok) + } + if _, ok := sliceUTF16Range("A😀B", 2, 3); ok { + t.Fatal("selection splitting a surrogate pair succeeded") + } + for _, raw := range []string{`["550582"]`, `[550582]`} { + mentions, err := normalizeMentionUserIDs([]string{raw}) + if err != nil || len(mentions) != 1 || mentions[0] != "550582" { + t.Fatalf("mention normalization %q = %#v/%v", raw, mentions, err) + } + } + if _, err := normalizeMentionUserIDs([]string{`[{"uid":"550582"}]`}); err == nil { + t.Fatal("object mention unexpectedly succeeded") + } resourceOnly := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}} _ = runDocCoverage(t, ResourceDownload, resourceOnly, "--node", "n", "--output", "cover.png") @@ -704,6 +1312,53 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) { }) } +func TestCrossPlatformCoverageVersionRoutesAreCanonicalAndHistoryRoutesAreCompatible(t *testing.T) { + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "doc" { + registered[item.Command] = item + } + } + + pairs := map[string]string{ + "+history-save": "+version-save", + "+history-list": "+version-list", + "+history-revert": "+version-revert", + } + for compatibility, canonical := range pairs { + compatItem, ok := registered[compatibility] + if !ok { + t.Fatalf("missing compatibility route %s", compatibility) + } + canonicalItem, ok := registered[canonical] + if !ok { + t.Fatalf("missing canonical route %s", canonical) + } + if compatItem.Disposition != shortcut.DispositionAliasInternal || compatItem.PrimaryCommand != canonical { + t.Errorf("%s routing = %s/%s, want alias_internal/%s", compatibility, compatItem.Disposition, compatItem.PrimaryCommand, canonical) + } + if !strings.Contains(strings.Join(compatItem.Contract.Selection.AvoidWhen, " "), canonical) { + t.Errorf("%s compatibility Selection does not route Agent to %s", compatibility, canonical) + } + if canonicalItem.Contract.Selection.UseWhen[0] != canonicalItem.Intent { + t.Errorf("%s UseWhen and Intent drifted", canonical) + } + if strings.Contains(strings.Join(canonicalItem.Contract.Selection.AvoidWhen, " "), "+history-") { + t.Errorf("%s canonical Selection routes back to history compatibility commands", canonical) + } + } + + if VersionSave.Command != "+version-save" || VersionSave.Safety.Confirmation != "user_required" { + t.Errorf("version-save command/confirmation = %s/%s", VersionSave.Command, VersionSave.Safety.Confirmation) + } + if compatHistorySave.Safety.Confirmation != "not_required" { + t.Errorf("history-save compatibility confirmation = %s", compatHistorySave.Safety.Confirmation) + } + if VersionRevert.Command != "+version-revert" || VersionRevert.Execute == nil { + t.Errorf("version-revert canonical smart route is incomplete") + } +} + func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) { t.Chdir(t.TempDir()) testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) { @@ -713,7 +1368,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) decl shortcut.Shortcut args []string }{ - {Export, []string{"--node", "n", "--output", "out.docx"}}, + {Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}}, {MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}}, {ResourceDownload, []string{"--node", "n", "--output", "out.png"}}, } { @@ -727,7 +1382,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) decl shortcut.Shortcut args []string }{ - {Export, []string{"--node", "n", "--output", "out.docx"}}, + {Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}}, {MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}}, {ResourceDownload, []string{"--node", "n", "--output", "out.png"}}, } { diff --git a/internal/shortcut/doc/history_template_shortcuts.go b/internal/shortcut/doc/history_template_shortcuts.go index 5cbd8da2..ea9ac94d 100644 --- a/internal/shortcut/doc/history_template_shortcuts.go +++ b/internal/shortcut/doc/history_template_shortcuts.go @@ -4,112 +4,165 @@ package doc import ( + "encoding/json" "fmt" + "sort" "strconv" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) var ( - legacyVersionSave shortcut.Shortcut - legacyVersionList shortcut.Shortcut - legacyVersionRevert shortcut.Shortcut + compatHistorySave shortcut.Shortcut + compatHistoryList shortcut.Shortcut + compatHistoryRevert shortcut.Shortcut ) func canonicalizeHistoryShortcuts() { - legacyVersionSave = VersionSave - legacyVersionList = VersionList - legacyVersionRevert = VersionRevert - - VersionSave.Command = "+history-save" + VersionSave.Command = "+version-save" VersionSave.Aliases = nil VersionSave.Description = "手动保存当前文档版本快照" - VersionSave.Intent = "当用户要在重要修改前后手动建立一个可回滚的文档历史快照时使用;保存快照本身无需交互确认。" - VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"} - VersionSave.Contract = docContract("+history-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +history-save --node `}) - VersionSave.Tips = []string{`dws doc +history-save --node `} + VersionSave.Intent = "当用户要求保存、创建或建立当前文档版本快照时使用;只保存快照,不更新正文。" + VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"} + VersionSave.Contract = versionSaveContract() + VersionSave.Tips = []string{`dws doc +version-save --node `} - VersionList.Command = "+history-list" + VersionList.Command = "+version-list" VersionList.Aliases = nil VersionList.Description = "分页列出文档历史版本" VersionList.Intent = "当用户要查看文档已有版本、选择回滚目标或审计版本时间线时使用;返回版本号和分页游标。" - VersionList.Contract = docContract("+history-list", VersionList.Description, VersionList.Intent, []string{`dws doc +history-list --node `, `dws doc +history-list --node --page-size 20`}) + VersionList.Contract = versionListContract() VersionList.Flags = []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, - {Name: "page-size", Type: shortcut.FlagInt, Desc: "每页版本数量"}, - {Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标"}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的兼容别名"}, - {Name: "cursor", Type: shortcut.FlagString, Desc: "--page-token 的兼容别名"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回版本数量上限", Aliases: []string{"page-size"}, AliasesVisible: true}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true}, } - VersionList.Tips = []string{`dws doc +history-list --node `, `dws doc +history-list --node --page-size 20`} + VersionList.Tips = []string{`dws doc +version-list --node `, `dws doc +version-list --node --limit 20`} VersionList.Execute = func(rt *shortcut.RuntimeContext) error { params := map[string]any{"nodeId": rt.Str("node")} - if size := rt.IntFirst("page-size", "limit"); size > 0 { + if size := rt.IntFirst("limit", "page-size"); size > 0 { params["maxResults"] = size } - if token := rt.StrFirst("page-token", "cursor"); token != "" { + if token := rt.StrFirst("cursor", "page-token"); token != "" { params["nextCursor"] = token } return rt.CallMCP("list_doc_versions", params) } - VersionRevert.Command = "+history-revert" + VersionRevert.Command = "+version-revert" VersionRevert.Aliases = nil VersionRevert.Description = "预检并回滚文档到指定历史版本" VersionRevert.Intent = "当用户明确要把整篇文档恢复到某个历史版本时使用;先确认目标版本存在,再执行高风险回滚并读回验证。" - VersionRevert.Contract = docContract("+history-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +history-revert --node --version 3`}) - VersionRevert.Tips = []string{`dws doc +history-revert --node --version 3`} + VersionRevert.Contract = versionRevertContract() + VersionRevert.Tips = []string{`dws doc +version-revert --node --version 3`} VersionRevert.Execute = executeHistoryRevert + compatHistorySave = compatibilityHistoryShortcut(VersionSave, "+history-save", "+version-save") + compatHistorySave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"} + compatHistoryList = compatibilityHistoryShortcut(VersionList, "+history-list", "+version-list") + compatHistoryRevert = compatibilityHistoryShortcut(VersionRevert, "+history-revert", "+version-revert") + TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板" - TemplateList.Intent = "当用户要浏览自己的或公开的文档模板并获取 templateId 时使用;若已知名称可改用 template-search。" - TemplateList.Contract = docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`}) - TemplateSearch.Description = "按名称检索文档模板" - TemplateSearch.Intent = "当用户知道模板名称关键词、要快速定位唯一 templateId 后继续创建文档时使用。" - TemplateSearch.Contract = docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`}) + TemplateList.Intent = "当用户没有明确模板名称或关键词,只要浏览自己的或公开模板并获取 templateId 时使用。" + TemplateList.Contract = templateListContract() + TemplateSearch.Description = "按名称或关键词检索文档模板" + TemplateSearch.Intent = "当用户已提供明确模板名称或关键词时使用;返回结构化候选和 resolved/not_found/selection_required 状态,零命中或多候选时停止创建。" + TemplateSearch.Contract = templateSearchContract() } -func canonicalizeCommentShortcuts() { - // Preserve the historical confirmation contract for comment writes. The - // richer canonical implementations must not silently weaken that gate. - CommentCreate.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"} - CommentReply.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"} - CommentCreate.Description = "创建全文评论,或按 selection 创建划词评论" - CommentCreate.Intent = "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。" - CommentCreate.Contract = docContract("+comment-create", CommentCreate.Description, CommentCreate.Intent, []string{`dws doc +comment-create --node --content "请补充数据来源"`, `dws doc +comment-create --node --selection "计划下周发布" --content "请确认日期"`}) - CommentCreate.Flags = []shortcut.Flag{ - {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, - {Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true}, - {Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀 selection;使用时不能为空且必须唯一匹配"}, - {Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;使用时不能为空且须与 start/end 一起提供"}, - {Name: "start", Type: shortcut.FlagInt, Desc: "块内起始字符偏移;高级通道参数不能为空且须一起提供"}, - {Name: "end", Type: shortcut.FlagInt, Desc: "块内结束字符偏移;高级通道参数不能为空且须一起提供"}, - {Name: "selected-text", Type: shortcut.FlagString, Desc: "高级通道引用原文"}, - {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"}, +func versionSaveContract() corecmd.ContractDecl { + decl := docContract("+version-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +version-save --node `}) + decl.Selection.AvoidWhen = []string{ + "只查看历史版本时使用 doc +version-list;需要恢复历史内容时使用 doc +version-revert", + "需要保存恢复点后继续修改正文时使用 doc +checkpoint-update", } - CommentCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: "selection/高级通道参数不能为空;selection 必须唯一匹配,block-id/start/end 必须一起提供"}} - CommentCreate.Validate = validateCommentCreate - CommentCreate.Execute = executeCommentCreate - CommentCreate.Tips = []string{`dws doc +comment-create --node --content "请补充数据来源"`, `dws doc +comment-create --node --selection "计划下周发布" --content "请确认日期"`} + return decl +} + +func versionListContract() corecmd.ContractDecl { + decl := docContract("+version-list", VersionList.Description, VersionList.Intent, []string{`dws doc +version-list --node `, `dws doc +version-list --node --limit 20`}) + decl.Selection.AvoidWhen = []string{ + "需要创建当前版本快照时使用 doc +version-save", + "已经明确目标版本并要求恢复正文时使用 doc +version-revert", + } + return decl +} + +func versionRevertContract() corecmd.ContractDecl { + decl := docContract("+version-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +version-revert --node --version 3`}) + decl.Selection.AvoidWhen = []string{ + "只需要创建当前版本快照时使用 doc +version-save", + "尚未确定目标版本号时先使用 doc +version-list", + } + return decl +} + +func compatibilityHistoryShortcut(source shortcut.Shortcut, command, canonical string) shortcut.Shortcut { + result := source + result.Command = command + result.Aliases = nil + result.Description = fmt.Sprintf("兼容入口:%s", source.Description) + result.Intent = fmt.Sprintf("仅当调用方明确使用既有 doc %s 路径时兼容执行。", command) + result.Contract = docContract(command, result.Description, result.Intent, []string{fmt.Sprintf("dws doc %s --node ", command)}) + result.Contract.Selection.AvoidWhen = []string{fmt.Sprintf("新的 Agent 任务统一使用 doc %s;不要因自然语言版本意图选择本兼容入口", canonical)} + result.Tips = []string{fmt.Sprintf("dws doc %s --node ", command)} + if command == "+history-revert" { + result.Contract.Selection.Examples = []string{`dws doc +history-revert --node --version 3`} + result.Tips = []string{`dws doc +history-revert --node --version 3`} + } + return result +} + +func templateSearchContract() corecmd.ContractDecl { + decl := docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`}) + decl.Selection.AvoidWhen = []string{ + "没有明确模板名称或关键词、只是浏览可用模板时使用 doc +template-list", + "已经拿到唯一 templateId 时不要重复搜索,直接使用 doc +create-from-template --template-id", + "不要为了预览多个候选而逐个创建文档;多候选必须让用户选择", + } + return decl +} + +func templateListContract() corecmd.ContractDecl { + decl := docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`}) + decl.Selection.AvoidWhen = []string{ + "已经有明确模板名称或关键词时使用 doc +template-search --query", + "已经拿到 templateId 且要创建文档时使用 doc +create-from-template --template-id", + } + return decl +} + +func createFromTemplateContract() corecmd.ContractDecl { + decl := docContract("+create-from-template", "使用已选定的 templateId 创建文档", + "当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。", + []string{`dws doc +create-from-template --template-id --name "我的周报"`}) + decl.Selection.AvoidWhen = []string{ + "只有模板名称或关键词时先使用 doc +template-search;零命中或多候选时停止并让用户选择", + "没有模板名称、关键词或 templateId,只想浏览可用模板时使用 doc +template-list", + "不要通过创建多个候选文档来预览模板,也不要反复改写关键词自动重试", + } + return decl } func executeHistoryRevert(rt *shortcut.RuntimeContext) error { nodeID := rt.Str("node") target := rt.Int("version") - versions, err := rt.CallMCPData(productDoc, "list_doc_versions", map[string]any{"nodeId": nodeID}) + found, err := findHistoryVersion(rt, nodeID, target) if err != nil { return err } - if !containsVersion(versions, target) { + if !found { return apperrors.NewValidation(fmt.Sprintf("目标版本 %d 不存在,已停止回滚", target)) } if rt.DryRun() { return rt.Output(docEnvelope("doc.history_revert", map[string]any{"executed": false, "nodeId": nodeID, "version": target, "preflight": "version_exists"})) } - if _, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target}); err != nil { + reverted, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target}) + if err != nil { return err } current, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": nodeID}) @@ -125,28 +178,111 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error { map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"}, ) } - return rt.Output(docEnvelope("doc.history_revert", map[string]any{"version": target, "current": current}, + return rt.Output(docEnvelope("doc.history_revert", map[string]any{ + "version": target, "revertResult": reverted, "current": current, "verified": true, + "verification": "revert_acknowledged_and_document_readable", + }, map[string]any{"name": "preflight", "status": "success"}, map[string]any{"name": "revert", "status": "success"}, map[string]any{"name": "verify", "status": "success"})) } +func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) { + const maxPages = 20 + cursor := "" + seenCursors := map[string]bool{} + for page := 1; page <= maxPages; page++ { + params := map[string]any{"nodeId": nodeID} + if cursor != "" { + params["nextCursor"] = cursor + } + versions, err := rt.CallMCPData(productDoc, "list_doc_versions", params) + if err != nil { + return false, err + } + if containsVersion(versions, target) { + return true, nil + } + hasMore, hasMoreKnown, nextCursor := docPageState(versions) + nextCursor = strings.TrimSpace(nextCursor) + if hasMoreKnown && !hasMore { + return false, nil + } + if nextCursor == "" { + if hasMoreKnown && hasMore { + return false, historyVersionPaginationError("missing_next_cursor", page, cursor) + } + return false, nil + } + if seenCursors[nextCursor] { + return false, historyVersionPaginationError("stalled_cursor", page, nextCursor) + } + seenCursors[nextCursor] = true + cursor = nextCursor + } + return false, historyVersionPaginationError("max_pages", maxPages, cursor) +} + +func historyVersionPaginationError(reason string, page int, cursor string) error { + return apperrors.NewAPI( + "文档版本预检无法证明分页已经完整,已停止回滚", + apperrors.WithOperation("doc.history_revert"), + apperrors.WithReason("doc_history_version_"+reason), + apperrors.WithFailureStage("preflight"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(true), + apperrors.WithDetails(map[string]any{"page": page, "cursor": cursor, "targetVerified": false}), + ) +} + +func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool) bool { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + if acceptedKeys[normalized] { + if versionNumberMatches(child, target) { + return true + } + } + if versionEvidenceMatches(child, target, acceptedKeys) { + return true + } + } + case []any: + for _, child := range typed { + if versionEvidenceMatches(child, target, acceptedKeys) { + return true + } + } + } + return false +} + +func versionNumberMatches(value any, target int) bool { + switch number := value.(type) { + case float64: + return number == float64(target) + case json.Number: + parsed, err := number.Int64() + return err == nil && parsed == int64(target) + case int: + return number == target + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(number)) + return err == nil && parsed == target + } + return false +} + func containsVersion(value any, target int) bool { switch typed := value.(type) { case map[string]any: for key, child := range typed { normalized := strings.ToLower(strings.ReplaceAll(key, "_", "")) if normalized == "version" || normalized == "versionnumber" || normalized == "revision" { - switch number := child.(type) { - case float64: - if int(number) == target && number == float64(target) { - return true - } - case string: - parsed, err := strconv.Atoi(strings.TrimSpace(number)) - if err == nil && parsed == target { - return true - } + if versionNumberMatches(child, target) { + return true } } if containsVersion(child, target) { @@ -167,39 +303,53 @@ var CreateFromTemplate = shortcut.Shortcut{ Service: "doc", Command: "+create-from-template", Product: productDoc, - Description: "按 templateId 直达或搜索消歧后创建文档", - Intent: "当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。", + Description: "使用已选定的 templateId 创建文档", + Intent: "当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}, - Contract: docContract("+create-from-template", "按 templateId 直达或搜索消歧后创建文档", - "当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。", - []string{`dws doc +create-from-template --template-id --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`}), + Contract: createFromTemplateContract(), Flags: []shortcut.Flag{ {Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID"}, - {Name: "query", Type: shortcut.FlagString, Desc: "模板搜索名称"}, + {Name: "query", Type: shortcut.FlagString, Desc: "兼容入口:先搜索且仅唯一命中时创建;新的 Agent 流程应先调用 +template-search"}, {Name: "source", Type: shortcut.FlagString, Desc: "模板来源", Enum: []string{"MY", "PUBLIC"}}, {Name: "name", Type: shortcut.FlagString, Desc: "新文档名称"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}, {Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"}, }, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"template-id", "query"}, Description: "--template-id 与 --query 必须且只能提供一个"}}, - Tips: []string{`dws doc +create-from-template --template-id --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`}, + Tips: []string{`dws doc +create-from-template --template-id --name "我的周报"`}, Execute: func(rt *shortcut.RuntimeContext) error { templateID := rt.Str("template-id") if templateID == "" { - params := map[string]any{"searchName": rt.Str("query")} - if rt.Str("source") != "" { - params["templateSource"] = rt.Str("source") - } - found, err := rt.CallMCPData(productDoc, "search_doc_templates", params) + candidates, err := searchTemplateCandidatesForCreate(rt, rt.Str("query"), rt.Str("source")) if err != nil { return err } - ids := collectTemplateIDs(found) - if len(ids) != 1 { - return apperrors.NewValidation(fmt.Sprintf("模板搜索需要唯一命中,实际 %d 个候选: %v", len(ids), ids)) + if len(candidates) != 1 { + status := "selection_required" + message := fmt.Sprintf("模板搜索返回 %d 个候选,必须先选择唯一 templateId", len(candidates)) + actions := []string{"运行 dws doc +template-search 查看结构化候选", "让用户选择后使用 --template-id 创建一次"} + if len(candidates) == 0 { + status = "not_found" + message = "模板搜索没有命中;已停止创建" + actions = []string{"检查模板来源或请用户提供更准确的模板名", "不要自动变换关键词循环搜索"} + } + return apperrors.NewValidation(message, + apperrors.WithOperation("doc.create_from_template"), + apperrors.WithReason("template_"+status), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithActions(actions...), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.template-selection.v1", + "status": status, + "query": rt.Str("query"), + "source": rt.Str("source"), + "candidates": candidates, + }), + ) } - templateID = ids[0] + templateID = candidates[0]["templateId"].(string) } params := map[string]any{"templateId": templateID} for flag, property := range map[string]string{"name": "name", "folder": "folderId", "workspace": "workspaceId"} { @@ -212,12 +362,151 @@ var CreateFromTemplate = shortcut.Shortcut{ } result, err := rt.CallMCPWriteData(productDoc, "apply_doc_template", params) if err != nil { - return err + return docUnknownWriteError("doc.create_from_template", "apply_template", "", err) } - return rt.Output(docEnvelope("doc.create_from_template", result, map[string]any{"name": "apply_template", "status": "success"})) + return rt.Output(docEnvelope("doc.create_from_template", map[string]any{"templateId": templateID, "result": result}, map[string]any{"name": "apply_template", "status": "success"})) }, } +func searchTemplateCandidatesForCreate(rt *shortcut.RuntimeContext, query, source string) ([]map[string]any, error) { + const ( + pageSize = 2 + maxPages = 20 + ) + cursor := "" + seenCursors := map[string]bool{} + seenCandidates := map[string]bool{} + candidates := make([]map[string]any, 0, pageSize) + for page := 1; page <= maxPages; page++ { + params := map[string]any{"searchName": query, "maxResults": pageSize} + if source != "" { + params["templateSource"] = source + } + if cursor != "" { + params["nextCursor"] = cursor + } + found, err := rt.CallMCPData(productDoc, "search_doc_templates", params) + if err != nil { + return nil, err + } + pageCandidates := collectTemplateCandidates(found) + for _, candidate := range pageCandidates { + id, _ := candidate["templateId"].(string) + if id == "" || seenCandidates[id] { + continue + } + seenCandidates[id] = true + candidates = append(candidates, candidate) + } + + hasMore, hasMoreKnown, next := docPageState(found) + next = strings.TrimSpace(next) + if len(candidates) > 1 { + return sortedTemplateCandidates(candidates), nil + } + complete := hasMoreKnown && !hasMore + if !hasMoreKnown && next == "" && len(pageCandidates) < pageSize { + complete = true + } + if complete { + return sortedTemplateCandidates(candidates), nil + } + if next == "" { + return nil, templatePaginationError("missing_next_cursor", page, cursor, candidates) + } + if next == cursor || seenCursors[next] { + return nil, templatePaginationError("stalled_cursor", page, next, candidates) + } + seenCursors[next] = true + cursor = next + } + return nil, templatePaginationError("max_pages", maxPages, cursor, candidates) +} + +func templatePaginationError(reason string, page int, cursor string, candidates []map[string]any) error { + return apperrors.NewAPI( + "模板搜索分页未完整结束;为避免基于非唯一候选创建文档,已停止执行", + apperrors.WithOperation("doc.create_from_template"), + apperrors.WithReason("template_pagination_"+reason), + apperrors.WithFailureStage("template_search"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithActions("使用 dws doc +template-search 分页查看候选", "选择唯一 templateId 后重新执行创建"), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.template-selection.v1", + "status": "pagination_incomplete", + "reason": reason, + "page": page, + "nextCursor": cursor, + "candidates": sortedTemplateCandidates(candidates), + }), + ) +} + +func sortedTemplateCandidates(candidates []map[string]any) []map[string]any { + out := append([]map[string]any(nil), candidates...) + sort.SliceStable(out, func(i, j int) bool { + leftName, _ := out[i]["name"].(string) + rightName, _ := out[j]["name"].(string) + if leftName != rightName { + return leftName < rightName + } + leftID, _ := out[i]["templateId"].(string) + rightID, _ := out[j]["templateId"].(string) + return leftID < rightID + }) + return out +} + +func collectTemplateCandidates(value any) []map[string]any { + seen := map[string]bool{} + var out []map[string]any + var walk func(any) + walk = func(current any) { + switch typed := current.(type) { + case map[string]any: + id := directTemplateString(typed, "templateId", "template_id") + if id != "" && !seen[id] { + seen[id] = true + candidate := map[string]any{"templateId": id} + if name := directTemplateString(typed, "templateName", "name", "title"); name != "" { + candidate["name"] = name + } + if source := directTemplateString(typed, "templateSource", "source"); source != "" { + candidate["source"] = source + } + if description := directTemplateString(typed, "description", "summary"); description != "" { + candidate["description"] = description + } + out = append(out, candidate) + } + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + walk(typed[key]) + } + case []any: + for _, child := range typed { + walk(child) + } + } + } + walk(value) + return sortedTemplateCandidates(out) +} + +func directTemplateString(value map[string]any, keys ...string) string { + for _, key := range keys { + if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + return "" +} + func collectTemplateIDs(value any) []string { seen := map[string]bool{} var out []string diff --git a/internal/shortcut/doc/media_style_shortcuts.go b/internal/shortcut/doc/media_style_shortcuts.go index 0dbb7bb4..8d30ff11 100644 --- a/internal/shortcut/doc/media_style_shortcuts.go +++ b/internal/shortcut/doc/media_style_shortcuts.go @@ -17,11 +17,11 @@ import ( var MediaList = shortcut.Shortcut{ Service: "doc", Command: "+media-list", Product: productDoc, Description: "列出文档正文中的图片和附件资源", - Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。", + Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, Contract: docContract("+media-list", "列出文档正文中的图片和附件资源", - "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。", + "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。", []string{`dws doc +media-list --node `}), Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}}, Tips: []string{`dws doc +media-list --node `}, @@ -31,40 +31,42 @@ var MediaList = shortcut.Shortcut{ return err } items := collectMediaItems(data) - return rt.Output(map[string]any{"count": len(items), "media": items}) + return rt.Output(docEnvelope("doc.media_list", map[string]any{"nodeId": rt.Str("node"), "count": len(items), "media": items})) }, } var MediaInsert = shortcut.Shortcut{ Service: "doc", Command: "+media-insert", Product: productDoc, Description: "上传本地图片或文件并插入文档正文", - Intent: "当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。", + Intent: "当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}, Contract: withDryRun(docContract("+media-insert", "上传本地图片或文件并插入文档正文", - "当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。", + "当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。", []string{`dws doc +media-insert --node --file ./report.pdf`, `dws doc +media-insert --node --file ./image.png --ref-block --where after`}), contract.DryRunPreviewPlan, false), Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, - {Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true}, + {Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在的相对文件路径", Required: true}, {Name: "name", Type: shortcut.FlagString, Desc: "显示名称"}, {Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"}, {Name: "index", Type: shortcut.FlagInt, Desc: "顶层插入索引"}, {Name: "where", Type: shortcut.FlagString, Desc: "相对参考块的位置", Enum: []string{"before", "after"}}, {Name: "ref-block", Type: shortcut.FlagString, Desc: "参考 block ID"}, }, - Tips: []string{`dws doc +media-insert --node --file ./report.pdf`, `dws doc +media-insert --node --file ./image.png --ref-block --where after`}, - Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) }, + Validate: func(rt *shortcut.RuntimeContext) error { return validateWorkspaceInputPath("file", rt.Str("file")) }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "--file 必须是工作目录内存在且不能经符号链接逃逸的相对文件"}}, + Tips: []string{`dws doc +media-insert --node --file ./report.pdf`, `dws doc +media-insert --node --file ./image.png --ref-block --where after`}, + Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) }, } var MediaDownload = shortcut.Shortcut{ Service: "doc", Command: "+media-download", Product: productDoc, Description: "安全下载文档正文附件到工作目录", - Intent: "当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。", + Intent: "当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, Contract: docContract("+media-download", "安全下载文档正文附件到工作目录", - "当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。", + "当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。", []string{`dws doc +media-download --node --resource-id --output ./downloads/`}), Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, @@ -97,7 +99,7 @@ var MediaPreview = shortcut.Shortcut{ } data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")}) if err != nil { - return err + return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err) } dir, err := docMkdirTemp("", "dws-doc-preview-*") if err != nil { @@ -106,9 +108,13 @@ var MediaPreview = shortcut.Shortcut{ result, err := downloadResolvedResource(rt, data, dir, ".") if err != nil { _ = docRemoveAll(dir) - return err + return docMediaRecoveryError("doc_media_preview_failed", "download", rt.Str("node"), rt.Str("resource-id"), err) } - return rt.Output(docEnvelope("doc.media_preview", map[string]any{"previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes})) + if result.SizeBytes <= 0 { + _ = docRemoveAll(dir) + return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空")) + } + return rt.Output(docEnvelope("doc.media_preview", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes, "verified": true})) }, } @@ -124,11 +130,20 @@ var ResourceUpdate = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, {Name: "image", Type: shortcut.FlagString, Desc: "HTTPS 封面图片 URL"}, - {Name: "file", Type: shortcut.FlagString, Desc: "本地封面图片"}, + {Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在封面图片的相对路径"}, }, - Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"}}, - Tips: []string{`dws doc +resource-update --node --image https://example.com/cover.png`, `dws doc +resource-update --node --file ./cover.png`}, - Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("file") == "" { + return nil + } + return validateWorkspaceInputPath("file", rt.Str("file")) + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "提供 --file 时必须是工作目录内已存在且不通过符号链接逃逸的相对路径"}, + }, + Tips: []string{`dws doc +resource-update --node --image https://example.com/cover.png`, `dws doc +resource-update --node --file ./cover.png`}, + Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) }, } var ResourceDownload = shortcut.Shortcut{ @@ -225,7 +240,7 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error { } data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")}) if err != nil { - return err + return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err) } cwd, err := docGetwd() if err != nil { @@ -233,9 +248,35 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error { } result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output")) if err != nil { - return err + return docMediaRecoveryError("doc_media_download_failed", "download", rt.Str("node"), rt.Str("resource-id"), err) } - return rt.Output(docEnvelope("doc.media_download", map[string]any{"resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes})) + if result.SizeBytes <= 0 { + return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空")) + } + return rt.Output(docEnvelope("doc.media_download", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes, "verified": true})) +} + +func docMediaRecoveryError(reason, stage, nodeID, resourceID string, cause error) error { + return apperrors.NewAPI( + "文档媒体操作未完成;已保留 nodeId/resourceId,请稍后重试同一 shortcut,禁止改用临时 URL 或手写 HTTP", + apperrors.WithOperation("doc.media_download"), + apperrors.WithReason(reason), + apperrors.WithFailureStage(stage), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(true), + apperrors.WithActions( + fmt.Sprintf("dws doc +media-download --node %s --resource-id %s --output <工作目录相对路径>", nodeID, resourceID), + "不要 curl/wget 临时 OSS URL,不要安装本地下载或文档转换依赖", + ), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.operation.v1", + "status": "incomplete", + "nodeId": nodeID, + "resourceId": resourceID, + "stage": stage, + }), + apperrors.WithCause(cause), + ) } func executeResourceDownload(rt *shortcut.RuntimeContext) error { diff --git a/internal/shortcut/doc/pagination.go b/internal/shortcut/doc/pagination.go new file mode 100644 index 00000000..083cdd24 --- /dev/null +++ b/internal/shortcut/doc/pagination.go @@ -0,0 +1,222 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +type docPageOptions struct { + PageAll bool + PageSize int + MaxPages int + MaxItems int + Cursor string +} + +func collectDocPages( + rt *shortcut.RuntimeContext, + tool, outputKey string, + base map[string]any, + project func(map[string]any) []map[string]any, + options docPageOptions, +) (map[string]any, error) { + if options.PageSize <= 0 { + options.PageSize = 30 + } + if options.MaxPages <= 0 { + options.MaxPages = 20 + } + if options.MaxItems <= 0 { + options.MaxItems = 500 + } + pageLimit := 1 + if options.PageAll { + pageLimit = options.MaxPages + } + + items := make([]map[string]any, 0) + seenItems := map[string]bool{} + seenCursors := map[string]bool{} + cursor := strings.TrimSpace(options.Cursor) + if cursor != "" { + seenCursors[cursor] = true + } + complete := false + truncated := false + stopReason := "" + nextCursor := "" + hasMore := false + pagesRead := 0 + + for page := 1; page <= pageLimit; page++ { + remaining := options.MaxItems - len(items) + requestPageSize := options.PageSize + if requestPageSize > remaining { + requestPageSize = remaining + } + params := cloneMap(base) + params["pageSize"] = requestPageSize + if cursor != "" { + params["pageToken"] = cursor + } + data, err := rt.CallMCPData(productDoc, tool, params) + if err != nil { + return nil, docPaginationError(tool, "page_read_failed", err, page, items, cursor) + } + pagesRead++ + projected := project(data) + pageItems := make([]map[string]any, 0, len(projected)) + pageSeen := map[string]bool{} + for _, item := range projected { + key := pageItemKey(item) + if key != "" && (seenItems[key] || pageSeen[key]) { + continue + } + if key != "" { + pageSeen[key] = true + } + pageItems = append(pageItems, item) + } + if len(pageItems) > remaining { + return nil, docPaginationError(tool, "page_size_exceeded", nil, page, items, cursor) + } + for _, item := range pageItems { + if key := pageItemKey(item); key != "" { + seenItems[key] = true + } + items = append(items, item) + } + pageHasMore, hasMoreKnown, next := docPageState(data) + nextCursor = strings.TrimSpace(next) + switch { + case hasMoreKnown: + hasMore = pageHasMore + complete = !pageHasMore + case nextCursor != "": + hasMore = true + case len(projected) < requestPageSize: + complete = true + default: + hasMore = true + stopReason = "pagination_unproven" + } + if len(items) >= options.MaxItems && hasMore { + truncated = true + stopReason = "max_items" + } + if truncated { + complete = false + hasMore = true + } + + if truncated || complete || !options.PageAll { + break + } + if stopReason == "pagination_unproven" { + return nil, docPaginationError(tool, stopReason, nil, page, items, cursor) + } + if nextCursor == "" { + return nil, docPaginationError(tool, "missing_next_cursor", nil, page, items, cursor) + } + if seenCursors[nextCursor] { + return nil, docPaginationError(tool, "stalled_cursor", nil, page, items, nextCursor) + } + seenCursors[nextCursor] = true + cursor = nextCursor + if page == pageLimit { + truncated = true + stopReason = "max_pages" + } + } + + result := map[string]any{ + "contractVersion": "doc.list.v1", + "status": "success", + "count": len(items), + outputKey: items, + "pagesRead": pagesRead, + "complete": complete, + "truncated": truncated, + "hasMore": hasMore, + "nextCursor": nextCursor, + "stopReason": stopReason, + "failures": []map[string]any{}, + } + return result, nil +} + +func docPaginationError(tool, reason string, cause error, page int, items []map[string]any, cursor string) error { + message := fmt.Sprintf("%s 分页未完成,已在第 %d 页停止", tool, page) + return apperrors.NewAPI( + message, + apperrors.WithOperation("doc/"+tool), + apperrors.WithReason("doc_pagination_"+reason), + apperrors.WithFailureStage("pagination"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(cause != nil), + apperrors.WithActions("保留 nextCursor 后继续读取", "缩小查询范围后重试"), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.list.v1", + "status": "partial_success", + "complete": false, + "reason": reason, + "page": page, + "nextCursor": cursor, + "count": len(items), + "items": items, + }), + apperrors.WithCause(cause), + ) +} + +func cloneMap(source map[string]any) map[string]any { + out := make(map[string]any, len(source)+2) + for key, value := range source { + out[key] = value + } + return out +} + +func pageItemKey(item map[string]any) string { + for _, key := range []string{"nodeId", "id", "url"} { + if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" { + return key + ":" + strings.TrimSpace(value) + } + } + return "" +} + +func docPageState(data map[string]any) (bool, bool, string) { + var hasMore bool + var known bool + var next string + var walk func(map[string]any) + walk = func(value map[string]any) { + if !known { + for _, key := range []string{"hasMore", "has_more"} { + if flag, ok := value[key].(bool); ok { + hasMore, known = flag, true + break + } + } + } + if next == "" { + if value, ok := docFirst(value, "nextPageToken", "nextCursor", "next_page_token"); ok { + next, _ = value.(string) + } + } + for _, key := range []string{"result", "data", "page", "pagination"} { + if nested, ok := value[key].(map[string]any); ok { + walk(nested) + } + } + } + walk(data) + return hasMore, known, next +} diff --git a/internal/shortcut/doc/pagination_test.go b/internal/shortcut/doc/pagination_test.go new file mode 100644 index 00000000..d9b16871 --- /dev/null +++ b/internal/shortcut/doc/pagination_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "errors" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageDocSearchPageAllContract(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "search_documents": { + {"documents": []any{map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": true, "nextPageToken": "p2"}, + {"documents": []any{map[string]any{"nodeId": "b", "name": "B"}, map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": false}, + }, + }} + if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2"); err != nil { + t.Fatal(err) + } + if len(caller.history) != 2 || caller.history[1].params["pageToken"] != "p2" { + t.Fatalf("pagination calls = %#v", caller.history) + } +} + +func TestCrossPlatformCoverageDocPaginationFailsClosedOnStalledCursor(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "list_nodes": { + {"nodes": []any{map[string]any{"nodeId": "a"}}, "hasMore": true, "nextPageToken": "p2"}, + {"nodes": []any{map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"}, + }, + }} + err := runDocCoverage(t, List, caller, "--folder", "f", "--page-all", "--limit", "1") + if err == nil || len(caller.history) != 2 { + t.Fatalf("stalled pagination err=%v history=%#v", err, caller.history) + } +} + +func TestCrossPlatformCoverageDocPaginationMaxItemsStopsAtPageBoundary(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "search_documents": { + {"documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"}, + {"documents": []any{map[string]any{"nodeId": "c"}}, "hasMore": true, "nextPageToken": "p3"}, + }, + }} + if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "3"); err != nil { + t.Fatal(err) + } + if len(caller.history) != 2 || caller.history[1].params["pageSize"] != 1 || caller.history[1].params["pageToken"] != "p2" { + t.Fatalf("max-items pagination calls = %#v", caller.history) + } +} + +func TestCrossPlatformCoverageDocPaginationRejectsServerPageOverflow(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "search_documents": {{ + "documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}}, + "hasMore": true, "nextPageToken": "p2", + }}, + }} + err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "1") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "doc_pagination_page_size_exceeded" { + t.Fatalf("page overflow error = %#v", err) + } + if len(caller.history) != 1 || caller.history[0].params["pageSize"] != 1 { + t.Fatalf("page overflow calls = %#v", caller.history) + } +} + +func TestCrossPlatformCoverageDocPaginationFinalBranchMatrix(t *testing.T) { + run := func(t *testing.T, caller *docCoverageCaller, options docPageOptions) (map[string]any, error) { + t.Helper() + declaration := Search + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + result, err := collectDocPages(rt, "search_documents", "documents", map[string]any{"base": true}, searchDocsProject, options) + if err == nil { + err = rt.Output(result) + } + return err + } + err := runDocCoverage(t, declaration, caller) + return nil, err + } + + defaults := &docCoverageCaller{responses: map[string][]map[string]any{ + "search_documents": {{"documents": []any{map[string]any{"url": "u"}}}}, + }} + if _, err := run(t, defaults, docPageOptions{Cursor: " initial "}); err != nil { + t.Fatal(err) + } + if len(defaults.history) != 1 || defaults.history[0].params["pageSize"] != 30 || defaults.history[0].params["pageToken"] != "initial" { + t.Fatalf("default pagination call = %#v", defaults.history) + } + + readFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}} + if _, err := run(t, readFailure, docPageOptions{PageAll: true}); err == nil { + t.Fatal("page read failure succeeded") + } + + duplicate := &docCoverageCaller{responses: map[string][]map[string]any{ + "search_documents": {{ + "documents": []any{ + map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "a"}, map[string]any{"name": "no-key"}, + }, + "nextPageToken": "p2", + }}, + }} + if _, err := run(t, duplicate, docPageOptions{PageAll: false, PageSize: 3, MaxPages: 2, MaxItems: 10}); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + response map[string]any + maxPages int + wantError bool + }{ + {"unproven", map[string]any{"documents": []any{map[string]any{"nodeId": "a"}}}, 2, true}, + {"missing cursor", map[string]any{"documents": []any{}, "hasMore": true}, 2, true}, + {"max pages", map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": "p2"}, 1, false}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {tc.response}}} + _, err := run(t, caller, docPageOptions{PageAll: true, PageSize: 1, MaxPages: tc.maxPages, MaxItems: 10}) + if (err != nil) != tc.wantError { + t.Fatalf("err=%v wantError=%v", err, tc.wantError) + } + }) + } + + if got := pageItemKey(map[string]any{"url": " u "}); got != "url:u" { + t.Fatalf("URL page key = %q", got) + } + if got := pageItemKey(map[string]any{"id": 1}); got != "" { + t.Fatalf("invalid page key = %q", got) + } + if more, known, next := docPageState(map[string]any{"data": map[string]any{"has_more": true, "nextCursor": "c"}}); !more || !known || next != "c" { + t.Fatalf("nested page state = %v/%v/%q", more, known, next) + } +} diff --git a/internal/shortcut/doc/review_shortcuts.go b/internal/shortcut/doc/review_shortcuts.go index a1f07dd4..6548ba9e 100644 --- a/internal/shortcut/doc/review_shortcuts.go +++ b/internal/shortcut/doc/review_shortcuts.go @@ -4,6 +4,7 @@ package doc import ( + "encoding/json" "fmt" "strings" "unicode/utf16" @@ -60,13 +61,17 @@ var CommentUpdate = shortcut.Shortcut{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, {Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "更新后的评论正文", Required: true}, - {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"}, + {Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"}, }, Tips: []string{`dws doc +comment-update --node --comment-key --content "已按最新数据修正"`}, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key"), "content": rt.Str("content")} if rt.Changed("mention") { - params["mentionedUserIds"] = rt.StrSlice("mention") + mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention")) + if err != nil { + return err + } + params["mentionedUserIds"] = mentions } return rt.CallMCP("update_comment", params) }, @@ -113,14 +118,29 @@ func validateCommentCreate(rt *shortcut.RuntimeContext) error { func executeCommentCreate(rt *shortcut.RuntimeContext) error { params := map[string]any{"nodeId": rt.Str("node"), "content": rt.Str("content")} if rt.Changed("mention") { - params["mentionedUserIds"] = rt.StrSlice("mention") + mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention")) + if err != nil { + return err + } + params["mentionedUserIds"] = mentions } if rt.Str("block-id") != "" { - params["blockId"], params["start"], params["end"] = rt.Str("block-id"), rt.Int("start"), rt.Int("end") - if rt.Str("selected-text") != "" { - params["selectedText"] = rt.Str("selected-text") + blockID, start, end := rt.Str("block-id"), rt.Int("start"), rt.Int("end") + blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "blockId": blockID, "format": "element"}) + if err != nil { + return err } - return rt.CallMCP("create_inline_comment", params) + blockText := map[string]string{} + collectBlockText(blocks, "", blockText) + selectedText, ok := sliceUTF16Range(blockText[blockID], start, end) + if !ok { + return apperrors.NewValidation(fmt.Sprintf("INVALID_SELECTION_RANGE: block %s 的 UTF-16 范围 [%d,%d) 无效", blockID, start, end)) + } + if supplied := rt.Str("selected-text"); supplied != "" && supplied != selectedText { + return apperrors.NewValidation("SELECTED_TEXT_MISMATCH: --selected-text 与 block/start/end 对应的真实原文不一致") + } + params["blockId"], params["start"], params["end"], params["selectedText"] = blockID, start, end, selectedText + return executeCommentWrite(rt, "create_inline_comment", "inline", params) } if selection := rt.Str("selection"); selection != "" { blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"}) @@ -136,9 +156,62 @@ func executeCommentCreate(rt *shortcut.RuntimeContext) error { return apperrors.NewValidation(fmt.Sprintf("AMBIGUOUS_SELECTION: selection 需要唯一匹配,实际 %d 处;候选=%v", len(matches), candidates)) } params["blockId"], params["start"], params["end"], params["selectedText"] = matches[0].blockID, matches[0].start, matches[0].end, matches[0].selected - return rt.CallMCP("create_inline_comment", params) + return executeCommentWrite(rt, "create_inline_comment", "inline", params) } - return rt.CallMCP("create_comment", params) + return executeCommentWrite(rt, "create_comment", "global", params) +} + +func normalizeMentionUserIDs(values []string) ([]string, error) { + if len(values) == 1 && strings.HasPrefix(strings.TrimSpace(values[0]), "[") { + var decoded []any + decoder := json.NewDecoder(strings.NewReader(values[0])) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("--mention JSON 数组解析失败: %v", err)) + } + values = make([]string, 0, len(decoded)) + for _, value := range decoded { + switch typed := value.(type) { + case string: + values = append(values, typed) + case json.Number: + values = append(values, typed.String()) + default: + return nil, apperrors.NewValidation("--mention JSON 数组只接受字符串或数字 uid") + } + } + } + values = stringSliceNonEmpty(values) + if len(values) == 0 { + return nil, apperrors.NewValidation("--mention 至少包含一个非空用户 uid") + } + return values, nil +} + +func executeCommentWrite(rt *shortcut.RuntimeContext, tool, commentType string, params map[string]any) error { + result, err := rt.CallMCPWriteData(productComment, tool, params) + if err != nil { + return docUnknownWriteError("doc.comment_create", tool, rt.Str("node"), err) + } + return rt.Output(docEnvelope("doc.comment_create", map[string]any{ + "nodeId": rt.Str("node"), "commentType": commentType, "result": result, + }, map[string]any{"name": tool, "status": "success"})) +} + +func sliceUTF16Range(text string, start, end int) (string, bool) { + units := utf16.Encode([]rune(text)) + if start < 0 || end <= start || end > len(units) { + return "", false + } + isHigh := func(value uint16) bool { return value >= 0xD800 && value <= 0xDBFF } + isLow := func(value uint16) bool { return value >= 0xDC00 && value <= 0xDFFF } + if start > 0 && start < len(units) && isHigh(units[start-1]) && isLow(units[start]) { + return "", false + } + if end > 0 && end < len(units) && isHigh(units[end-1]) && isLow(units[end]) { + return "", false + } + return string(utf16.Decode(units[start:end])), true } type selectionMatch struct { diff --git a/internal/shortcut/docresolver/resolver.go b/internal/shortcut/docresolver/resolver.go new file mode 100644 index 00000000..d3c124c0 --- /dev/null +++ b/internal/shortcut/docresolver/resolver.go @@ -0,0 +1,285 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package docresolver resolves document IDs, URLs, and natural titles to one +// typed document target before a shortcut performs a business operation. +package docresolver + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +const ( + searchPageSize = 30 + searchPageLimit = 40 +) + +// Reader is the read-only RuntimeContext surface needed by the resolver. +type Reader interface { + CallMCPData(product, tool string, params map[string]any) (map[string]any, error) +} + +// Candidate is a credential-free document search candidate. +type Candidate struct { + CanonicalID string `json:"canonicalId"` + Name string `json:"name,omitempty"` + Product string `json:"product"` + ResourceType string `json:"resourceType,omitempty"` + URL string `json:"canonicalUrl,omitempty"` + ContainerID string `json:"containerId,omitempty"` +} + +// Resolution is the stable result consumed by document shortcuts. +type Resolution struct { + ContractVersion string `json:"contractVersion"` + Status string `json:"status"` + Query string `json:"query,omitempty"` + MatchedBy string `json:"matchedBy"` + Complete bool `json:"complete"` + Selected Candidate `json:"selected"` + Candidates []Candidate `json:"candidates"` +} + +// Resolve accepts exactly one stable node/URL or natural title. Natural title +// resolution exhausts pagination before selecting a unique candidate. +func Resolve(rt Reader, node, query string) (Resolution, error) { + node = strings.TrimSpace(node) + query = strings.TrimSpace(query) + if (node == "") == (query == "") { + return Resolution{}, apperrors.NewValidation( + "文档目标必须且只能提供一个:--node 或 --query", + apperrors.WithReason("document_target_invalid"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + ) + } + if node != "" { + return Resolution{ + ContractVersion: "doc.target.v1", + Status: "resolved", + MatchedBy: "stable_id_or_url", + Complete: true, + Selected: Candidate{ + CanonicalID: node, + Product: "doc", + }, + Candidates: []Candidate{}, + }, nil + } + + candidates, err := searchAll(rt, query) + if err != nil { + return Resolution{}, err + } + selected, matchedBy := selectCandidates(candidates, query) + if len(selected) == 0 { + return Resolution{}, resolutionError("not_found", query, candidates, "没有找到匹配的在线文字文档") + } + if len(selected) > 1 { + return Resolution{}, resolutionError("ambiguous", query, selected, "找到多个匹配文档,请使用 nodeId 或 URL 消歧") + } + if typ := normalizeType(selected[0].ResourceType); typ != "" && typ != "adoc" && typ != "document" && typ != "doc" { + return Resolution{}, apperrors.NewValidation( + fmt.Sprintf("目标 %q 的资源类型为 %s,不是在线文字文档", selected[0].Name, selected[0].ResourceType), + apperrors.WithReason("document_target_type_mismatch"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithDetails(map[string]any{"status": "type_mismatch", "target": selected[0]}), + ) + } + return Resolution{ + ContractVersion: "doc.target.v1", + Status: "resolved", + Query: query, + MatchedBy: matchedBy, + Complete: true, + Selected: selected[0], + Candidates: []Candidate{}, + }, nil +} + +func searchAll(rt Reader, query string) ([]Candidate, error) { + cursor := "" + seenCursors := map[string]bool{} + all := make([]Candidate, 0) + for page := 1; page <= searchPageLimit; page++ { + params := map[string]any{"keyword": query, "pageSize": searchPageSize} + if cursor != "" { + params["pageToken"] = cursor + } + data, err := rt.CallMCPData("doc", "search_documents", params) + if err != nil { + return nil, err + } + rows := extractRows(data) + all = append(all, extractCandidates(rows)...) + hasMore, hasMoreKnown, next := extractPage(data) + switch { + case hasMoreKnown && !hasMore: + return dedupe(all), nil + case hasMoreKnown && hasMore: + // next is validated below. + case next != "": + // Older responses may only publish the continuation. + case len(rows) < searchPageSize: + return dedupe(all), nil + default: + return nil, incompleteError(query, all, "搜索返回满页结果但没有结束标记或下一页游标") + } + next = strings.TrimSpace(next) + if next == "" { + return nil, incompleteError(query, all, "搜索声明仍有更多结果但缺少下一页游标") + } + if next == cursor || seenCursors[next] { + return nil, incompleteError(query, all, fmt.Sprintf("搜索分页游标停滞在 %q", next)) + } + seenCursors[next] = true + cursor = next + } + return nil, incompleteError(query, all, fmt.Sprintf("搜索超过最大页数 %d", searchPageLimit)) +} + +func selectCandidates(candidates []Candidate, query string) ([]Candidate, string) { + exact := make([]Candidate, 0) + for _, candidate := range candidates { + if strings.EqualFold(strings.TrimSpace(candidate.Name), query) { + exact = append(exact, candidate) + } + } + if len(exact) > 0 { + return exact, "exact_title" + } + return candidates, "search_candidate" +} + +func extractCandidates(rows []any) []Candidate { + out := make([]Candidate, 0, len(rows)) + for _, row := range rows { + item, ok := row.(map[string]any) + if !ok { + continue + } + candidate := Candidate{ + CanonicalID: firstString(item, "nodeId", "node_id", "docId", "doc_id", "id"), + Name: firstString(item, "name", "title", "docName", "fileName"), + Product: "doc", + ResourceType: firstString(item, "docType", "doc_type", "extension", "fileType", "type"), + URL: firstString(item, "url", "docUrl", "nodeUrl", "webUrl"), + ContainerID: firstString(item, "workspaceId", "spaceId", "folderId", "parentId"), + } + if candidate.CanonicalID != "" { + out = append(out, candidate) + } + } + return out +} + +func dedupe(in []Candidate) []Candidate { + seen := make(map[string]bool, len(in)) + out := make([]Candidate, 0, len(in)) + for _, candidate := range in { + if candidate.CanonicalID == "" || seen[candidate.CanonicalID] { + continue + } + seen[candidate.CanonicalID] = true + out = append(out, candidate) + } + return out +} + +func extractRows(data map[string]any) []any { + for _, key := range []string{"documents", "nodes", "items", "list", "records", "result", "data"} { + value, ok := data[key] + if !ok { + continue + } + if rows, ok := value.([]any); ok { + return rows + } + if nested, ok := value.(map[string]any); ok { + if rows := extractRows(nested); rows != nil { + return rows + } + } + } + return []any{} +} + +func extractPage(data map[string]any) (bool, bool, string) { + var hasMore bool + var hasMoreKnown bool + var next string + var walk func(map[string]any) + walk = func(value map[string]any) { + if !hasMoreKnown { + for _, key := range []string{"hasMore", "has_more"} { + if flag, ok := value[key].(bool); ok { + hasMore, hasMoreKnown = flag, true + break + } + } + } + if next == "" { + next = firstString(value, "nextPageToken", "nextCursor", "next_page_token") + } + for _, key := range []string{"result", "data", "page", "pagination"} { + if nested, ok := value[key].(map[string]any); ok { + walk(nested) + } + } + } + walk(data) + return hasMore, hasMoreKnown, next +} + +func firstString(value map[string]any, keys ...string) string { + for _, key := range keys { + if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + return "" +} + +func normalizeType(value string) string { + return strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, "."))) +} + +func resolutionError(status, query string, candidates []Candidate, message string) error { + return apperrors.NewValidation( + message, + apperrors.WithReason("document_target_"+status), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.target.v1", + "status": status, + "query": query, + "complete": true, + "candidates": candidates, + }), + ) +} + +func incompleteError(query string, candidates []Candidate, cause string) error { + return apperrors.NewAPI( + "无法证明文档搜索候选完整,已停止目标解析", + apperrors.WithReason("document_target_incomplete"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(true), + apperrors.WithActions("稍后以相同条件重试", "使用已知 nodeId 或 URL 直接指定目标"), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.target.v1", + "status": "incomplete", + "query": query, + "complete": false, + "cause": cause, + "candidates": dedupe(candidates), + }), + ) +} diff --git a/internal/shortcut/docresolver/resolver_test.go b/internal/shortcut/docresolver/resolver_test.go new file mode 100644 index 00000000..26445b0c --- /dev/null +++ b/internal/shortcut/docresolver/resolver_test.go @@ -0,0 +1,154 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package docresolver + +import ( + "errors" + "fmt" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +type scriptedReader struct { + pages []map[string]any + err error + calls int +} + +func (r *scriptedReader) CallMCPData(_ string, _ string, _ map[string]any) (map[string]any, error) { + r.calls++ + if r.err != nil { + return nil, r.err + } + if len(r.pages) == 0 { + return map[string]any{}, nil + } + page := r.pages[0] + r.pages = r.pages[1:] + return page, nil +} + +func row(id, name, typ string) any { + return map[string]any{"nodeId": id, "name": name, "docType": typ, "url": "https://alidocs.dingtalk.com/i/nodes/" + id} +} + +func TestCrossPlatformCoverageResolveStableTargetDoesNotSearch(t *testing.T) { + reader := &scriptedReader{} + resolution, err := Resolve(reader, "node-1", "") + if err != nil || reader.calls != 0 || resolution.Selected.CanonicalID != "node-1" || !resolution.Complete { + t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err) + } +} + +func TestCrossPlatformCoverageResolveNaturalTitleExhaustsPagesBeforeSelecting(t *testing.T) { + first := make([]any, 0, searchPageSize) + for i := 0; i < searchPageSize; i++ { + first = append(first, row(fmt.Sprintf("other-%d", i), "其他", "adoc")) + } + reader := &scriptedReader{pages: []map[string]any{ + {"documents": first, "hasMore": true, "nextPageToken": "p2"}, + {"documents": []any{row("wanted", "项目周报", "adoc")}, "hasMore": false}, + }} + resolution, err := Resolve(reader, "", "项目周报") + if err != nil || reader.calls != 2 || resolution.Selected.CanonicalID != "wanted" || resolution.MatchedBy != "exact_title" { + t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err) + } +} + +func TestCrossPlatformCoverageResolveFailsClosedForAmbiguousIncompleteAndWrongType(t *testing.T) { + tests := []struct { + name string + pages []map[string]any + query string + reason string + }{ + {"ambiguous", []map[string]any{{"documents": []any{row("1", "周报", "adoc"), row("2", "周报", "adoc")}, "hasMore": false}}, "周报", "ambiguous"}, + {"not found", []map[string]any{{"documents": []any{}, "hasMore": false}}, "missing", "not_found"}, + {"stalled", []map[string]any{{"documents": []any{row("1", "周报", "adoc")}, "hasMore": true, "nextPageToken": "p1"}, {"documents": []any{}, "hasMore": true, "nextPageToken": "p1"}}, "周报", "incomplete"}, + {"wrong type", []map[string]any{{"documents": []any{row("1", "周报", "pdf")}, "hasMore": false}}, "周报", "type_mismatch"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := Resolve(&scriptedReader{pages: tc.pages}, "", tc.query) + var typed *apperrors.Error + if err == nil || !errors.As(err, &typed) || !strings.Contains(typed.Reason, tc.reason) { + t.Fatalf("err=%v", err) + } + }) + } + reader := &scriptedReader{err: errors.New("backend")} + if _, err := Resolve(reader, "", "x"); !errors.Is(err, reader.err) { + t.Fatalf("transport error=%v", err) + } +} + +func TestCrossPlatformCoverageResolverFinalBranchMatrix(t *testing.T) { + for _, target := range [][2]string{{"", ""}, {"node", "query"}} { + if _, err := Resolve(&scriptedReader{}, target[0], target[1]); err == nil { + t.Fatalf("invalid target %#v succeeded", target) + } + } + + full := make([]any, searchPageSize) + for i := range full { + full[i] = row(fmt.Sprintf("full-%d", i), "other", "adoc") + } + for _, tc := range []struct { + name string + pages []map[string]any + }{ + {"unproven full page", []map[string]any{{"documents": full}}}, + {"missing cursor", []map[string]any{{"documents": []any{}, "hasMore": true}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := Resolve(&scriptedReader{pages: tc.pages}, "", "wanted"); err == nil { + t.Fatal("incomplete pagination succeeded") + } + }) + } + if _, err := Resolve(&scriptedReader{pages: []map[string]any{{"documents": []any{}}}}, "", "wanted"); err == nil { + t.Fatal("underfilled implicit final page should resolve to not-found") + } + pages := make([]map[string]any, searchPageLimit) + for i := range pages { + pages[i] = map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": fmt.Sprintf("p-%d", i+1)} + } + if _, err := Resolve(&scriptedReader{pages: pages}, "", "wanted"); err == nil { + t.Fatal("max-page pagination succeeded") + } + + nested := map[string]any{ + "result": map[string]any{ + "data": map[string]any{ + "records": []any{ + "invalid", + map[string]any{"id": "", "title": "empty"}, + map[string]any{"doc_id": "d", "fileName": "Doc", "fileType": ".ADOC", "webUrl": " u ", "parentId": "p"}, + }, + "pagination": map[string]any{"has_more": false, "next_page_token": " next "}, + }, + }, + } + rows := extractRows(nested) + candidates := extractCandidates(rows) + if len(candidates) != 1 || candidates[0].CanonicalID != "d" || normalizeType(candidates[0].ResourceType) != "adoc" { + t.Fatalf("nested candidates = %#v", candidates) + } + if hasMore, known, next := extractPage(nested); !known || hasMore || next != "next" { + t.Fatalf("nested page = %v/%v/%q", hasMore, known, next) + } + if rows := extractRows(map[string]any{"items": "not-a-list"}); len(rows) != 0 { + t.Fatalf("non-list rows = %#v", rows) + } + deduped := dedupe([]Candidate{{}, {CanonicalID: "d"}, {CanonicalID: "d"}}) + if len(deduped) != 1 { + t.Fatalf("deduped = %#v", deduped) + } + selected, matchedBy := selectCandidates([]Candidate{{CanonicalID: "d", Name: "Different"}}, "query") + if len(selected) != 1 || matchedBy != "search_candidate" { + t.Fatalf("fallback selection = %#v/%q", selected, matchedBy) + } +} diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index 09810782..af7138f6 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -153,6 +153,20 @@ func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]an return rt.callMCPData(product, tool, params) } +// CallMCPReadData dispatches a tool whose name is explicitly classified as +// read-only without consulting Cobra state. Callers must invoke it serially +// unless the injected ToolCaller separately documents and enforces concurrent +// safety. Write-shaped tool names fail closed. +func (rt *RuntimeContext) CallMCPReadData(product, tool string, params map[string]any) (map[string]any, error) { + if !looksReadTool(tool) { + return nil, apperrors.NewValidation(fmt.Sprintf( + "并发只读入口拒绝写工具 %s/%s;写操作必须使用 CallMCPWriteData", + product, tool, + )) + } + return rt.callMCPReadData(product, tool, params) +} + // CallMCPWriteData dispatches a write tool call and returns its parsed response. // Unlike CallMCPData, it refuses to run under --dry-run so smart shortcuts cannot // accidentally perform writes while rendering a preview. For compatibility with diff --git a/internal/shortcut/semantic_catalog_doc.json b/internal/shortcut/semantic_catalog_doc.json index 4693cbdf..4bd60e75 100644 --- a/internal/shortcut/semantic_catalog_doc.json +++ b/internal/shortcut/semantic_catalog_doc.json @@ -11,11 +11,11 @@ "+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true}, "+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true}, "+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。","risk":"write","public":true,"reviewed":true}, - "+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true}, - "+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true}, - "+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true}, + "+version-save": {"disposition":"semantic_adapter","semantic_delta":"版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。","risk":"write","public":true,"reviewed":true}, + "+version-list": {"disposition":"semantic_adapter","semantic_delta":"版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。","risk":"read","public":true,"reviewed":true}, + "+version-revert": {"disposition":"primary_smart","semantic_delta":"版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。","risk":"high-risk-write","public":true,"reviewed":true}, "+template-list": {"disposition":"semantic_adapter","semantic_delta":"统一 MY/PUBLIC 模板浏览和分页参数。","risk":"read","public":true,"reviewed":true}, - "+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称检索模板并返回可继续创建的 templateId。","risk":"read","public":true,"reviewed":true}, + "+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称或关键词检索模板并返回可消歧候选和 templateId。","risk":"read","public":true,"reviewed":true}, "+create-from-template": {"disposition":"primary_smart","semantic_delta":"支持 templateId 直达或按名称搜索消歧后创建文档。","risk":"write","public":true,"reviewed":true}, "+media-list": {"disposition":"semantic_adapter","semantic_delta":"从文档块中提取图片、附件及其 block/resource 标识。","risk":"read","public":true,"reviewed":true}, "+media-insert": {"disposition":"primary_smart","semantic_delta":"组合本地文件校验、上传凭证、OSS PUT、插块和验证。","risk":"write","public":true,"reviewed":true}, @@ -40,9 +40,9 @@ "+find-doc": {"disposition":"alias_internal","semantic_delta":"保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true}, "+doc-append": {"disposition":"alias_internal","semantic_delta":"保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。","risk":"write","primary":"+update","public":true,"reviewed":true}, - "+version-save": {"disposition":"alias_internal","semantic_delta":"保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。","risk":"write","primary":"+history-save","public":true,"reviewed":true}, - "+version-list": {"disposition":"alias_internal","semantic_delta":"保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。","risk":"read","primary":"+history-list","public":true,"reviewed":true}, - "+version-revert": {"disposition":"alias_internal","semantic_delta":"保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。","risk":"high-risk-write","primary":"+history-revert","public":true,"reviewed":true}, + "+history-save": {"disposition":"alias_internal","semantic_delta":"保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。","risk":"write","primary":"+version-save","public":true,"reviewed":true}, + "+history-list": {"disposition":"alias_internal","semantic_delta":"保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。","risk":"read","primary":"+version-list","public":true,"reviewed":true}, + "+history-revert": {"disposition":"alias_internal","semantic_delta":"保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。","risk":"high-risk-write","primary":"+version-revert","public":true,"reviewed":true}, "+share-doc": {"disposition":"alias_internal","semantic_delta":"保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。","risk":"write","primary":"+share","public":true,"reviewed":true}, "+list": {"disposition":"alias_internal","semantic_delta":"旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。","risk":"read","primary":"drive list","public":true,"reviewed":true}, @@ -50,7 +50,7 @@ "+move": {"disposition":"alias_internal","semantic_delta":"旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。","risk":"write","primary":"drive move","public":true,"reviewed":true}, "+export-submit": {"disposition":"alias_internal","semantic_delta":"导出中断恢复所需的专家入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true}, "+export-get": {"disposition":"alias_internal","semantic_delta":"按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true}, - "+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"已知 block/start/end 时使用的低级批注入口;常规场景使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true}, + "+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"仅兼容既有调用;selection 与 block/start/end 新场景都统一使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true}, "+template-apply": {"disposition":"alias_internal","semantic_delta":"已知 templateId 时使用的低级入口;常规场景使用 +create-from-template。","risk":"write","primary":"+create-from-template","public":false,"reviewed":true} } } diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index b1a5139c..5cbf5704 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -685,6 +685,14 @@ func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) { } } +func TestCrossPlatformCoverageCallMCPReadDataRejectsWriteName(t *testing.T) { + rt := &RuntimeContext{} + _, err := rt.CallMCPReadData("doc", "update_document", map[string]any{"nodeId": "n"}) + if err == nil || !strings.Contains(err.Error(), "doc/update_document") { + t.Fatalf("read-only guard error = %v", err) + } +} + func TestCrossPlatformCoverageCallMCPDataDryRunReadNameFailsClosed(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("dry-run", false, "") diff --git a/scripts/gen_skill_shortcut_sections.py b/scripts/gen_skill_shortcut_sections.py index f79b0b04..a75cf44a 100644 --- a/scripts/gen_skill_shortcut_sections.py +++ b/scripts/gen_skill_shortcut_sections.py @@ -59,7 +59,7 @@ RUNTIME_CONTRACT_END = "" # here only after verifying that the product skill has its own reviewed routing # section and intent table; compacting a sparse skill without an alternative # route would make its shortcuts harder to discover. -COMPACT_PRODUCT_SERVICES = {"chat"} +COMPACT_PRODUCT_SERVICES = {"chat", "doc"} def md_escape(value: Any) -> str: @@ -121,7 +121,10 @@ def runtime_contract_block() -> str: def mono_overview(items: list[dict[str, Any]]) -> str: - counts = Counter(item["service"] for item in items) + # The source collector cannot see a small number of commands whose final + # canonical names are normalized at runtime. Overview counts come from the + # reviewed public catalog so they cannot under-report those declarations. + counts = Counter(service for service, _ in load_public_catalog()) rows = [] for service, count in sorted(counts.items()): path = SERVICE_TO_SKILL.get(service) @@ -163,10 +166,18 @@ def product_section(service: str, rows: list[dict[str, Any]]) -> str: def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str: + # Compact skills intentionally do not depend on the source parser's ability + # to recover every runtime-normalized declaration. The reviewed public + # catalog is the count authority for this non-enumerating overview. + public_count = sum(1 for item_service, _ in load_public_catalog() if item_service == service) + if service == "doc": + discovery = """已知意图按下方路由。""" + else: + discovery = """已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。""" return f"""{PRODUCT_START} ## Shortcut 发现(按需) -`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 +`{md_escape(service)}` 当前有 {public_count} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。{discovery} 仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。 {PRODUCT_END}""" @@ -194,6 +205,7 @@ def update_runtime_contract(check: bool) -> list[Path]: changed = [] targets = [ ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md", + ROOT / "skills" / "multi" / "dingtalk-doc" / "SKILL.md", SHARED_SKILL, ] for path in targets: diff --git a/scripts/policy/check-skill-context-budget.sh b/scripts/policy/check-skill-context-budget.sh index 4d0356d8..d425a344 100755 --- a/scripts/policy/check-skill-context-budget.sh +++ b/scripts/policy/check-skill-context-budget.sh @@ -7,12 +7,14 @@ cd "$ROOT" python3 scripts/gen_skill_shortcut_sections.py --check chat_skill="skills/multi/dingtalk-chat/SKILL.md" +doc_skill="skills/multi/dingtalk-doc/SKILL.md" event_skill="skills/multi/dingtalk-misc/references/event.md" mono_skill="skills/mono/SKILL.md" runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md" chat_target_bytes=10000 chat_max_overage_percent=5 chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100)) +doc_max_bytes=9000 event_max_bytes=10000 runtime_contract_max_bytes=3000 @@ -23,6 +25,13 @@ if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then exit 1 fi +doc_bytes="$(wc -c < "$doc_skill" | tr -d ' ')" +if [ "$doc_bytes" -gt "$doc_max_bytes" ]; then + printf '%s\n' \ + "skill context budget exceeded: $doc_skill is ${doc_bytes} bytes (max ${doc_max_bytes})" >&2 + exit 1 +fi + event_bytes="$(wc -c < "$event_skill" | tr -d ' ')" if [ "$event_bytes" -gt "$event_max_bytes" ]; then printf '%s\n' \ @@ -51,6 +60,20 @@ if [ "$shortcut_rows" -ne 0 ]; then exit 1 fi +doc_shortcut_rows="$( + awk ' + // { in_block = 1; next } + // { in_block = 0 } + in_block && /^\| `dws doc \+/ { count++ } + END { print count + 0 } + ' "$doc_skill" +)" +if [ "$doc_shortcut_rows" -ne 0 ]; then + printf '%s\n' \ + "skill context budget exceeded: $doc_skill re-expanded $doc_shortcut_rows shortcut rows" >&2 + exit 1 +fi + for required_heading in \ "## 最小 DWS 执行契约" \ "## Golden Route" \ @@ -65,6 +88,67 @@ do fi done +for required_heading in \ + "## 最小 DWS 执行契约" \ + "## Golden Route" \ + "## 关键结果语义" \ + "## 按需加载" \ + "## 错误最短路径" +do + if ! grep -Fq "$required_heading" "$doc_skill"; then + printf '%s\n' \ + "skill value regression: $doc_skill is missing $required_heading" >&2 + exit 1 + fi +done + +for required_route in \ + "+search" \ + "+fetch" \ + "+create" \ + "+update" \ + "+checkpoint-update" \ + "+export" +do + if ! grep -Fq -- "$required_route" "$doc_skill"; then + printf '%s\n' \ + "skill route regression: $doc_skill is missing $required_route" >&2 + exit 1 + fi +done + +for forbidden_route in \ + "## 标准 SOP" \ + "doc_create_and_write.py" \ + "只在任务命中时读取一个精确 reference" \ + "MUST 先用 Read" \ + "先用 Read 工具完整读取" +do + if grep -Fq "$forbidden_route" "$doc_skill"; then + printf '%s\n' \ + "skill route regression: $doc_skill restored legacy route: $forbidden_route" >&2 + exit 1 + fi +done + +for required_reference_rule in \ + "Golden Route 已给出命令且参数足够时,禁止读取 reference" \ + "才最多读取一个 reference" \ + "\`+create\`、\`+fetch\`、\`+update\` append/overwrite、\`+export\`、\`+import\` 禁止读取 reference" +do + if ! grep -Fq "$required_reference_rule" "$doc_skill"; then + printf '%s\n' \ + "skill reference budget regression: $doc_skill is missing $required_reference_rule" >&2 + exit 1 + fi +done + +if grep -Fq "../dingtalk-shared/SKILL.md" "$doc_skill"; then + printf '%s\n' \ + "skill context regression: $doc_skill requires full dingtalk-shared cold-start loading" >&2 + exit 1 +fi + for required_route in \ "+dm" \ "+send-to-group" \ @@ -108,4 +192,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then fi printf '%s\n' \ - "skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)" + "skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% doc_bytes=$doc_bytes doc_max=$doc_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows doc_shortcut_rows=$doc_shortcut_rows)" diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index d186a6c1..9f2dc082 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "os" + "reflect" "sort" "strings" ) @@ -510,7 +511,8 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri } if oldTool.Constraints != newTool.Constraints && !compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) && - !compatibleAdditiveConstraintEvolution(oldTool, newTool) { + !compatibleAdditiveConstraintEvolution(oldTool, newTool) && + !compatibleReviewedConstraintTransition(toolPath, oldTool, newTool) { failures = append(failures, fmt.Sprintf("schema tool %q changed constraints", toolPath)) } if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) { @@ -562,6 +564,31 @@ var reviewedInterfaceRefRedirect = map[string]map[string]string{ }, } +// reviewedConstraintTransition enumerates exact constraint changes that were +// explicitly reviewed even though the compatibility gate cannot infer their +// business semantics. Keep this narrow: removing a constraint can expose a new +// runtime route, so arbitrary removals must not pass as harmless drift. +var reviewedConstraintTransition = map[string]map[string]string{ + // PR #933 aligned the Shortcut and Schema with the existing doc import + // runtime: omitting both targets imports into the default root. Keeping the + // historical require_one_of made the documented Golden Route unreachable. + "doc/doc.shortcut_import": { + `{"require_one_of":[["folder","workspace"]]}`: "", + }, +} + +func compatibleReviewedConstraintTransition(toolPath string, oldTool, newTool toolSchema) bool { + transitions, ok := reviewedConstraintTransition[toolPath] + if !ok { + return false + } + want, ok := transitions[oldTool.Constraints] + if !ok { + return false + } + return want == newTool.Constraints +} + // compatibleInterfaceRefRedirect accepts repointing a tool at a different // backing RPC when the migration is an explicitly reviewed entry in // reviewedInterfaceRefRedirect **and** the CLI-facing contract is provably @@ -814,14 +841,120 @@ func compatiblePositionals(oldPositionals, newPositionals []positionalSchema) bo return true } +// parameterTypeChange identifies one published parameter type migration +// exactly: which tool, which parameter, and in which direction. Nothing is +// matched by wildcard — an entry differing in any of the four fields does not +// apply. +// +// ToolPath is "/" exactly as the comparison builds it (see +// where toolPath is assembled), e.g. +// "minutes/minutes.apply_minutes_permission". From and To are the canonical +// type values schemaType produces — the JSON encoding of the "type" keyword, +// so `"string"` **with** its quotes, not a bare string. Spelling either one any +// other way silently disables the exemption instead of failing loudly (exactly +// how reviewedInterfaceRefRedirect was broken twice), so +// TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical +// recomputes both through schemaType rather than trusting the table. +type parameterTypeChange struct { + ToolPath string + Parameter string + From string + To string +} + +// reviewedParameterTypeChanges enumerates the individually reviewed parameter +// type migrations this gate accepts. A published type is part of the Schema +// contract and a swap cannot be proven safe from the type names alone, so a +// migration is accepted only when this exact tool, parameter and old→new pair +// appear here. Adding an entry is a contract decision and belongs in review, +// not in a feature change. +// +// Entries are direction-sensitive by construction: "string" → "integer" is a +// separate key from "integer" → "string", and only the reviewed direction is +// accepted. +// +// The table alone does not admit a change: the migration is rejected unless +// every other published field of the parameter is identical — not merely free +// of compatibility failures. See compatibleReviewedParameterTypeChange. +var reviewedParameterTypeChanges = map[parameterTypeChange]struct{}{ + // "dws minutes permission apply --policy" moved from a String flag to a + // native Int flag, so the published type follows it from "string" to + // "integer". A parameter's type is projected from the Cobra flag type + // (provenance source cobra_flag_type), so it describes how the CLI accepts + // the value, not how the backing RPC receives it. + // + // Accepted because no historical caller is invalidated: + // + // - Consumers read this Schema to construct CLI invocations, and a command + // line is text either way: "--policy 4" is the same argv under both + // declarations, and a quoted "--policy \"4\"" still reaches pflag as 4. + // - The flag keeps enforcing the same [2,4] domain in RunE, and pflag's + // base-0 parse accepts every base-10 spelling the previous + // strconv.ParseInt(v, 10, 64) accepted, so the set of successful + // invocations only widens. + // - "integer" also matches what actually goes on the wire: this parameter + // maps to property "policyId", which the command has always sent as a + // number, so the new declaration is closer to the request than the old. + // + // The parameter's default stays absent on both sides here (a zero default is + // not published) and every other published field is identical, which is why + // the equality guard admits this entry. A migration that also moved the + // default — or relaxed required, cleared required_when, widened enum, cleared + // interface_type — would be rejected even though several of those are + // individually compatible, because none of them were reviewed under this + // entry. + { + ToolPath: "minutes/minutes.apply_minutes_permission", + Parameter: "policy", + From: `"string"`, + To: `"integer"`, + }: {}, +} + +// parameterContractUnchangedExceptType reports whether every published field of +// the parameter other than its type is identical. +// +// This is a full equality check rather than "the gate recorded no other +// failure", because several field changes are individually *compatible* and so +// produce no failure at all: relaxing required or cli_required, clearing +// required_when, widening enum, clearing interface_type, and clearing property +// through a reviewed mapping exclusion. Keying the carve-out on the failure list +// would let any of those ride along with a reviewed type migration without ever +// having been reviewed under that entry — the exemption would be wider than what +// it documents. +// +// Comparing the struct also means a field added to parameterSchema later is +// covered here automatically, instead of silently widening every existing entry. +func parameterContractUnchangedExceptType(oldParameter, newParameter parameterSchema) bool { + oldParameter.Type = "" + newParameter.Type = "" + return reflect.DeepEqual(oldParameter, newParameter) +} + +// compatibleReviewedParameterTypeChange accepts a published parameter type +// migration when it is an explicitly reviewed entry **and** the rest of the +// parameter's published contract is unchanged. +func compatibleReviewedParameterTypeChange(toolPath, name string, oldParameter, newParameter parameterSchema) bool { + _, reviewed := reviewedParameterTypeChanges[parameterTypeChange{ + ToolPath: toolPath, + Parameter: name, + From: oldParameter.Type, + To: newParameter.Type, + }] + return reviewed && parameterContractUnchangedExceptType(oldParameter, newParameter) +} + func checkParameterCompatibility(toolPath, name string, oldParameter, newParameter parameterSchema) []string { var failures []string + if oldParameter.Type != newParameter.Type && + !compatibleReviewedParameterTypeChange(toolPath, name, oldParameter, newParameter) { + failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed type", toolPath, name)) + } for _, field := range []struct { name string old string new string }{ - {name: "type", old: oldParameter.Type, new: newParameter.Type}, {name: "default", old: oldParameter.Default, new: newParameter.Default}, {name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault}, {name: "format", old: oldParameter.Format, new: newParameter.Format}, diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index 2d10fa53..72bc6462 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -430,6 +430,239 @@ func TestMergeContracts(t *testing.T) { } } +// registerReviewedParameterTypeFixture puts a fixture entry in the reviewed +// table for the duration of one test, so the behaviour tests exercise the real +// lookup instead of depending on whichever production entries happen to exist. +func registerReviewedParameterTypeFixture(t *testing.T, change parameterTypeChange) { + t.Helper() + if _, exists := reviewedParameterTypeChanges[change]; exists { + t.Fatalf("fixture %+v collides with a production entry", change) + } + reviewedParameterTypeChanges[change] = struct{}{} + t.Cleanup(func() { delete(reviewedParameterTypeChanges, change) }) +} + +func reviewedFormatTypeFixture() parameterTypeChange { + return parameterTypeChange{ + ToolPath: "doc/doc.create", + Parameter: "format", + From: `"string"`, + To: `"integer"`, + } +} + +const docCreateFormatTypeFailure = `schema tool "doc/doc.create" parameter "format" changed type` + +func assertSchemaFailureContains(t *testing.T, failures []string, want string) { + t.Helper() + for _, failure := range failures { + if strings.Contains(failure, want) { + return + } + } + t.Fatalf("failures %v do not contain %q", failures, want) +} + +func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChange(t *testing.T) { + baseline := cloneContract(baselineContract()) + current := cloneContract(baseline) + mutateParameter(¤t, func(parameter *parameterSchema) { parameter.Type = `"integer"` }) + + // Without an entry the migration is still a blocking change. + assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure) + + registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture()) + if failures := checkCompatibility(baseline, current); len(failures) != 0 { + t.Fatalf("a reviewed parameter type migration should pass: %v", failures) + } +} + +func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeIsDirectionSensitive(t *testing.T) { + registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture()) + + // The reverse migration shares tool and parameter but not direction, so the + // string->integer entry must not admit it. + baseline := cloneContract(baselineContract()) + mutateParameter(&baseline, func(parameter *parameterSchema) { parameter.Type = `"integer"` }) + current := cloneContract(baselineContract()) + assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure) +} + +func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsOtherToolsAndParameters(t *testing.T) { + baseline := cloneContract(baselineContract()) + current := cloneContract(baseline) + mutateParameter(¤t, func(parameter *parameterSchema) { parameter.Type = `"integer"` }) + + // Same parameter and direction, different tool. + other := reviewedFormatTypeFixture() + other.ToolPath = "doc/doc.elsewhere" + registerReviewedParameterTypeFixture(t, other) + assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure) + + // Same tool and direction, different parameter. + elsewhere := reviewedFormatTypeFixture() + elsewhere.Parameter = "title" + registerReviewedParameterTypeFixture(t, elsewhere) + assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure) +} + +// A reviewed migration is only accepted when the rest of the parameter's +// contract held still. Each case bundles one unrelated regression with the +// reviewed type change and requires the type failure to reappear, so the +// exemption cannot carry a second change in behind it. +func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsBundledRegression(t *testing.T) { + registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture()) + + tests := []struct { + name string + mutate func(*parameterSchema) + }{ + {name: "changed default", mutate: func(p *parameterSchema) { p.Default = `"text"` }}, + {name: "changed interface_default", mutate: func(p *parameterSchema) { p.InterfaceDefault = `"text"` }}, + {name: "changed format", mutate: func(p *parameterSchema) { p.Format = "uri" }}, + {name: "redirected property", mutate: func(p *parameterSchema) { p.Property = "renamed" }}, + {name: "redirected interface_type", mutate: func(p *parameterSchema) { p.InterfaceType = "integer" }}, + {name: "newly required", mutate: func(p *parameterSchema) { p.Required = true }}, + {name: "newly cli_required", mutate: func(p *parameterSchema) { p.CLIRequired = true }}, + {name: "changed required_when", mutate: func(p *parameterSchema) { p.RequiredWhen = "always" }}, + {name: "narrowed enum", mutate: func(p *parameterSchema) { p.Enum = []string{"markdown"} }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + baseline := cloneContract(baselineContract()) + current := cloneContract(baseline) + mutateParameter(¤t, func(parameter *parameterSchema) { + parameter.Type = `"integer"` + test.mutate(parameter) + }) + assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure) + }) + } +} + +// Every drift below is individually *compatible* — on its own it produces no +// failure at all — which is precisely why the carve-out cannot be keyed on "the +// gate recorded no other failure for this parameter". Bundled with a reviewed +// type migration they must still be rejected: none of them were reviewed under +// that entry, and admitting them would make the exemption wider than what the +// table documents. +// +// Each case first asserts that the drift alone really is compatible, so a future +// rule change that starts reporting it turns into an explicit failure here +// rather than quietly turning this test into a duplicate of the bundled- +// regression cases above. +func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsIndividuallyCompatibleDrift(t *testing.T) { + registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture()) + + tests := []struct { + name string + baseline func(*parameterSchema) + current func(*parameterSchema) + }{ + { + name: "relaxed required", + baseline: func(p *parameterSchema) { p.Required = true }, + current: func(p *parameterSchema) { p.Required = false }, + }, + { + name: "relaxed cli_required", + baseline: func(p *parameterSchema) { p.CLIRequired = true }, + current: func(p *parameterSchema) { p.CLIRequired = false }, + }, + { + name: "cleared required_when", + baseline: func(p *parameterSchema) { p.RequiredWhen = "always" }, + current: func(p *parameterSchema) { p.RequiredWhen = "" }, + }, + { + name: "widened enum", + current: func(p *parameterSchema) { p.Enum = []string{"html", "markdown", "text"} }, + }, + { + name: "cleared interface_type", + baseline: func(p *parameterSchema) { p.InterfaceType = "string" }, + current: func(p *parameterSchema) { p.InterfaceType = "" }, + }, + { + name: "cleared property through reviewed mapping exclusion", + current: func(p *parameterSchema) { + p.Property = "" + p.PropertySource = propertySourceReviewedMappingExclusion + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + baseline := cloneContract(baselineContract()) + if test.baseline != nil { + mutateParameter(&baseline, test.baseline) + } + + driftOnly := cloneContract(baseline) + mutateParameter(&driftOnly, test.current) + if failures := checkCompatibility(baseline, driftOnly); len(failures) != 0 { + t.Fatalf("前提不成立:该变化本身已不兼容,用例测不到相等性守卫: %v", failures) + } + + bundled := cloneContract(baseline) + mutateParameter(&bundled, func(parameter *parameterSchema) { + parameter.Type = `"integer"` + test.current(parameter) + }) + assertSchemaFailureContains(t, checkCompatibility(baseline, bundled), docCreateFormatTypeFailure) + }) + } +} + +// The behaviour tests above register their own fixtures, so they cannot catch a +// production entry whose type values are spelled in the wrong form. That mistake +// disables the exemption silently and only a real gate run reports it — exactly +// how reviewedInterfaceRefRedirect was broken twice. Recompute both values +// through schemaType instead of trusting the spelling in the table. +func TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical(t *testing.T) { + if len(reviewedParameterTypeChanges) == 0 { + t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除") + } + for change := range reviewedParameterTypeChanges { + if strings.Count(change.ToolPath, "/") != 1 || + strings.HasPrefix(change.ToolPath, "/") || strings.HasSuffix(change.ToolPath, "/") { + t.Errorf("tool path %q 不是 \"/\" 形态", change.ToolPath) + } + if strings.TrimSpace(change.Parameter) != change.Parameter || change.Parameter == "" || + strings.HasPrefix(change.Parameter, "-") { + t.Errorf("%s: 参数名应是 Schema 里的裸名,登记为 %q", change.ToolPath, change.Parameter) + } + if change.From == change.To { + t.Errorf("%s %s: From 与 To 相同,不构成迁移", change.ToolPath, change.Parameter) + } + for _, side := range []struct { + label string + value string + }{{"From", change.From}, {"To", change.To}} { + var name string + if err := json.Unmarshal([]byte(side.value), &name); err != nil { + t.Errorf("%s %s 的 %s=%q 不是合法的 JSON type 值(schemaType 产出带引号的形态,如 `\"string\"`): %v", + change.ToolPath, change.Parameter, side.label, side.value, err) + continue + } + if got := schemaType(map[string]any{"type": name}); got != side.value { + t.Errorf("%s %s 的 %s 不是规范形态\n 登记: %s\n 规范: %s", + change.ToolPath, change.Parameter, side.label, side.value, got) + } + // 复算只能证明引号形态对,证明不了类型名本身没拼错——"integar" 同样 + // 能通过复算。JSON Schema 的 type 取值是封闭集合,直接校验。 + switch name { + case "string", "integer", "number", "boolean", "array", "object", "null": + default: + t.Errorf("%s %s 的 %s=%q 不是 JSON Schema 的合法 type 名", + change.ToolPath, change.Parameter, side.label, name) + } + } + } +} + func baselineContract() schemaContract { return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{ "doc": {Tools: map[string]toolSchema{ @@ -693,6 +926,37 @@ func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing } } +func TestCrossPlatformCoverageSchemaCompatReviewedConstraintTransition(t *testing.T) { + const toolPath = "doc/doc.shortcut_import" + oldTool := toolSchema{Constraints: `{"require_one_of":[["folder","workspace"]]}`} + newTool := oldTool + newTool.Constraints = "" + + if !compatibleReviewedConstraintTransition(toolPath, oldTool, newTool) { + t.Fatal("reviewed doc import target removal must be accepted") + } + if failures := checkToolCompatibility(toolPath, oldTool, newTool); len(failures) != 0 { + t.Fatalf("reviewed doc import constraint transition failed: %v", failures) + } + + for _, test := range []struct { + name string + path string + old string + new string + }{ + {name: "unlisted tool", path: "doc/doc.other", old: oldTool.Constraints, new: ""}, + {name: "unlisted source", path: toolPath, old: `{"require_one_of":[["folder","workspace","name"]]}`, new: ""}, + {name: "unlisted target", path: toolPath, old: oldTool.Constraints, new: `{}`}, + } { + t.Run(test.name, func(t *testing.T) { + if compatibleReviewedConstraintTransition(test.path, toolSchema{Constraints: test.old}, toolSchema{Constraints: test.new}) { + t.Fatal("unreviewed constraint transition unexpectedly passed") + } + }) + } +} + // Clearing a property through the reviewed mapping exclusion table is the one // accepted shape, mirroring the interface_type retirement allowance. A leaf // whose backing RPC moved to a nested payload has no honest flat property to diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index dec4fa79..27d2800e 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -50,7 +50,7 @@ cli_version: ">=1.0.15" | `contact` | 14 | `dingtalk-contact` | | `devapp` | 19 | `dingtalk-misc` | | `ding` | 4 | `dingtalk-misc` | -| `doc` | 41 | `dingtalk-doc` | +| `doc` | 45 | `dingtalk-doc` | | `drive` | 7 | `dingtalk-drive` | | `mail` | 10 | `dingtalk-mail` | | `minutes` | 6 | `dingtalk-minutes` | diff --git a/skills/mono/references/products/doc.md b/skills/mono/references/products/doc.md index 500a3fe6..f7d2769c 100644 --- a/skills/mono/references/products/doc.md +++ b/skills/mono/references/products/doc.md @@ -1,27 +1,20 @@ # 文档 (doc) 命令参考 -## 查询命令帮助 +## 路由与帮助预算 -当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。 +已知精确命令和参数时直接执行,禁止预调用 Help。参数语义不确定时先查精确 leaf Schema;真实返回 `unknown flag` 后最多读取一次该 leaf Help。`unknown command` 只执行一次 `dws shortcut list --service doc --format json`,禁止试探候选后缀或执行产品级 `dws doc --help | grep/head`。 + +本页已给出精确命令且参数足够时,禁止读取 reference 子页。只有复杂 JSONML、block/划词评论高级参数、分页或部分成功恢复仍无法由本页和精确 leaf Schema处理时,才最多读取一个对应子 reference;普通创建、全文读取、append/overwrite、导出和导入直接执行。 + +版本快照、列表和恢复的 Agent 主入口固定为: ```bash -# 查看 doc 下所有子命令 -dws doc --help - -# 查看具体命令的完整参数说明 -dws doc read --help -dws doc create --help -dws doc block insert --help - -# 查看子命令组下的所有命令 -dws doc block --help -dws doc permission --help +dws doc +version-save --node +dws doc +version-list --node --limit 20 +dws doc +version-revert --node --version ``` -规则: -- 参数名不确定时 → 先 `--help`,再调用 -- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称 -- 不确定某个功能是否存在时 → `dws doc --help` 查看命令列表 +`+history-*` 仅兼容已有调用,不用于新的 Agent 选路。 ## 命令总览 @@ -1005,26 +998,25 @@ dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=749936706 | `--content -` | 从 stdin 读取(可配合 heredoc/pipe) | | `--content-file path` | 从文件读取(UTF-8),推荐 | -### 短/中等长度(< 200KB)— 单步创建 +### 创建并写入 — Runtime 自动分片 ```bash # 1. 把内容写入 UTF-8 文本文件: # Linux/Mac: /tmp/.md;Windows: %TEMP%\.md -# 2. 一步创建+写入: -dws doc create --name "<文档名>" --content-file [--folder ] [--workspace ] +# 2. 一步创建、必要时自动分片并回读验证: +dws doc +create --name "<文档名>" --content @<工作目录相对文件> [--folder ] [--workspace ] ``` -### 超长(> 200KB 兜底)— 创建空文档 + 分片追加 +`+create` 会按安全边界拆分长 Markdown,首片创建、后续片追加,每个写调用最多执行一次。若返回 `partial_success` 或 `unknown`,先按返回的 `nodeId/steps` 回读文档,不得重跑整个创建。 + +### 更新并验证 ```bash -# 1. 创建空文档拿 nodeId -dws doc create --name "<文档名>" [--folder/--workspace] # → nodeId - -# 2. 按 markdown 标题或段落边界切成 ≤200KB 的片段(不要切断表格) -# 3. 逐个追加: -dws doc update --node --content-file --mode append +dws doc +update --node --command append --content @<工作目录相对文件> ``` +Runtime 自动分片并在最后回读验证。重要覆盖使用 `+checkpoint-update`;不要自行编写重试循环,创建和追加在超时后可能已经提交。 + ### stdin 变体 ```bash @@ -1085,12 +1077,6 @@ dws doc media upload --node --file ./icon.svg \ media upload 返回的 `resourceId` / `resourceUrl` 只能用于同一 nodeId 下的白板 Vector/SVG。完整协议见 [whiteboard.md](./whiteboard.md)。 -## 自动化脚本 - -| 脚本 | 场景 | 用法 | -|------|------|------| -| [doc_create_and_write.py](../../scripts/doc_create_and_write.py) | 创建文档并写入 Markdown 内容 | `python doc_create_and_write.py --name "周报" --content "# 本周总结"` | - ## 相关产品 - [aitable](./aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档 diff --git a/skills/mono/scripts/doc_create_and_write.py b/skills/mono/scripts/doc_create_and_write.py deleted file mode 100644 index 367e4b93..00000000 --- a/skills/mono/scripts/doc_create_and_write.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -""" -在指定目录创建文档并写入 Markdown 内容(一键完成) - -用法: - python doc_create_and_write.py \ - --name "项目周报" \ - --content "# 本周总结\n\n## 完成事项\n- 任务A" - - python doc_create_and_write.py \ - --name "会议纪要" \ - --content-file notes.md - - python doc_create_and_write.py \ - --name "知识库文档" --content "# 内容" --folder FOLDER_ID - - python doc_create_and_write.py --name "test" --content "hello" --dry-run -""" - -import sys -import json -import time -import subprocess -import argparse -from pathlib import Path -from typing import List, Any, Optional - - -def run_dws( - args: List[str], dry_run: bool = False, -) -> Optional[Any]: - cmd = ['dws'] + args - if dry_run: - print(f"[dry-run] {' '.join(cmd)}") - return {'dry_run': True} - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=60 - ) - if result.returncode != 0: - print(f" ✗ 错误:{result.stderr.strip()}") - return None - return json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, - FileNotFoundError) as e: - print(f" ✗ 错误:{e}") - return None - - -def run_dws_with_retry( - args: List[str], - dry_run: bool = False, - max_retries: int = 3, - retry_delay: float = 1.0, -) -> Optional[Any]: - """带重试机制的 dws 命令执行""" - last_error = None - for attempt in range(1, max_retries + 1): - result = run_dws(args, dry_run=dry_run) - if result is not None: - return result - if attempt < max_retries: - print(f" ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...") - time.sleep(retry_delay) - retry_delay *= 1.5 # 指数退避 - return None - - -def main(): - parser = argparse.ArgumentParser( - description='创建文档并写入内容' - ) - parser.add_argument('--name', required=True, help='文档名称') - parser.add_argument('--content', default='', help='Markdown 内容') - parser.add_argument('--content-file', default='', help='内容文件') - parser.add_argument('--folder', default='', help='目标文件夹 ID 或 URL') - parser.add_argument('--workspace', default='', help='目标知识库 ID') - parser.add_argument( - '--mode', default='append', choices=['overwrite', 'append'], - help='写入模式: overwrite=覆盖, append=追加 (默认 append)', - ) - parser.add_argument( - '--max-retries', type=int, default=3, - help='每块写入失败时的最大重试次数 (默认 3)', - ) - parser.add_argument('--dry-run', action='store_true') - args = parser.parse_args() - - content = args.content - if args.content_file: - p = Path(args.content_file) - if not p.exists(): - print(f"错误:文件不存在: {p}") - sys.exit(1) - content = p.read_text(encoding='utf-8') - if not content: - print('错误:需要 --content 或 --content-file') - sys.exit(1) - chunk_size = 30000 - - create_args = ['doc', 'create', '--name', args.name, '--format', 'json'] - if args.folder: - create_args.extend(['--folder', args.folder]) - if args.workspace: - create_args.extend(['--workspace', args.workspace]) - - print(f'\n📝 创建文档: {args.name}') - create_data = run_dws(create_args, dry_run=args.dry_run) - - node_id = None - if not args.dry_run: - if not create_data: - sys.exit(1) - node_id = (create_data.get('nodeId') - or create_data.get('dentryUuid') - or create_data.get('id', '')) - print(f" ✓ 文档已创建 (ID: {node_id})") - - if len(content) <= chunk_size: - mode_label = '追加' if args.mode == 'append' else '覆盖' - print(f'\n✍️ 写入内容 (模式: {mode_label}, {len(content)} 字符)...') - write_data = run_dws([ - 'doc', 'update', - '--node', node_id or '', - '--content', content, - '--mode', args.mode, - '--format', 'json', - ], dry_run=args.dry_run) - if write_data: - print(f" ✓ 内容已写入 ({len(content)} 字符)") - else: - chunks = [] - pos = 0 - while pos < len(content): - end = min(pos + chunk_size, len(content)) - if end < len(content): - newline_pos = content.rfind('\n', pos, end) - if newline_pos > pos: - end = newline_pos + 1 - chunks.append(content[pos:end]) - pos = end - - total_chunks = len(chunks) - print(f'\n✍️ 内容较长 ({len(content)} 字符), 分 {total_chunks} 块写入...') - - success_chunks = 0 - for idx, chunk in enumerate(chunks): - chunk_mode = args.mode if idx == 0 else 'append' - write_data = run_dws_with_retry( - [ - 'doc', 'update', - '--node', node_id or '', - '--content', chunk, - '--mode', chunk_mode, - '--format', 'json', - ], - dry_run=args.dry_run, - max_retries=args.max_retries, - ) - if write_data: - print(f" ✓ 块 {idx + 1}/{total_chunks} 已写入 ({len(chunk)} 字符)") - success_chunks += 1 - elif not args.dry_run: - # 写入失败,报告部分写入状态 - print(f"\n❌ 块 {idx + 1}/{total_chunks} 写入失败(已重试 {args.max_retries} 次)") - print(f"\n⚠️ 文档处于部分写入状态:") - print(f" - 文档 ID: {node_id}") - print(f" - 已写入: {success_chunks}/{total_chunks} 块") - print(f" - 失败位置: 第 {idx + 1} 块") - if args.mode == 'overwrite': - print(f" - 模式: 覆盖模式,文档可能包含不完整内容") - print(f" - 建议: 手动检查文档内容,或删除后重新创建") - else: - print(f" - 模式: 追加模式,已写入内容已保存") - print(f" - 建议: 可手动补充剩余内容,或重新运行脚本") - sys.exit(1) - print('\n✅ 完成!') - - -if __name__ == '__main__': - main() diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md index 1e584bb8..c0f8a579 100644 --- a/skills/multi/dingtalk-doc/SKILL.md +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-doc -description: 钉钉文档(adoc):创建、读取、编辑、块、评论、附件、白板卡片、导出、版本及Markdown/JSONML写入。原生 .md→dingtalk-misc;文件→dingtalk-drive;知识库→dingtalk-wiki;axls→dingtalk-misc,able→dingtalk-aitable。 +description: 钉钉在线文字文档(adoc)的查找、读取、创建、编辑、块、评论、附件、导入导出、模板、版本和权限协作。普通文件走 dingtalk-drive,知识库空间走 dingtalk-wiki。命令前缀:dws doc。 metadata: cli_version: ">=0.2.14" category: product @@ -11,179 +11,95 @@ metadata: # 钉钉文档 Skill -## 前置条件 — 执行操作前必读 + +## 最小 DWS 执行契约 -> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。 - -> 命令参考:[doc.md](references/doc.md);剧本:[04-document.md](references/04-document.md)。 - -## 参数硬约束 - -- 创建文档只用 `--name`,不要写 `--title`。 -- 目标文件夹只用 `--folder <文档文件夹nodeId或URL>`,不要写 `--parent` / `--parent-node` / `--parent-id`。 -- 目标知识库只用 `--workspace `,不要写 `--space-id` / `--spaceId`。 -- 文档内容只用 `--content` / `--content-file`,不要写 `--markdown`。 -- 复杂内容(换行、表格、代码块、长 Markdown)先写临时 `.md`,再用 `--content-file`,不要把大段 Markdown 塞进命令行。 -- 每次 `create` / `update` / `block insert` / `media insert` 后必须 `dws doc read` 或 `dws doc block list` 回读关键内容。 -- `doc whiteboard insert`、`doc media upload` 属于远端写入;必须先获得用户确认,再加 `--yes`。 +- 只通过 `dws` CLI 操作钉钉;结构化读取使用 `--format json`,按真实返回判断结果。 +- 已知命令直接执行。只有 leaf 参数或安全语义不确定时读取精确 Schema,只有 Cobra flag 不确定时读取精确 leaf Help;不要加载产品级 Catalog 代替选路。 +- 不猜命令、flag、字段、ID、账号或时间。后续 ID 必须来自真实返回;零命中、多候选或类型不明时停止并消歧。 +- 解析目标、读取上下文和最终执行必须使用同一 profile;不得跨组织复用 userId、openDingTalkId 或 openConversationId。多账号组织只使用明确的 `isOrgCurrent=true` 默认账号;没有默认账号时要求用户指定,禁止选择第一项、最近登录或最近使用账号。 +- 不输出或记录 token、refresh token、appSecret、webhook token 等凭据;宿主已注入认证时不要索要凭据。 +- 写操作必须符合用户明确意图。是否需要确认以最终 Runtime gate 和 Schema 为准;需要确认时先说明对象、动作与影响,再追加 `--yes`。 +- 写后按任务结果契约验证;不能仅凭退出码宣称成功。部分结果、未知投递状态和失败项必须如实保留。 +- 时间戳面向用户展示时转换为带时区的可读时间;默认使用当前会话时区,必要时同时保留原值。 +- 遇到认证、权限、profile、confirmation 或未知错误时,只加载 `dingtalk-shared` 中对应 reference;不要连续猜测替代命令。 + -## Shortcuts(无专用脚本/recipe 时优先) +## Shortcut 发现(按需) -以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "doc +" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws doc --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service doc --format json` 批量发现。 +`doc` 当前有 45 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图按下方路由。 -| Shortcut | 风险 | 适用场景 | -|---|---|---| -| `dws doc +access-change` | write | 预检已有协作者后变更文档角色 | -| `dws doc +access-grant` | write | 按姓名解析后批量授予文档权限 | -| `dws doc +access-revoke` | high-risk-write | 预检并移除指定协作者的文档权限 | -| `dws doc +background-delete` | write | 清除文档背景色 | -| `dws doc +background-update` | write | 设置文档 #RRGGBB 背景纯色 | -| `dws doc +checkpoint-update` | write | 先保存可回滚版本,再更新并读回验证 | -| `dws doc +comment-create` | write | 在文档上创建一条评论 | -| `dws doc +comment-delete` | high-risk-write | 永久删除指定文档评论 | -| `dws doc +comment-list` | read | 查询文档评论列表 | -| `dws doc +comment-reply` | write | 回复文档中的一条评论 | -| `dws doc +comment-update` | write | 更新指定文档评论正文和 mention | -| `dws doc +copy` | write | 复制文档/文件到指定文件夹或知识库 | -| `dws doc +create` | write | 从 Markdown 或 JSONML 创建在线文字文档 | -| `dws doc +create-from-template` | write | 按 templateId 直达或搜索消歧后创建文档 | -| `dws doc +doc-append` | write | 在文档末尾追加一段文本(安全追加,不改动原有内容) | -| `dws doc +export` | read | 提交、轮询并安全下载在线文档导出文件 | -| `dws doc +export-get` | read | 根据 jobId 查询文档导出任务结果 | -| `dws doc +export-submit` | read | 提交在线文档导出任务 (docx/markdown/pdf),返回 jobId | -| `dws doc +fetch` | read | 读取完整或局部文档内容,并按 detail 控制保真度 | -| `dws doc +find-doc` | read | 按关键词搜索云文档并投影关键字段(只读) | -| `dws doc +grant-and-share` | write | 确保目标角色后按姓名逐人发送文档链接 | -| `dws doc +import` | write | 上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库 | -| `dws doc +inspect` | read | 聚合文档元信息,并按需附带样式、权限、历史、媒体和评论 | -| `dws doc +list` | read | 列出文件夹或知识库下的直接子节点 | -| `dws doc +media-download` | read | 安全下载文档正文附件到工作目录 | -| `dws doc +media-insert` | write | 上传本地图片或文件并插入文档正文 | -| `dws doc +media-list` | read | 列出文档正文中的图片和附件资源 | -| `dws doc +media-preview` | read | 下载正文媒体到受控临时目录并返回预览路径 | -| `dws doc +move` | write | 移动文档/文件到指定文件夹或知识库 | -| `dws doc +resource-delete` | high-risk-write | 幂等清除文档封面 | -| `dws doc +resource-download` | read | 读取并安全下载当前文档封面 | -| `dws doc +resource-update` | write | 从本地图片或 HTTPS URL 设置文档封面 | -| `dws doc +review` | read | 聚合未解决评论、引用原文和块上下文 | -| `dws doc +search` | read | 按关键词搜索有权限的文档 (不传则返回最近访问) | -| `dws doc +share-doc` | write | 按姓名把文档链接私信发给某人(自动解析 userId) | -| `dws doc +template-list` | read | 获取文档模板列表 | -| `dws doc +template-search` | read | 根据关键词搜索文档模板 | -| `dws doc +update` | write | 追加、覆盖或按 block 精确更新文档内容 | -| `dws doc +version-list` | read | 查看文档历史版本列表 | -| `dws doc +version-revert` | high-risk-write | 回滚文档到指定历史版本 | -| `dws doc +version-save` | write | 手动保存文档版本快照 | +仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service doc --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。 -## 意图表 +## Golden Route -| 用户说 | 命令 | -|--------|------| -| "创建文档(短内容)" | `dws doc create --name "<标题>" --content "<内容>"` | -| "创建+写入(长内容自动分块)" | `python scripts/doc_create_and_write.py --name "<标题>" --content "<内容>" [--mode append\|overwrite]` | -| "搜在线文字文档 / 找在线文字文档" | `dws drive search --query "<关键词>" --format json` → `dws drive info --node --format json` → 仅 `extension=adoc` 使用 `dws doc read --node --format json` | -| "读在线文字文档(adoc)内容" | `dws doc read --node --format json` | -| "更新文档内容 / 分块追加" | `dws doc update --node --content "<分块>" --mode append` | -| "删除块" | `dws doc block delete`(需用户确认) | -| "导出 docx / markdown / pdf" | `dws doc export --node --export-format --output ` | -| "导入本地文件为在线文档" | `dws doc import --file --folder --name "<标题>" --format json`(详见 `references/doc/doc-import.md`) | -| "查模板 / 套用模板创建文档" | `dws doc template list|search|apply`(详见 `references/doc.md` 模板管理) | -| "保存 / 查看 / 回滚在线文字文档(adoc)版本" | `dws doc version save/list/revert` | -| "在文档里创建空白板" | `dws doc whiteboard insert --node --yes --format json` | -| "为白板上传 SVG/Vector 资源" | `dws doc media upload --node --file --yes --format json` | +选择最小入口。ID/URL 直用;只有标题时先搜索,唯一候选后执行,禁止默认第一项。 -## 标准 SOP(必遵流程) +| 用户意图 | 唯一推荐入口 | 关键边界 | +|---|---|---| +| 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` | +| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node ` | `--scope keyword/section/range` 可减少无关正文;非 adoc 切换对应产品 | +| 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node ` | 仅打开任务所需的 `--include-*`,不要默认全取 | +| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 长或复杂 Markdown 使用 `@file`/stdin;根据结果验证真实 `nodeId` | +| 追加、覆盖或精确编辑 block | `dws doc +update --node --command <动作>` | `--expected-revision` 仅用于 JSONML 原子覆盖;先消费 Schema confirmation | +| 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` | +| 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 | +| 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` | +| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | folder/workspace 可选,缺省导入默认根;纯上传切 `dingtalk-drive` | +| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | 没有名称/关键词时使用;不调用 search/create | +| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 有查询词才使用;零或多候选停止 | +| 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search | +| 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` | +| 添加/调整/移除协作者权限 | `dws doc +access-grant/+access-change/+access-revoke` | 先读取现有权限;姓名歧义或 profile 不一致时禁止写入 | +| 授权后向多人分享链接 | `dws doc +grant-and-share` | 返回逐人执行账本;部分失败不等于整体成功 | +| 插入或下载正文媒体 | `dws doc +media-insert/+media-download` | 本地路径必须位于工作目录;下载默认 no-clobber | -> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 nodeId/blockId。结构化命令必须带 `--format json`,执行后必须按"验证"步回读真实字段。文件类操作(上传/下载/复制/移动)切 `dingtalk-drive`;知识库节点管理切 `dingtalk-wiki`。 +## 关键结果语义 -### SOP-1 查找并读取文档(query-doc) +- 保留真实 `nodeId`、URL、资源类型和容器;标题不能替代稳定 ID。 +- 按状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读且不重试写入;`retryable` 仅限明确未开始;权限、参数、认证失败停止。 +- 仅在结果明确且关键内容回读匹配后报告写入完成。 +- 搜索、列表和批量结果必须检查 `complete`、`hasMore`、continuation 和失败项;单页结果不得表述为完整集合。 +- 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。 -**触发**:查文档/读文档/某文档在哪/搜文档内容。 +## 参数与安全边界 -1. **定位(必须)**:用户已提供 URL / `nodeId` 时直接使用原值;未提供目标时才执行 `dws drive search --query "<关键词>" --format json`,再取候选结果的真实 `nodeId`。 -2. **探测(必须)**:对选中的候选执行 `dws drive info --node --format json`,从真实返回读取 `extension`;不得因为搜索结果标题像“文档”就跳过探测。 -3. **按类型读取(必须)**: - - `extension=adoc`:`dws doc read --node --format json`;大文档只抽取用户需要的章节。 - - `extension=md`:切到 `dingtalk-misc` 的 `references/markdown.md`,用 `dws markdown fetch --node --format json` 读取原文;仅需文件实体下载时切 `dingtalk-drive` 用 `drive download`。 - - `extension=axls`:切到 `dingtalk-misc`,读取 `references/sheet.md` 后按电子表格意图执行。 - - `extension=able`:切到 `dingtalk-aitable`。 - - `extension=xlsx` / `xls` / `xlsm` / `csv` 或其他普通文件:切到 `dingtalk-drive`;不得执行 `dws doc read`。 +- `@file` 统一协议:已有或临时文件先暂存到 cwd 后传 `@相对路径`;单次生成文本优先 `--content -` (stdin);禁止绝对路径和 `..`。 +- `doc +update` 的动作由 `--command` 指定;block 操作的 ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表。 +- Schema 门禁:已知只读路由且参数明确时直接执行;写命令或 selection/参数不清、准备 Help 时,本轮仅查一次 `dws schema --cli-path "doc +" --compact --fields use_when,avoid_when,parameters,constraints,confirmation -f json`;禁用产品级/`--all`。 +- 执行前消费其中的 `confirmation`:`user_required` 且用户已确认同一目标/动作/参数才加 `--yes`,否则预览/询问;禁止靠失败探测门禁。 +- JSONML 顶层必须是单个非空元素;禁止 `[[...]]` 元素数组包裹。 -**禁止**:用户未提供目标时跳过搜索并猜 nodeId、未探测类型就执行 `doc read`、把整篇文档原样贴给用户。 +## 按需加载 -### SOP-2 创建文档并写入(create-doc) +Golden Route 已给出命令且参数足够时,禁止读取 reference。其余仅在下列语义不明确时,才最多读取一个 reference: -**触发**:新建文档/写一篇/建文字文档。 +| 触发条件 | Reference | +|---|---| +| 低频/无 shortcut 意图消歧 | [intent-guide.md](references/intent-guide.md) / [doc.md](references/doc.md) 对应章节 | +| 分页、`partial_success`、`status=unknown` 或恢复 | [contracts.md](references/contracts.md) | +| 复杂 JSONML/非默认操作 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) | +| block/划词评论/媒体高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) | +| 导出/导入失败恢复 | [export](references/doc/doc-export.md) / [import](references/doc/doc-import.md) | -1. **执行(必须)**:`dws doc create --name "<标题>" --content-file [--folder | --workspace ] --format json`(长/多行内容用 `--content-file`,不要用 `--content` 拼长串;用户未指定位置时省略两个位置参数,创建到“我的文档”根目录)。 -2. **验证(必须)**:从返回取 `nodeId`,立即 `dws doc info --node --format json` 回读确认。 +`+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;禁止预加载或连读。 -**禁止**:创建后不回读就答复"已创建"、把 `--folder` 当成空间 ID 传入。 +## 错误最短路径 -### SOP-3 覆盖/追加内容(write-content) +1. 零命中、多候选、类型不明或分页不完整:停止后续写入,展示候选或 continuation;禁止默认第一项。 +2. Help 不参与选路;按上方门禁先消费一次精确 leaf Schema。只有真实 `unknown flag`/契约漂移后才查一次 leaf Help;`unknown command` 只查一次 shortcut 清单,禁止试探后缀和 `dws doc --help | grep/head`。 +3. `REVISION_CONFLICT`:重新读取当前 revision,展示差异;未经用户确认不得改成无 revision 覆盖。 +4. `partial_success`:保留已完成步骤,优先执行补偿或从 checkpoint 继续,不重放成功步骤。 +5. `doc_write_commit_unknown`:先回读文档确认状态;禁止自动重试创建或追加。 +6. 认证、权限或 profile 错误:只读取 `dingtalk-shared` 对应 reference;不要尝试同义底层命令绕过。 +7. 导出或媒体失败:保留 `jobId/resourceId/nodeId` 后停止;禁止 `curl`、安装依赖、Python 文档库或手写 HTTP 兜底。 -**触发**:覆盖写/追加内容/改文档正文。 +## 跨产品边界 -1. **执行(必须)**:覆盖先执行 `dws doc update --node --mode overwrite --content-file --dry-run --format json` 预览,用户确认后改用 `--yes` 实际覆盖;追加执行 `dws doc update --node --mode append --content-file --format json`。 -2. **验证(必须)**:写后 `dws doc read --node --format json` 抽取受影响段落核对。 - -**禁止**:不加 `--yes` 反复重试覆盖、跳过 `--dry-run` 直接覆盖未确认的长文档。 - -### SOP-4 导出 / 下载(export-doc) - -**触发**:导出文档/下载文档/转 PDF·Markdown。 - -1. **判类型(必须)**:先 `dws drive info --node --format json`;`extension=adoc` → `dws doc export --node --export-format --output --format json`;普通文件 → 切 `dingtalk-drive` 用 `dws drive download --node --output --format json`。 - -**禁止**:不分类型一律走 `doc export`(普通文件会失败)、跳过 `drive info` 判断。 - -### SOP-5 块级编辑(block-edit) - -**触发**:插引用块/代码块/表格/分栏/图片/附件,或删除某块。 - -1. **先列块(必须)**:`dws doc block list --node --format json`,当前响应的可操作块 ID 位于 `blocks[].element.id`(部分版本可能回显为 `blockId`);必须从目标内容对应项读取,不得编造。空文档的占位空段落可能不能作为 `--ref-block`。 -2. **按动作执行(必须)**: - - 插入:默认追加用 `dws doc block insert --node --text "<内容>" --format json`;只有明确要求相对位置时才加 `--ref-block <非空参照块ID> --where before|after`,容器内插入使用 `--parent-block <父块ID> --index <位置>`。插入命令**不接受** `--block-id`。 - - 更新:`dws doc block update --node --block-id <目标blockId> --text "<新内容>" --format json`。 - - 删除:用户确认后执行 `dws doc block delete --node --block-id <目标blockId> --yes --format json`。 -3. **验证(必须)**:再次执行 `dws doc block list --node --format json` 核对插入、更新或删除结果。 -4. **复杂块(必须)**:插入引用/代码/表格/分栏/附件/图片前,**必须**先读 [doc.md](references/doc.md) 对应小节,**禁止**只停在"准备查看 help"——说"我将插入..."后必须立即执行命令。 - -**禁止**:编造 blockId、未确认就删除、把完整 `--help` 输出当成最终结果答复用户。 -### SOP-6 导入本地文件为在线文档(import-file) - -**触发**:导入 Word / Excel / Markdown / 本地文件为在线文档。 - -1. **判类型(必须)**:确认用户意图是“导入为在线文档”,不是“上传到钉盘”。仅上传存储时切 `dingtalk-drive`。 -2. **执行(必须)**:`dws doc import --file --folder --name "<标题>" --format json`;复杂参数和限制见 [doc-import.md](references/doc/doc-import.md)。 -3. **验证(必须)**:拿到返回 `nodeId` 后执行 `dws doc info --node --format json`,必要时 `dws doc read --node --format json` 抽样核对内容。 - -**禁止**:把上传文件到钉盘误当成 doc import;不知道目标文件夹 nodeId 时先切 `dingtalk-drive`/`dingtalk-wiki` 查询。 - -## 多步文档短路径 - -- 在目标文件夹创建文字文档:`dws doc create --name "<标题>" --folder --content-file --format json`。拿到 `nodeId` 后立即回读。 -- 块级编辑固定顺序:`doc block list --node ` → 插入用 `--ref-block`/`--parent-block`,更新或删除用 `--block-id` → `doc block list` 验证。删除块必须已有用户明确删除意图或二次确认。 -- 插入引用块、代码块、表格、分栏、附件、图片时,优先读 [doc.md](references/doc.md) 对应小节,不要只停在"准备查看 help"。说出"我将插入..."后必须立即执行对应 terminal 调用。 -- 用户要求多个子文档/附件/块操作时,按 checklist 串行完成;最后一条 assistant 消息不能停在"接下来我要...",必须有实际工具调用或明确失败原因。 -- 用户说“读取并下载/导出”时,先 `drive info --node ... --format json` 按 - `extension` 判断类型:`adoc` 用 `doc export`,普通文件切到 - `dingtalk-drive` 用 `drive download`。 -- 所有结构化 dws 命令带 `--format json`。仅参数不确定时查 `--help`,不要把完整 help 当成最终结果。 - -## 危险操作 - -`block delete` 不可逆,必须确认再加 `--yes`。 - -## 跨产品协作 - -- 文件存储 / 上传下载 → 切到 `dingtalk-drive` -- 知识库空间管理 → 切到 `dingtalk-wiki` -- 数据表 → 切到 `dingtalk-aitable` -- 原生 `.md` 文件读取、创建、全量覆盖或局部替换 → 切到 `dingtalk-misc` 的 `references/markdown.md` -- 长篇报告生成(多源采集 + 写文档)→ 此 skill 提供 `doc_create_and_write.py` 脚本 -## 局部意图与短流程 - -- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。 +- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive` +- 知识库空间、节点层级和成员管理 → `dingtalk-wiki` +- 原生 `.md` 文件读取和编辑 → `dingtalk-misc` +- `axls` / `able` → 对应电子表格或多维表 Skill +- 持续监听文档事件 → `dingtalk-misc` diff --git a/skills/multi/dingtalk-doc/references/04-document.md b/skills/multi/dingtalk-doc/references/04-document.md index 706567a9..c502b794 100644 --- a/skills/multi/dingtalk-doc/references/04-document.md +++ b/skills/multi/dingtalk-doc/references/04-document.md @@ -1,70 +1,57 @@ -# 文档知识 +# 文档组合任务 -> lite recipe 见 [SKILL.md 速查表](../SKILL.md)。 +本页只描述跨步骤组合任务。单一创建、读取、更新、导出或媒体操作直接使用根 Skill Golden Route,不要加载本页。 -| Recipe | 行动指南(固定路线) | -|--------|-------------------| -| import-file | 1. **直接执行** `dws doc import --file <本地文件路径> --format json`(一条命令完成上传+转换+创建)
2. 从返回中提取 `documentUrl` 并告知用户
3. **禁止先 Read 文件内容再 `doc create` + `doc update`**——`doc import` 是服务端格式转换,客户端无需解析文件内容
4. 可选参数:`--folder <文件夹ID>` 指定目标文件夹、`--workspace <知识库ID>` 指定目标知识库、`--name "文档名"` 自定义名称
5. 格式映射:docx/doc→文档, xlsx/xls→表格, xmind/mark→脑图, md/txt→文档
6. 超时或中断时 CLI 返回 `taskId`,用 `dws doc import get --task-id ` 手动查询
详见 [doc-import.md](./doc/doc-import.md) | -| write-doc | 0. 阅读 [doc-create-workflow.md](./doc/style/doc-create-workflow.md) 的 §前置必读 + §关键词速查表,锁定文档类型和起稿路径:**决策型/含对比的知识沉淀型/用户要求美观 → JSONML 起稿**(`.json`);执行型/说明型 → Markdown 起稿(`.md`)
1. 按选定路径执行 doc-create-workflow.md(JSONML 路径有骨架范例可直接复制修改)
2. `doc create --content-file /tmp/.json --content-format jsonml`(或 `.md` + `--content-format markdown`)
3. 大内容默认依赖 DWS 自动分片;只有 `CONTENT_TRUNCATED`、部分写入失败或回读发现缺失时,才按 workflow 的恢复流程手工补片
4. **回读校验(必须)**:所有写入完成后,执行 `doc read --node `,校验关键标题/段落/表格是否完整写入 | -| search-docs-and-share | 1. `dws drive search --query "<关键词>" --format json` → 取候选 `nodeId` + 标题建索引(不读全文)
2. 对追问选中的候选执行 `dws drive info --node --format json`
3. 仅 `extension=adoc` 使用 `dws doc read --node --format json`(最多 2 篇);`md` / `axls` / `able` / 普通文件分别切到 markdown / sheet / aitable / drive,禁止固定执行 `doc read` | -| create-knowledge-base | 1. 创建知识库空间取 `WS_ID`
2. `wiki node create --workspace --name "<文档名>"` → 取 `nodeId`
3. `wiki node list --workspace ` 确认 | -| migrate-doc | 1. `doc read --node <源nodeId>` → 取正文并写入临时文件 `.md`
2. `doc create --name "<文档名>" --folder --content-file .md` → 取新 `nodeId`(`--folder` 只传文档文件夹 nodeId / alidocs 文件夹 URL,不传数字 dentryId;正文 <200KB 单步到位)
2a. 若正文 >200KB:**必须先向用户提示截断风险**(详见下方「分块 append 截断风险提示」),用户确认后再执行:`doc create --name "<文档名>" --folder ` → `nodeId` → 按段落切片 → 每片 `doc update --node --content-file --mode append`
3. **回读校验**:`doc read --node ` 校验内容完整性 | -| update-doc-section | 1. `dws drive search --query "<关键词>" --format json` → 取 `nodeId`
2. `dws drive info --node --format json`,仅 `extension=adoc` 继续;其他类型切到对应 skill/reference
3. **形态选择(按 [doc-update-workflow.md §1.3](./doc/style/doc-update-workflow.md) 优先级)**:目标段落含 callout / 分栏 / 颜色 / @人 / 附件 / 嵌套结构 → 走 `jsonml-node-edit`;纯文本替换且确认无富结构 → 继续本 recipe
4. `dws doc read --node --format json` 定位目标章节
5. `dws doc update --node --content "<替换内容>" --mode overwrite --yes --format json`
6. **回读校验**:`dws doc read --node --format json` 确认 overwrite 未被降级为 append、内容完整无截断
**overwrite 须用户确认**;完整改写流程见 [doc-update-workflow.md](./doc/style/doc-update-workflow.md) | -| rewrite-doc | 1. 阅读并执行 [doc-update-workflow.md](./doc/style/doc-update-workflow.md):先看 §1.3 编辑形态优先级(**JSONML 首选**),再按 §3 速查表选路径,跳 §4 对应小节执行
2. 单块改写 / 含富结构 → §4.4 路径 B;多处保真改写或改 root → §4.4 路径 A;纯文本骨架重写 → §4.5 markdown
3. 整篇 overwrite 前必须按 workflow §4.5 向用户提示风险并等待确认
4. **回读校验(必须)**:按 workflow §6 的校验要点逐项核查;@人、附件、图片等保真要素必须原样保留
**适用场景**:用户提供已有 nodeId/链接,需要改写、润色、章节补充、段落形态转换、整篇重写 | -| doc-to-message | 1. `doc read --node ` → 取正文(大文档只摘要+链接)
2. `aisearch person --keyword "<姓名>" --dimension name` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取 `openConversationId`
3. `chat message send --open-dingtalk-id --text "<内容>"`(推荐)或 `--group --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | -| lossless-doc-edit | 1. `doc read --node --content-format jsonml --output /tmp/doc.json` → 获取完整 JSONML 结构(输出含 `revision`,并发敏感时记下来;默认改写不需要)
2. 解析 JSON 文件,修改 `jsonml` 数组中的目标节点(节点结构参见 [doc-jsonml-schema.md](./doc/format/doc-jsonml-schema.md))
3. 将修改后的内容写回临时文件 `/tmp/doc_modified.json`,格式为 `{"jsonml": [...]}`
4. `doc update --node --content-file /tmp/doc_modified.json --content-format jsonml --mode overwrite`(默认不做并发检查;担心多 agent 同时改时加 `--revision <第 1 步拿到的 N>` 触发并发检查,版本不一致返回 `VersionConflict` 时回到第 1 步重读重写)
5. **回读校验**:`doc read --node --content-format jsonml` 确认写入成功
**适用场景**:保留样式、精准插入特定节点类型、改属性不动文本;普通文本编辑仍优先用 markdown 模式。完整 JSONML 改写流程见 [doc-update-workflow.md §4.4](./doc/style/doc-update-workflow.md) | -| jsonml-node-edit | 1. `doc block list --node --content-format jsonml` → 获取 JSONML 节点列表(含 uuid)
2. 根据 uuid 定位目标节点
3. `doc block list --node --content-format jsonml --block-id ` → 读取完整子树
4. 修改 JSONML 节点内容(节点结构参见 [doc-jsonml-schema.md](./doc/format/doc-jsonml-schema.md),可复制范例见 [doc-jsonml-cookbook.md](./doc/format/doc-jsonml-cookbook.md))
5. `doc block update --node --block-id --content-format jsonml --element '<修改后的 JSONML>'` → 写回
**适用场景**:只改一个 block 的结构/样式,无需全文回写;写入端默认 normalize(自动补 uuid、裸字符串包成 canonical 文本),可用 `--no-fix-jsonml` 关闭全部修复;`--fix-jsonml` 额外启用 JSON 语法修复(推荐 agent 调用) | +## 创建成稿 -## template-based-generation +1. 把最终正文写到工作目录内的 `body.md` 或 `body.json`。 +2. 执行一次 `dws doc +create --name "<标题>" --content @body.md --format json`。 +3. 使用返回的 `verified/nodeId` 判断完成;只有用户明确要求额外结构验收时再做局部 `+fetch`。 -当用户提供已有 alidocs 文档并要求“按模板生成、复刻、生成同形态的新版本”时,必须走保形复制链路: +复杂排版才按需读取 style/JSONML Reference;执行入口仍使用 `+create`。禁止调用已删除的创建脚本。 -1. `dws drive info --node <源文档ID或URL> --format json`,确认源节点存在并记录类型。 -2. `dws drive copy --node <源文档ID或URL> [--folder <目标文件夹>] [--workspace <目标知识库>] --format json`,从返回中提取副本 `nodeId`。 -3. 如需改名,只对副本执行 `dws drive rename --node <副本nodeId> --name "<新名称>" --format json`。 -4. `dws doc block list --node <副本nodeId> --content-format jsonml` 定位需要替换的块;仅对副本执行 `dws doc block update`。涉及多个块或富结构时,按 [doc-update-workflow.md](./doc/style/doc-update-workflow.md) 的 JSONML 路径操作。 -5. 再次读取副本的 `drive info` 和目标块,确认名称、内容与结构;禁止修改源文档。 - -**禁止**用 `doc read` → `doc create` 重建模板。Markdown 是有损投影,会丢失行高、单元格背景色、字号和部分富结构。 - ---- - -## 分块 append 截断风险提示 - -### 触发条件 - -当内容总大小 **超过 200KB**,需要拆分为多片通过 `doc update --mode append` 分块写入时,**必须在执行前向用户发出截断风险提示**,等待用户确认后再继续。 - -### 提示话术(参考模板) - -> 注意: 内容较长(约 {size}),需要分 {n} 片写入。分块 append 存在以下风险: -> - 部分片段可能写入失败但返回 success,导致文档**内容截断或缺失** -> - 片段之间的表格、代码块等跨块元素可能**被截断破坏** -> - 写入顺序异常可能导致**段落错乱** -> -> 建议:写入完成后我会回读校验文档完整性。是否继续? - -### 执行规范 - -1. **提示时机**:在执行第一片 append **之前**提示,而非写入过程中 -2. **分片原则**:按段落/标题边界切分,**禁止**在表格、代码块、列表内部截断 -3. **逐片校验**(推荐):每写入一片后记录已写入的最后一个标题/段落标记,供最终回读时比对 -4. **最终回读**(必须):所有片段写入完成后,执行 `doc read --node ` 回读全文,逐片比对关键标记是否完整(详见下方「doc update 回读校验规范」) -5. **失败处理**:若回读发现缺失片段,向用户报告具体缺失位置,建议针对缺失部分单独重试 append - ---- - -## doc update 回读校验规范 - -**所有 `doc update`(含 overwrite 和 append)执行后都必须回读校验**——返回 `success=true` 不等于内容真的写入完整。 - -完整规范(静默失败场景、校验流程、异常处理路径、"先清空再重建"命令样板)见 [doc-update-workflow.md §6「回读验收」](./doc/style/doc-update-workflow.md)。 - -最小流程: +## 导入文件 ```bash -dws doc update --node --content-file /tmp/new-content.md --mode overwrite -dws doc read --node # 校验关键标题、段落首句、表格、@人、附件 +dws doc +import --file ./report.docx --folder --format json ``` -**禁止**在未回读的情况下向用户报告「已完成」。 +导入是服务端格式转换。禁止先读文件内容再走 `create + update`。单纯保存普通文件切换到 `dingtalk-drive` 上传。 + +## 查找并读取 + +```bash +dws doc +fetch --query "<唯一标题>" --format json +``` + +`+fetch --query` 会跨页解析唯一在线文字文档。零命中、多候选或类型不是 `adoc` 时停止并按返回候选消歧;不要继续调用 drive/wiki/aisearch 做无界穷举。 + +## 更新章节 + +1. `+fetch --node --scope section|keyword` 读取最小必要上下文。 +2. 普通编辑用 `+update`;重要覆盖用 `+checkpoint-update`。 +3. 使用 shortcut 的验证结果;`partial_success/unknown` 时只恢复缺失步骤。 + +## 从模板创建 + +1. `+template-search --query <名称>`。 +2. 唯一命中才取得 `templateId`;多候选要求用户选择。 +3. `+create-from-template --template-id ` 只执行一次。 + +模板保形复制已有文档是另一种任务:使用 drive copy 复制源文档,再只修改副本。禁止 `doc read → doc create` 重建富格式模板。 + +## 导出并归档 + +1. 在线文字文档使用 `+export` 导出到工作目录。 +2. 检查 `localPath/sizeBytes`。 +3. 用户要求归档到钉盘时,再用 `dingtalk-drive` 上传该文件。 + +导出失败不得安装本地转换依赖或直接下载临时 URL。 + +## 文档转消息/待办 + +1. 使用局部 `+fetch` 提取必要内容。 +2. 在目标产品中解析真实用户/群/任务标识。 +3. 根据目标产品 Runtime gate 确认后写入。 + +跨产品步骤必须复用稳定 ID;失败后不能重放已经成功的文档写入步骤。 diff --git a/skills/multi/dingtalk-doc/references/contracts.md b/skills/multi/dingtalk-doc/references/contracts.md new file mode 100644 index 00000000..a75ff6d1 --- /dev/null +++ b/skills/multi/dingtalk-doc/references/contracts.md @@ -0,0 +1,38 @@ +# Doc Runtime Contracts + +本文只定义 Agent 需要稳定消费的文档运行时语义。字段以 leaf Schema 和实际 JSON 返回为准;不要从终端展示文本反推状态。 + +## 目标 + +文档目标至少保留 `nodeId`、资源类型、canonical URL(服务返回时)和容器信息。名称或标题不是稳定身份。按自然标题解析出现零命中、多候选或分页不完整时,写操作必须停止。 + +## 写操作状态 + +| status | 含义 | 后续动作 | +|---|---|---| +| `success` | 所有计划步骤完成,且需要验证的内容已经回读 | 可以向用户报告完成 | +| `partial_success` | 已发生部分副作用,后续步骤失败 | 检查 `steps` 与 `compensation`,不得重放成功步骤 | +| `unknown` | 请求已发出但无法确定服务端是否提交 | 先回读目标;创建和追加禁止自动重试 | +| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after`,最多有界重试一次 | +| `failed` | 已确认没有完成目标动作 | 根据 `retryable` 和 `suggestedActions` 决定是否重试 | + +`ok=true` 或进程退出码为零不能替代 `status/verified`。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。 + +## 分页与完整性 + +列表和搜索结果需要区分: + +- `complete=true`:已证明覆盖请求范围; +- `hasMore=true`:仍有后续页,必须保留有效 continuation; +- `truncated=true`:因 maxPages/maxItems/timeout 等边界停止; +- `failures[]`:已返回部分数据但某一页或后处理失败。 + +只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。 + +## 错误 + +结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 和 suggested actions。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。 + +## 安全落盘 + +导出、下载和媒体预览只使用工作目录内相对路径。完成条件包括临时文件写入成功、内容校验、文件关闭和原子发布;失败时不得留下看似最终产物的半成品。默认 no-clobber,覆盖必须由用户显式选择。 diff --git a/skills/multi/dingtalk-doc/references/doc.md b/skills/multi/dingtalk-doc/references/doc.md index bf43eb32..76570e83 100644 --- a/skills/multi/dingtalk-doc/references/doc.md +++ b/skills/multi/dingtalk-doc/references/doc.md @@ -1,510 +1,75 @@ -# 文档 (doc) 命令参考 +# dingtalk-doc 低频能力索引 -> **渐进式文档**:本文件为路由层(命令索引 + 场景索引 + 意图判断 + 工作流),各命令的详细参数、示例和踩坑说明在 [doc/](./doc/) 目录下按需加载。 +本页只在根 Skill 的 Golden Route 和精确任务 Reference 都无法选路时加载。它不是创建、读取或更新任务的前置必读,也不要求预加载样式、JSONML 或完整产品帮助。 -## 文档地址 (URI) +## 高频入口 -| 资源 | URI 格式 | -|------|----------| -| 文档节点 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | -| edit / preview 链接 | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | +| 意图 | 推荐命令 | 精确 Reference | +|---|---|---| +| 搜索在线文字文档 | `dws doc +search --query <关键词>` | [doc-info.md](doc/doc-info.md) | +| 读取正文或局部内容 | `dws doc +fetch --node ` | [doc-read.md](doc/doc-read.md) | +| 创建并写入 | `dws doc +create` | [doc-create.md](doc/doc-create.md) | +| 追加、覆盖、block 编辑 | `dws doc +update` | [doc-update.md](doc/doc-update.md) | +| 重要更新与恢复点 | `dws doc +checkpoint-update` | [doc-update.md](doc/doc-update.md) | +| 导出本地文件 | `dws doc +export` | [doc-export.md](doc/doc-export.md) | +| 导入为在线对象 | `dws doc +import` | [doc-import.md](doc/doc-import.md) | +| 评论聚合与操作 | `dws doc +review/+comment-*` | [doc-comment.md](doc/doc-comment.md) | +| 媒体插入、列表、下载 | `dws doc +media-*` | [doc-media.md](doc/doc-media.md) | -> **操作后请返回文档 URI**:每次执行 create / read / update 等操作后,从返回数据中提取 `docUrl` 直接返回;缺失时用 `doc info --node ` 补查。 +命令已选定但参数不确定时读取精确 leaf Schema;只有 Cobra flag 与 Schema 冲突时读取精确 leaf Help。不要加载 `dws doc --help` 或完整 Catalog 代替选路。 -## 前置条件 — 执行操作前必读 +## 模板 -**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下子文件:** - -1. **解析 URL / 定位文档**(几乎所有命令都需要先拿 nodeId) - → 必读 [`doc/doc-info.md`](doc/doc-info.md)(URL/dentryKey 提取规则、ID 边界、extension 路由、**获取 nodeId 三种方式 A/B/C**) - -2. **创建或编辑文档内容**(`doc create` / `doc update` / `doc block insert|update`) - → 必读 [`doc/style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md)(**形态优先级硬规则:JSONML > element JSON > markdown**;markdown overwrite 会丢富结构) - - 从零创建时加读 [`doc/style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) - - **任何 `doc create` 都必须先读 [`doc/style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) §2.0 类型决策表 + §1 硬规则**(决定骨架 + 全局约束,不读就不知道用哪种骨架) - - 涉及 callout / 分栏 / 富 block 精修时再加读 style-guideline §4-§7 + [`doc/format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) - -**未读以上文件就改写已有文档会导致富结构丢失、参数错误或样式不达标。其他命令(阅读 / 评论 / 权限 / 附件 / 下载导出 / 文件操作)按需查下方 §命令索引表跳转对应子文件加载,不必提前加载。** - -## 查询命令帮助 - -当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。 +只有名称时先只读搜索: ```bash -# 查看 doc 下所有子命令 -dws doc --help - -# 查看具体命令的完整参数说明 -dws doc read --help -dws doc create --help -dws doc block insert --help - -# 查看子命令组下的所有命令 -dws doc block --help -dws doc media --help +dws doc +template-search --query "周报" --source PUBLIC --format json ``` -规则: +- `selection.status=resolved`:取唯一候选的 `templateId`。 +- `selection.status=not_found`:报告零命中后停止。 +- `selection.status=selection_required`:展示候选并要求用户选择,禁止默认第一项。 -- 参数名不确定时 → 先 `--help`,再调用 -- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称 -- 不确定某个功能是否存在时 → `dws doc --help` 查看命令列表 - -## 命令索引表 - -> 命令名 → 单文件,按需加载子文档。复杂任务请优先看下方 §场景索引。 - -### 阅读 / 元信息 - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `doc info` | 文档元信息(含 contentType / extension) | `--node` | [`doc/doc-info.md`](./doc/doc-info.md) | -| `doc read` | 读取正文(markdown 或 jsonml) | `--node` | [`doc/doc-read.md`](./doc/doc-read.md) | - -### 创建 / 写入 / 块编辑 - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `doc create` | 创建文字文档(adoc) | `--name` | [`doc/doc-create.md`](./doc/doc-create.md) | -| `doc update` | 整篇 / 段落级更新(markdown / jsonml) | `--node` `--mode` | [`doc/doc-update.md`](./doc/doc-update.md) | -| `doc block list/insert/update/delete` | 块级精细编辑(含 JSONML 节点操作) | `--node` (+ `--block-id`) | [`doc/doc-block.md`](./doc/doc-block.md) | - -### 附件 / 评论 / 导出 - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `doc media insert/download` | 附件 / 图片插入与下载 | `--node` `--file` 或 `--resource-id` | [`doc/doc-media.md`](./doc/doc-media.md) | -| `doc comment list/create/reply/update/delete/create-inline` | 文档评论与划词评论 | `--node` (+ ...) | [`doc/doc-comment.md`](./doc/doc-comment.md) | -| `doc export` / `doc export get` | 在线文档导出 docx/markdown/pdf;`export get` 输出已对齐统一 TaskResult 格式 | `--node` `--output` | [`doc/doc-export.md`](./doc/doc-export.md) | - -### 导入 - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `doc import` / `doc import get` | 本地文件导入为在线文档(docx→文档 / xlsx→表格 / xmind→脑图 / md→文档);`import get` 输出已对齐统一 TaskResult 格式 | `--file` | [`doc/doc-import.md`](./doc/doc-import.md) | - -### 版本管理 - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `doc version save` | 手动保存文档版本快照 | `--node` | 本节 | -| `doc version list` | 查看文档历史版本列表 | `--node` | 本节 | -| `doc version revert` | 回滚文档到指定版本 | `--node` `--version` `--yes` | 本节 | - -### 模板管理 - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `doc template list` | 获取文档模板列表 | 无 | 本节 | -| `doc template search` | 搜索文档模板 | `--query` | 本节 | -| `doc template apply` | 应用文档模板创建新文档 | `--template-id` | 本节 | - -#### 手动保存文档版本 - -``` -Usage: - dws doc version save [flags] -Example: - dws doc version save --node -Flags: - --node string 文档 ID 或 URL (必填) -``` - -> 仅支持 adoc 类型文档。保存后会创建一个 USER_SAVE 类型的版本记录。 - -#### 查看文档历史版本列表 - -``` -Usage: - dws doc version list [flags] -Example: - dws doc version list --node - dws doc version list --node --limit 10 -Flags: - --node string 文档 ID 或 URL (必填) - --limit int 返回版本数量上限 (可选) - --cursor string 分页游标 (可选) -``` - -#### 回滚文档到指定版本 - -> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 - -``` -Usage: - dws doc version revert [flags] -Example: - dws doc version revert --node --version 3 --yes -Flags: - --node string 文档 ID 或 URL (必填) - --version int 目标版本号 (必填,从 version list 获取) - --yes 跳过确认提示 (非交互终端必须传) -``` - -#### 获取文档模板列表 - -``` -Usage: - dws doc template list [flags] -Example: - dws doc template list - dws doc template list --source MY - dws doc template list --source PUBLIC - dws doc template list --limit 20 -Flags: - --source string 模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY - --limit int 返回数量上限 (可选) - --cursor string 分页游标 (可选) -``` - -#### 搜索文档模板 - -``` -Usage: - dws doc template search [flags] -Example: - dws doc template search --query "周报" - dws doc template search --query "会议纪要" --limit 10 - dws doc template search --query "项目" --source PUBLIC -Flags: - --query string 搜索关键词 (必填) - --source string 模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY - --limit int 返回数量上限 (可选) - --cursor string 分页游标 (可选) -``` - -#### 应用文档模板 - -``` -Usage: - dws doc template apply [flags] -Example: - dws doc template apply --template-id TPL_ID --name "我的周报" - dws doc template apply --template-id TPL_ID --name "项目方案" --folder FOLDER_ID -Flags: - --template-id string 模板 ID (必填,从 template list/search 获取) - --name string 新文档名称 (可选) - --folder string 目标文件夹 ID (可选) - --workspace string 知识库 ID (可选) -``` - -### 文件操作(已迁移,以下命令 deprecated) - -> **迁移提示**:文件管理命令已按产品领域架构重新归属。**Agent 必须使用右侧推荐命令,禁止使用左侧旧命令**(旧命令仅供人类用户过渡参考,可能已不可用)。 - -| 旧命令 | 推荐命令 | 详见 | -|--------|----------|------| -| `doc upload` | `dws drive upload` | [`drive.md`](../../dingtalk-drive/references/drive.md) | -| `doc download` | `dws drive download` | [`drive.md`](../../dingtalk-drive/references/drive.md) | -| `doc copy` | `dws drive copy` | [`drive.md`](../../dingtalk-drive/references/drive.md) | -| `doc move` | `dws drive move` | [`drive.md`](../../dingtalk-drive/references/drive.md) | -| `doc rename` | `dws drive rename` | [`drive.md`](../../dingtalk-drive/references/drive.md) | -| `doc delete` | `dws drive delete` | [`drive.md`](../../dingtalk-drive/references/drive.md) | -| `doc folder create` | `dws drive folder create` | [`drive.md`](../../dingtalk-drive/references/drive.md) | -| `doc file create` | `dws wiki node create --type ` | [`wiki.md`](../../dingtalk-wiki/references/wiki.md) | -| `doc permission *` | `dws drive permission *` | [`drive.md`](../../dingtalk-drive/references/drive.md) | -| `doc list` | `dws drive list --workspace` / `dws wiki node list` | [`drive.md`](../../dingtalk-drive/references/drive.md) / [`wiki.md`](../../dingtalk-wiki/references/wiki.md) | -| `doc search` | `dws drive search` / `dws wiki node search` | [`drive.md`](../../dingtalk-drive/references/drive.md) / [`wiki.md`](../../dingtalk-wiki/references/wiki.md) | - -### 排版规范 / JSONML 参考 - -| 资源 | 用途 | 详见 | -|------|------|------| -| 创建工作流 | 标题、位置、骨架、回读校验 | [`doc/style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) | -| 改写工作流 | 编辑形态优先级、分片 append、回读验收 | [`doc/style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) | -| 排版规范 | 文档类型 / 骨架 / 元素边界 / 颜色语义 | [`doc/style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | -| JSONML 范例 | 所有节点类型的可复制命令 | [`doc/format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) | -| JSONML 节点结构 | 字段定义 + JSON Schema | [`doc/format/doc-jsonml-schema.md`](./doc/format/doc-jsonml-schema.md) | - -## 场景索引 - -> 任务驱动的入口:知道任务但不确定要读哪些命令文件时,按本表**一次性 Read 文件组**,不要逐个跳。 - -| 任务场景 | 一次性读取 | 主命令 | -|---------|-----------|--------| -| 定位 nodeId / URL 解析 | [`doc-info.md`](./doc/doc-info.md)(搜索请用 `dws drive search` / `dws wiki node search`;遍历请用 `dws drive list` / `dws wiki node list`) | `drive search` / `wiki node search` | -| 阅读已有文档 | [`doc-info.md`](./doc/doc-info.md) + [`doc-read.md`](./doc/doc-read.md) | read | -| 创建新文档 | [`doc-create.md`](./doc/doc-create.md) + [`doc-update.md`](./doc/doc-update.md)(写入管道)+ [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create | -| 创建文档且包含图片/截图/图文并茂 | [`doc-create.md`](./doc/doc-create.md) + [`doc-media.md`](./doc/doc-media.md) + [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create → media insert | -| 局部改写 / 段落替换(保真) | [`doc-read.md`](./doc/doc-read.md) + [`doc-update.md`](./doc/doc-update.md) + [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) | block update | -| 整篇 overwrite(可选并发检查) | [`doc-read.md`](./doc/doc-read.md) + [`doc-update.md`](./doc/doc-update.md) + [`format/doc-jsonml-schema.md`](./doc/format/doc-jsonml-schema.md) + [`style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) | update | -| 插入富 block(callout / 分栏 / 表格) | [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | block insert | -| 上传图片 / 附件 | [`doc-media.md`](./doc/doc-media.md) | media insert | -| 评论 / 划词评论(含 @人 / 更新 / 删除) | [`doc-comment.md`](./doc/doc-comment.md)(+ `dws aisearch person` 取 mention 用 userId) | comment create | -| 文档分享 / 节点级权限 | [`drive.md`](../../dingtalk-drive/references/drive.md)(已迁移:`dws drive permission add/update/list/remove`) | `drive permission` | -| 导出 PDF / DOCX / Markdown | [`doc-info.md`](./doc/doc-info.md) + [`doc-export.md`](./doc/doc-export.md) | export | -| 导入本地文件为在线文档 | [`doc-info.md`](./doc/doc-info.md) + [`doc-import.md`](./doc/doc-import.md) | import | -| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](../../dingtalk-drive/references/drive.md)(已迁移:`dws drive upload/download/copy/move/rename/delete`) | `drive *` | -| 版本管理(保存/列出/回滚) | 本文件 §版本管理(**仅 adoc**) | `dws doc version save/list/revert`(adoc);其他文件类型当前 CLI 不支持版本管理 | - -## 意图判断 - -用户说"找文档/搜文档/最近文档": - -- 全局搜索 → `dws drive search --query "<关键词>"`(聚合钉盘+文档空间) -- 空间内搜索 → `dws wiki node search --workspace --query "<关键词>"` -- 遍历文件夹 → `dws drive list --workspace ` 或 `dws wiki node list --workspace ` -- 最近访问/最近编辑 → `dws drive recent`(见 [`drive.md`](../../dingtalk-drive/references/drive.md)) - -用户说"看文档/读内容/文档内容": - -- 读取 → `read`(需文档 ID 或 URL) -- 元信息 → `info` - -用户说"写文档/创建文档": - -- 新建文字文档(adoc)→ `doc create` -- 追加内容 → `update --mode append` -- 覆盖替换 → `update --mode overwrite` -- 指定父目录时,只有明确的文档文件夹 `nodeId` / `alidocs` 文件夹 URL 才能放入 `--folder`;上一步若只返回了纯数字 `dentryId`、`spaceId` 或 drive `parent-id`,不要把它传给 doc 的 `--folder` - -> 严禁把「创建表格」路由到 `doc create`: -> -> - 用户说"创建表格/新建表格/建个电子表格/在线表格" → 走 [`dws sheet create`](../../dingtalk-misc/references/sheet/sheet-workbook.md#创建钉钉表格文档)(axls 在线电子表格) -> - 用户说"创建多维表格/新建 AI 表格/建 base/数据库表" → 走 [`dws aitable base create`](../../dingtalk-aitable/references/aitable.md#base-base-管理)(able 多维表格) - -用户说"建文件夹/新建目录": - -- 创建 → `dws drive folder create` - -用户说"上传文件/传文件/上传到文档/上传到知识库": - -- 上传 → `dws drive upload`(需本地文件路径) - -用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来": - -- **必须先判断目标文件类型**,再决定走 `export` 还是 `download`: - - 在线文档 (alidocs/adoc) → **`doc export`**(导出是内容层操作,仅对 adoc 有意义) - - 已有文件(PDF、图片、附件、视频等非在线文档) → **`dws drive download`** -- **任务查询**:`doc export` 内部自动轮询完成导出;仅在超时或中断后,用 `dws doc export get --job-id ` 手动查询任务状态 -- 判断方法: - 1. 如果用户明确说了"导出文档"、"导出为Word/docx" → 直接走 `doc export` - 2. 如果用户明确说了"下载PDF/图片/附件" → 直接走 `drive download` - 3. 不确定时,先用 `drive info --node ` 查询节点信息,根据返回的 `extension` 字段判断: - - `extension` 为 `adoc` → 走 `doc export` - - `extension` 为其他(txt/pdf/docx/md/xlsx 等)→ 走 `drive download` - -> **严禁将"导出文档"直接路由到 `download`**。`download` 只能下载已有文件(原样下载),`export` 是将在线文档格式转换后导出为 docx 或 markdown 或 pdf,两者完全不同。 -> -> **导出任务查询**:`doc export` 超时后可用 `dws doc export get --job-id ` 查询任务状态。 - -用户说"导入文件/导入docx/导入Word/导入Excel/导入脑图/导入xmind/导入思维导图/把文件转成文档/文件转在线文档/导入到知识库/打开本地文件": - -- **直接调用 `dws doc import --file <文件路径> --workspace `**(或 `--folder `),一条命令自动完成上传+转换+创建在线文档,**无需先读取文件内容再手动创建**。**注意:`--folder` 和 `--workspace` 至少传一个,不传会报错。导入到"我的文档"需先执行 `dws wiki space list --type myWikiSpace` 获取 workspaceId。** -- 支持格式与映射:docx/doc→文档, xlsx/xls→表格, xmind/mark→脑图, md/txt→文档 -- 文件大小限制:20MB -- 导入后会自动转换为钉钉在线文档,返回在线文档 URL -- **任务查询**:`doc import` 内部自动轮询完成转换;仅在超时或中断后,用 `dws doc import get --task-id ` 手动查询任务状态 - -> **严禁先 Read 文件再 `doc create` + `doc update`**。`doc import` 是一体化命令,服务端负责格式转换,客户端无需解析文件内容。 -> -> **导入任务查询**:`doc import` 超时后可用 `dws doc import get --task-id ` 查询任务状态。 - -用户说"复制文档/拷贝文件/复制到": - -- 复制 → `dws drive copy` - -用户说"移动文档/搬到/移到/转移文件": - -- 移动 → `dws drive move` - -用户说"重命名/rename/改名/改文档名/修改文档名称/修改文档标题/把这个文档叫做...": - -- 重命名 → `dws drive rename --node --name "新名称"` -- 只有用户明确说"正文里的标题/章节标题/段落标题/H1 标题"时,才走 `block update`。 - -用户说"删除文档/删掉这个文件/移到回收站/丢掉这篇文档": - -- 删除节点 → `dws drive delete` - -用户说"插入附件/上传附件到文档/往文档里加文件/加附件": - -- 插入附件 → `media insert`(需文档 ID 或 URL + 本地文件路径) - -用户说"插入图片/加图片/放张图/嵌入图片/往文档里插图": - -- 插入图片 → `media insert`(需文档 ID 或 URL + 本地图片文件路径) -- 注意:图片也是通过 `media insert` 作为附件块插入文档,不是通过 `block insert` - -用户说"下载附件/获取附件/取出文档里的附件": - -- 下载附件 → `media download`(需文档 ID 或 URL + 资源 ID) - -用户说"编辑块/改段落/插入标题/删除块": - -- 查看结构 → `block list` -- 插入 → `block insert` -- 修改 → `block update` -- 删除 → `block delete` - -用户说"保存版本/创建版本快照/手动保存一下版本": - -> **前置守卫**:以下 `doc version` 命令仅适用于钉钉在线文档(adoc)。其他文件类型(txt/pdf/docx/xlsx/md 等)当前 CLI 不支持版本管理。 - -- 保存版本 → `version save`(需文档 ID 或 URL) - -用户说"版本历史/版本列表/查看修改历史/历史版本": - -- 查看版本列表 → `version list`(需文档 ID 或 URL) - -用户说"回滚版本/恢复到某个版本/版本回退/回退到之前的版本": - -- 回滚到指定版本 → `version revert`(需文档 ID 或 URL + 版本号,危险操作需确认) - -用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看": - -- 新增权限 → `dws drive permission add` -- 修改权限 → `dws drive permission update` -- 查看谁有权限 → `dws drive permission list` -- 移除权限 → `dws drive permission remove` - -> **关键区分**: -> -> - "把**某篇文档**授权给某人" → `drive permission add`(节点级,包括「我的文档」下的文档都支持) -> - "把**某个知识库**整体授权给某人" → `wiki member add`(容器级,但**「我的文档」个人空间不支持**) - -> 补充:如果用户直接粘贴的是原始 `alidocs` URL,先按 [链接规范](../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程) 用 `dws drive info` probe;只有 probe 确认 `extension=adoc` 后,才继续按下列意图执行。其他类型(txt/pdf/docx/md/xlsx/file/folder 等)走 [`drive.md`](../../dingtalk-drive/references/drive.md) 或对应产品。 - -**用户直接粘贴文档 URL(无其他指令)**: - -- 默认 → `read`(读取文档内容) -- 如 URL 明显是文件夹 → `list`(列出文件夹内容) - -**用户粘贴 URL + 附加指令**: - -- "帮我看看这个文档" → `read` -- "这个文档的信息" → `info` -- "往这个文档追加内容" → `update --mode append` -- "把这个文档标题改成 X" / "这个文档改名为 X" → `dws drive rename` -- "把正文里的一级标题/章节标题改成 X" → `block update` - -关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(文件管理) - -## 核心工作流 - -> 步骤性指引。"读哪些文件"组合参见上方 §场景索引;命令详细参数参见对应子文件。 +选定后只创建一次: ```bash -# ── 工作流 1: 定位并阅读文档 ── -dws drive search --query "<关键词>" --format json # 1. 搜索定位 nodeId(或 wiki node search) -dws drive info --node --format json # 2. 必须确认 extension=adoc -dws doc read --node --format json # 3. 仅 adoc 读正文;其他 extension 切对应 skill - -# ── 工作流 2: 创建文档(含分片自动写入)── -dws drive folder create --name "项目资料" --format json # 1. (可选) 创建文件夹 -dws doc create --name "项目周报" --content-file /tmp/x.md \ - --folder --format json # 2. 创建 + 写入 -dws doc read --node --format json # 3. 回读校验(必须) - -# ── 工作流 3: 局部改写(保真,首选 JSONML)── -dws doc block list --node --content-format jsonml # 1. 取 uuid -dws doc block list --node --content-format jsonml --block-id # 2. 读子树 -dws doc block update --node --block-id \ - --content-format jsonml --element '[...]' # 3. 写回(uuid 必须 == --block-id) -dws doc read --node --content-format jsonml # 4. 回读 - -# ── 工作流 4: 整篇 overwrite(仅在新骨架重写时)── -dws doc read --node --content-format jsonml --output /tmp/doc.json # 1. 读出当前 JSONML -# 修改 /tmp/doc.json 中的 jsonml 数组 -dws doc update --node --content-file /tmp/doc.json \ - --content-format jsonml --mode overwrite # 2. 写回(默认不做并发检查) -dws doc read --node --content-format jsonml # 3. 回读 -# 担心被并发覆盖时,可加 --revision 触发并发检查(详见 doc-update.md) - -# ── 工作流 5: 上传独立文件 vs 插入附件到文档正文 ── -dws drive upload --file ./report.pdf --folder # 上传作为独立文件(存储层) -dws doc media insert --node --file ./report.pdf # 上传并作为附件块插入正文(内容层) - -# ── 工作流 6: 导入本地文件为在线文档 ── -dws doc import --file ./report.docx --workspace --format json # 一体化导入(推荐) -dws doc import --file ./notes.md --folder --format json # 指定目标文件夹 -dws doc import --file ./data.xlsx --workspace --format json # 指定目标知识库 - -# ── 工作流 7: 下载 vs 导出(先 info 判 extension)── -dws drive info --node --format json # 必须先查 extension -dws doc export --node --output ~/downloads/ # extension=adoc 走 export(内容层) -dws drive download --node --output ~/downloads/ # extension≠adoc 走 drive download(存储层) - -# ── 工作流 8: 评论 + 划词(@人 需 userId)── -dws aisearch person --keyword "张三" --dimension name --format json # 取 userId -dws doc comment create --node --content "请确认" --mention --format json -dws doc block list --node --format json # 划词需先取 blockId / paragraph.text -dws doc comment create-inline --node --block-id \ - --start 0 --end 10 --content "建议调整" --selected-text "原文" --format json -dws doc comment update --node --comment-key --content "已按最新数据修正" --format json -dws doc comment delete --node --comment-key --yes --format json # 删除不可逆,必须先确认 - -# ── 工作流 9: 权限授予(节点级,已迁移到 drive)── -dws aisearch person --keyword "张三" --dimension name --format json # 取 userId -dws drive permission add --node --user , --role EDITOR --format json -dws drive permission list --node --format json # 校验 - -# ── 工作流 10: 文件操作(已迁移到 drive)── -# 第一步:获取 nodeId -# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node -# 方式 B:按关键字找:dws drive search --query "项目周报" --format json -# 方式 C:按文件夹遍历:dws drive list --workspace --format json -# 第二步:执行 -dws drive copy --node --folder --format json -dws drive move --node --folder --format json -dws drive rename --node --name "新名称" --format json -dws drive delete --node --yes --format json +dws doc +create-from-template --template-id --name "我的周报" --format json ``` +禁止通过实际创建多个候选文档来预览模板。`+create-from-template --query` 仅保留兼容,不能作为新的 Agent Golden Route。 + +## 历史版本 + ```bash -# ── 工作流 11: 文档版本管理 ── -dws doc version save --node --format json # 1. 手动保存版本快照 -dws doc version list --node --format json # 2. 查看版本列表(提取 version 号) -dws doc version revert --node --version --yes --format json # 3. 回滚到指定版本(危险操作,需 --yes) +dws doc +version-save --node --format json +dws doc +version-list --node --limit 20 --format json +dws doc +version-revert --node --version --format json ``` -## 上下文传递表 +`+version-save/list/revert` 分别用于快照、浏览和恢复,命中后直接执行,不预读 Help。`+history-*` 仅兼容已有调用,不用于新的 Agent 选路。重要内容更新优先使用 `+checkpoint-update`,不要手工编排保存、写入和回读。回滚必须确认,以 leaf Schema 与 Runtime gate 为准。 -| 操作 | 从返回中提取 | 用于 | -|------|-------------|------| -| `drive search` / `wiki node search` | 文档 `nodeId` / URL | doc read / info / update 等所有 `--node` 入参 | +## 权限与分享 -| `drive list` / `wiki node list` | `nodes[].nodeId` | doc read / info / update / block 操作的 --node | -| `create` | `nodeId` | update / block 操作的 --node | -| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id | -| `read --content-format jsonml` | `revision` | update --content-format jsonml 的 --revision(可选,并发检查时使用) | -| `media insert` | `resourceId` | 附件已插入文档,可通过 block list 查看附件块 | -| `media download` | 附件下载链接 `downloadUrl` | 下载文档中的附件资源 | -| `block list` | attachment 块的 `resourceId` | media download 的 --resource-id | -| `comment list` | `commentList[].commentKey` | comment reply/update/delete 的 --comment-key | -| `comment create` | `commentKey` | comment reply/update/delete 的 --comment-key | -| `comment create-inline` | `commentKey` | comment reply/update/delete 的 --comment-key | -| `block list` | `blocks[].element.id` | comment create-inline 的 --block-id | -| `block list` | `blocks[].element.paragraph.text` | 计算 create-inline 的 --start / --end 偏移量 | -| `aisearch person` | `userId` | comment create/reply/create-inline 的 --mention;drive permission 的 --user | -| `version list` | `version`(版本号) | version revert 的 --version | -| `version save` | 版本信息 | 确认保存成功 | -| `import` | `documentUrl` / `documentName` / `documentType` | 导入完成后的在线文档地址和名称 | -| `import`(中断后) | `taskId` | `import get` 的 `--task-id`(查询后取结果获取文档地址) | +- 查询或聚合权限:`+inspect --include-permissions`。 +- 新增、变更、移除权限:`+access-grant/+access-change/+access-revoke`。 +- 授权后发链接:`+grant-and-share`。 +- 姓名、群聊或组织 profile 多候选时必须停止消歧。 -## 白板卡片与白板媒体资源 +## 高级原子能力 -创建文档内空白板前先确认目标文档,获得用户确认后执行: +以下能力在 shortcut 未公开所需参数时才使用原子 leaf: -```bash -dws doc whiteboard insert --node --yes --format json -``` +- 特殊 JSONML block、白板或样式字段 +- 需要原始 MCP 响应的诊断 +- shortcut 明确返回 capability unavailable 的低频操作 -返回的 `blockId` 用于 `doc block delete`,`whiteboardId` 是白板 partId,用于 -`dws whiteboard query/update`。两者不可混用。为白板 Vector/SVG 准备资源时: +进入高级通道前只读取对应 leaf Schema 和一个精确 Reference。原子命令不是 shortcut 失败后的自动兜底,不能用来绕过权限、确认、类型或路径检查。 -```bash -dws doc media upload --node --file ./icon.svg \ - --mime-type image/svg+xml --yes --format json -``` +## 本地与跨产品边界 -上传与后续白板更新必须使用同一 nodeId;仅使用稳定返回的 `resourceId` 和 -`resourceUrl`,禁止使用临时 uploadUrl 或跨文档复用资源。白板内容协议与更新 -流程见 `dingtalk-misc` 的 `references/whiteboard.md`。 +- 普通文件上传、下载、目录和文件树:`dingtalk-drive` +- 知识库空间与节点层级:`dingtalk-wiki` +- 原生 Markdown 文件:`dingtalk-misc` +- `axls` / `able`:对应电子表格或多维表 Skill -## 相关产品 - -- [wiki](../../dingtalk-wiki/references/wiki.md) — 知识库空间级管理(创建/查询/列出/搜索知识库),doc 中的文档存储在 wiki 知识库中 -- [aitable](../../dingtalk-aitable/references/aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档 -- [drive](../../dingtalk-drive/references/drive.md) — 钉盘文件存储/上传/下载,不是文档内容编辑 -- [report](../../dingtalk-misc/references/report.md) — 钉钉日志系统(日报/周报模版),不是在线文档 +导出或媒体错误必须保留稳定 ID 后停止。禁止隐式执行 `curl/wget`、`pip/brew install`、Python Office 库、本地 OCR 或手写 HTTP 来伪造 DWS 任务结果。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-block.md b/skills/multi/dingtalk-doc/references/doc/doc-block.md index 456650a5..e5167815 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-block.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-block.md @@ -1,268 +1,29 @@ -# doc block(块级精细编辑:list / insert / update / delete) +# 块级编辑 Golden Route -> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: -> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 -> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、JSONML validator 行为) -> 3. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — JSONML 范例(含 callout / 分栏 / 表格 / 标题等节点的完整命令) -> 4. [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md) — JSONML 节点结构字段定义 -> -> **同任务常配合**:[`doc-update.md`](./doc-update.md)(整篇 overwrite / 末尾追加纯文本)/ [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(JSONML 复制范例) +## 普通路径 -> **改写已有文档优先 JSONML**:保真度最高、callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 validator 兜底。详见 [`./style/doc-update-workflow.md` §1.3 编辑形态优先级](./style/doc-update-workflow.md)。 - ---- - -## doc block list(查询块元素) - -``` -Usage: - dws doc block list [flags] -Example: - dws doc block list --node - dws doc block list --node --start-index 0 --end-index 5 - dws doc block list --node --block-type heading - dws doc block list --node --content-format jsonml - dws doc block list --node --content-format jsonml --block-id -Flags: - --node string 文档 ID 或 URL (必填) - --start-index int 起始位置 (从 0 开始) - --end-index int 终止位置 (含) - --block-type string 按块类型过滤 - --content-format string 输出格式: 默认为 element,可选 jsonml(返回 JSONML 节点数组) - --block-id string 指定块 UUID(content-format=jsonml 时读取完整子树) -``` - -### content-format=jsonml 返回示例 - -每个 block 包含 `jsonml` 字段(JSON string,解析后为 JSONML 数组): - -```json -{ - "blocks": [ - { - "blockId": "mpeurp5mj5o9xz4hnj", - "blockType": "h2", - "index": 0, - "jsonml": "[\"h2\",{\"uuid\":\"mpeurp5mj5o9xz4hnj\"},...]" - } - ], - "totalCount": 5 -} -``` - ---- - -## doc block insert(插入块元素) - -``` -Usage: - dws doc block insert [flags] -Example: - dws doc block insert --node --text "这是一段文字" - dws doc block insert --node --heading "二级标题" --level 2 - dws doc block insert --node --element '{"blockType":"paragraph","paragraph":{"text":"内容"}}' - dws doc block insert --node --text "在此处之前插入" --ref-block --where before - dws doc block insert --node --content-format jsonml --element '["p",{"uuid":"..."},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新段落"]]]' - - # 插入引用块(blockquote) - dws doc block insert --node --element '{"blockType":"blockquote","blockquote":{"text":"这是一段引用内容"}}' - - # 插入分栏块(columns):2 栏,children 为每栏的内容 - dws doc block insert --node --element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏内容"}},{"blockType":"paragraph","paragraph":{"text":"右栏内容"}}]}' - - # 插入表格(table):2 行 3 列 - dws doc block insert --node --element '{"blockType":"table","table":{"rolSize":2,"colSize":3,"cells":[["姓名","部门","职位"],["张三","工程部","开发"]]}}' - - # 插入行内图片(inline image):构造一个空 paragraph,在 children 中用 image elementType 指定图片 src - dws doc block insert --node --element '{"blockType":"paragraph","paragraph":{},"children":[{"elementType":"image","properties":{"src":"https://example.com/photo.png"}}]}' - - # 插入附件块(attachment):resourceId 为资源ID,type 为 MIME 类型,viewType 可选 preview/summary - dws doc block insert --node --element '{"blockType":"attachment","attachment":{"resourceId":"12345-xxx-xxx-123-xxxxx","type":"application/pdf","name":"报告.pdf","viewType":"preview"}}' - - # 插入分割线:使用 doc update --mode append 以 Markdown 格式追加 - dws doc update --node --content "---" --mode append -Flags: - --node string 文档 ID 或 URL (必填) - --text string 快捷: 段落文本内容 - --heading string 快捷: 标题文本 - --level int 标题级别 1-6 (配合 --heading,默认 1) - --element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串 - --content-format string 输入格式: 默认为 element,可选 jsonml - --fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用 - --index int 参照位置索引 (从 0 开始) - --where string 插入方向: before / after (默认 after) - --ref-block string 参照块 ID (优先级高于 --index) -``` - -> **content-format=jsonml 完整范例**:参见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md),包含所有节点类型的正确 insert/update 命令示例。 - ---- - -## doc block update(更新块元素) - -``` -Usage: - dws doc block update [flags] -Example: - dws doc block update --node --block-id --text "新内容" - dws doc block update --node --block-id --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}' - dws doc block update --node --block-id --content-format jsonml --element '["h1",{"uuid":""},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新标题"]]]' -Flags: - --node string 文档 ID 或 URL (必填) - --block-id string 目标块 ID (必填) - --text string 快捷: 段落文本内容 - --heading string 快捷: 标题文本 - --level int 标题级别 1-6 (配合 --heading,默认 1) - --element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串 - --content-format string 输入格式: 默认为 element,可选 jsonml - --fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用 -``` - -> 使用 `--content-format jsonml` 时,`element` 中的 `uuid` **必须**等于 `--block-id`,否则报错。 - ---- - -## doc block delete(删除块元素) - -> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 - -``` -Usage: - dws doc block delete [flags] -Example: - dws doc block delete --node --block-id --yes -Flags: - --node string 文档 ID 或 URL (必填) - --block-id string 目标块 ID (必填) -``` - ---- - -## JSONML 格式的块操作(首选路径) - -使用 `--content-format jsonml` 可以直接以 JSONML 节点格式进行块操作,覆盖所有节点类型(不限于 block element 定义的有限类型)。 +先读取最小必要范围并取得稳定 block ID: ```bash -# 列出顶层 JSONML 节点 -dws doc block list --node DOC_ID --content-format jsonml - -# 读取指定 uuid 的完整子树 -dws doc block list --node DOC_ID --content-format jsonml --block-id UUID - -# 插入 JSONML 节点(同级定位) -dws doc block insert --node DOC_ID --content-format jsonml \ - --element '["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "新段落"]]]' \ - --ref-block UUID --where after - -# 插入 JSONML 节点(容器内定位) -dws doc block insert --node DOC_ID --content-format jsonml \ - --element '["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "新段落"]]]' \ - --parent-block UUID --index 2 - -# 整体替换(update 时 uuid 必须等于 --block-id) -dws doc block update --node DOC_ID --block-id UUID --content-format jsonml \ - --element '["p", {"uuid": "UUID"}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "修改后内容"]]]' - -# 删除(无需 format 区分) -dws doc block delete --node DOC_ID --block-id UUID +dws doc +fetch --node --detail with-ids --scope section --start-block-id --format json ``` -> 关于校验:CLI 不做结构修复,裸字符串、缺 uuid 等错误会被 validator 直接抦下。如需同时启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),用 `--fix-jsonml`。文本结构定义见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)。 - -## 关键说明 - -- **块类型**:paragraph、heading、blockquote、callout、columns、orderedList、unorderedList、table、sheet、attachment、slot。 -- **快捷 vs --element**:`block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型(table、callout、columns 等)使用 `--element` JSON 或 `--content-format jsonml`。 -- **简单内容追加**:建议用 [`./doc-update.md`](./doc-update.md) `--mode append`,不必走 block insert。 -- **JSONML validator**(写入端默认行为): - - 裸字符串、缺 uuid 等结构错误会被 validator 抦下并返回带 path 的错误(如 `$[2][2]: paragraph child must be span wrapper, got raw string.`)。 - - `--fix-jsonml` 开启 JSON 语法修复,推荐 agent 调用。 -- **图片插入**:插入图片走 [`./doc-media.md`](./doc-media.md) `media insert`(作为附件块),不走 block insert。 -- **分割线**:用 [`./doc-update.md`](./doc-update.md) `--content "---" --mode append`,不走 block insert。 - -## 上下文传递 - -| 从返回中提取 | 用于 | -|-------------|------| -| element 模式的 `blocks[].element.id`;JSONML 模式的 `blocks[].blockId` | `block insert` 的 `--ref-block`、`block update/delete` 的 `--block-id`;空文档占位空段落不要作为 `--ref-block` | -| `blocks[].element.id` | [`./doc-comment.md`](./doc-comment.md) `comment create-inline` 的 `--block-id` | -| `blocks[].element.paragraph.text` | 计算 [`./doc-comment.md`](./doc-comment.md) `comment create-inline` 的 `--start` / `--end` 偏移量 | -| attachment 块的 `resourceId` | [`./doc-media.md`](./doc-media.md) `media download` 的 `--resource-id` | - -## 常用模板 +再按意图使用统一更新入口: ```bash -# ── element JSON 形态(次选,老接口)── - -# 文本段落 -dws doc block insert --node --text "这是段落文字" --content-format element - -# 标题(level 1-6) -dws doc block insert --node --heading "二级标题" --level 2 --content-format element - -# 引用块 -dws doc block insert --node --content-format element \ - --element '{"blockType":"blockquote","blockquote":{"text":"引用内容"}}' - -# 分栏(2 栏) -dws doc block insert --node --content-format element \ - --element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏"}},{"blockType":"paragraph","paragraph":{"text":"右栏"}}]}' - -# 表格(2x3) -dws doc block insert --node --content-format element \ - --element '{"blockType":"table","table":{"rolSize":2,"colSize":3,"cells":[["姓名","部门","职位"],["张三","工程部","开发"]]}}' - -# Callout(element JSON 次选;首选 JSONML container[subType=colorBlocks]) -dws doc block insert --node --content-format element \ - --element '{"blockType":"callout","callout":{"emoji":"⚠️","bgColor":"#FDE2E0","content":[{"text":"高风险操作,先备份"}]}}' - -# 在指定 block 之前插入 -dws doc block insert --node --text "在此之前" --ref-block --where before --content-format element - -# 修改某块文本 -dws doc block update --node --block-id --text "修改后" --content-format element - -# 修改标题 -dws doc block update --node --block-id --content-format element \ - --element '{"blockType":"heading","heading":{"text":"新标题","level":2}}' - -# 删除(用户确认后) -dws doc block delete --node --block-id --yes - -# ── JSONML 形态(首选)── - -# 列出 + 取 uuid -dws doc block list --node --content-format jsonml - -# 读单个 block 完整子树 -dws doc block list --node --content-format jsonml --block-id - -# 插入段落(同级定位) -dws doc block insert --node --content-format jsonml --ref-block --where after \ - --element '["p",{},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新段落"]]]' - -# 插入 callout(colorBlocks) -dws doc block insert --node --content-format jsonml --ref-block --where after \ - --element '["container",{"uuid":"co1","subType":"colorBlocks","metadata":{"bgcolor":"#FDE2E0","border":"#F5C2C7"}},["p",{"uuid":"co1p1"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"高风险操作,先备份"]]]]' - -# 插入分栏(columns / column) -dws doc block insert --node --content-format jsonml --ref-block --where after \ - --element '["container",{"uuid":"cols1","subType":"columns","metadata":{"size":"2"}},["container",{"uuid":"cols1c1","subType":"column"},["p",{"uuid":"cols1c1p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"左栏"]]]],["container",{"uuid":"cols1c2","subType":"column"},["p",{"uuid":"cols1c2p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"右栏"]]]]]' - -# 整体替换(update 的 uuid 必须等于 --block-id) -dws doc block update --node --block-id --content-format jsonml \ - --element '["p",{"uuid":""},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"修改后内容"]]]' - -# 容器内定位插入(指定 parent + index) -dws doc block insert --node --content-format jsonml --parent-block --index 2 \ - --element '["p",{},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"在容器第 3 项位置插入"]]]' +dws doc +update --node --command block_replace --block-id --content "新内容" +dws doc +update --node --command block_insert_after --after-block-id --content "补充内容" +dws doc +update --node --command block_delete --block-id ``` -## 参考 +`block-id` 必须来自真实 `+fetch --detail with-ids`、`+review` 或原子 block 列表返回。确认、写入与验证统一由 `+update` 处理,正常成功不追加整篇回读。 -- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族) -- [`./doc-update.md`](./doc-update.md)(整篇改写 / 纯文本追加) -- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、validator 行为) -- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(所有节点的可复制 JSONML 范例) -- [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构字段定义) -- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(callout 颜色 / 元素边界规范) +## 富结构专家路径 + +只有需要 shortcut 未公开的 callout、分栏、复杂表格或 JSONML element 参数时: + +1. 按需读取 [JSONML schema](format/doc-jsonml-schema.md) 或 [cookbook](format/doc-jsonml-cookbook.md),不要两者都预加载。 +2. 读取精确 `doc block` leaf Schema,确认当前 flags。 +3. 用原子 block 命令只改目标块;JSONML update 的 uuid 必须等于目标 block ID。 + +图片和附件不得手写临时 URL 或 OSS 请求,统一走 [`doc-media.md`](doc-media.md) 的 `+media-insert/+media-download`。删除与覆盖的确认以 Runtime gate 为准,示例不得预填 `--yes`。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-comment.md b/skills/multi/dingtalk-doc/references/doc/doc-comment.md index 6bdc7eab..2fc83d2c 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-comment.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-comment.md @@ -1,203 +1,29 @@ -# doc comment(文档评论:list / create / reply / update / delete / create-inline) +# 文档评论 Golden Route -> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: -> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 -> -> **同任务常配合**:`dws aisearch person`(查 `--mention` 用 userId)/ `dws chat search`(查群用 openConversationId)/ [`doc-block.md`](./doc-block.md)(划词评论必须先取 blockId 与 paragraph 文本) - ---- - -## doc comment list(查询文档评论列表) - -``` -Usage: - dws doc comment list [flags] -Example: - dws doc comment list --node - dws doc comment list --node --type inline --resolve-status unresolved - dws doc comment list --node --limit 20 --cursor -Flags: - --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --limit int 每页返回的评论数量,默认 50,最大 50 - --cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传) - --type string 按评论类型过滤: global (全文评论) / inline (划词评论) - --resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决) -``` - ---- - -## doc comment create(创建全文评论) - -``` -Usage: - dws doc comment create [flags] -Example: - dws doc comment create --node --content "这里需要修改" - dws doc comment create --node --content "请review" --mention uid1,uid2 - dws doc comment create --node --content "请群内关注" --mentioned-open-conversation-id -Flags: - --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --content string 评论的文字内容,纯文本 (必填) - --mention string 被 @ 的用户 uid 列表,逗号分隔 - --mentioned-open-conversation-id strings 被 @ 的群 openConversationId,可重复指定或逗号分隔 -``` - ---- - -## doc comment reply(回复评论) - -``` -Usage: - dws doc comment reply [flags] -Example: - dws doc comment reply --node --comment-key --content "同意" - dws doc comment reply --node --comment-key --content "比心" --emoji - dws doc comment reply --node --comment-key --content "请确认" --mention uid1,uid2 - dws doc comment reply --node --comment-key --content "请群内确认" --mentioned-open-conversation-id -Flags: - --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --content string 回复的文字内容,表情回复时填写表情名称 (必填) - --comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填) - --emoji 设为 true 时作为表情贴图回复 (默认 false) - --mention string 被 @ 的用户 uid 列表,逗号分隔 - --mentioned-open-conversation-id strings 被 @ 的群 openConversationId,可重复指定或逗号分隔;不得与 --emoji 同时使用 -``` - ---- - -## doc comment update(更新评论) - -``` -Usage: - dws doc comment update [flags] -Example: - dws doc comment update --node --comment-key --content "已按最新数据修正" - dws doc comment update --node --comment-key --content "更新后的评论内容" - dws doc comment update --node --comment-key --content "请群内关注" --mentioned-open-conversation-id -Flags: - --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --comment-key string 待更新评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create/create-inline 结果获取 (必填) - --content string 更新后的评论文字内容,纯文本 (必填) - --mention string 被 @ 的用户 uid 列表,逗号分隔 - --mentioned-open-conversation-id strings 被 @ 的群 openConversationId,可重复指定或逗号分隔 -``` - ---- - -## doc comment delete(删除评论) - -> **CAUTION:** 不可逆操作。必须先向用户确认。用户明确同意后,命令加 `--yes` 执行;不加 `--yes` 时 CLI 会在终端中提示输入 `yes/no`。 - -``` -Usage: - dws doc comment delete [flags] -Example: - dws doc comment delete --node --comment-key --yes -Flags: - --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --comment-key string 待删除评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create/create-inline 结果获取 (必填) -``` - ---- - -## doc comment create-inline(创建划词评论) - -> **能力边界:** `create_inline_comment` 后端当前未提供 `mentionedOpenConversationIds`,因此 `create-inline` 不支持 @群。不得只在正文写 `@群名` 冒充真实群 mention;如果用户要求划词评论 @群,应明确提示暂不支持。 - -``` -Usage: - dws doc comment create-inline [flags] -Example: - dws doc comment create-inline --node --block-id --start 0 --end 10 --content "这里需要修改" - dws doc comment create-inline --node --block-id --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文" - dws doc comment create-inline --node --block-id --start 0 --end 10 --content "请review" --mention uid1,uid2 -Flags: - --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --block-id string 评论标记所在的块 ID,可通过 dws doc block list 获取 (必填) - --start int 评论标记在块内文本中的起始字符偏移量,从 0 开始 (必填) - --end int 评论标记在块内文本中的结束字符偏移量,必须大于 start (必填) - --content string 评论的文字内容,纯文本 (必填) - --selected-text string 选中文本的内容,填写后评论列表中会展示「引用原文:xxx」 - --mention string 被 @ 的用户 uid 列表,逗号分隔 -``` - -## 关键说明 - -- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws aisearch person --keyword "<姓名>" --dimension name` 拿到 userId。 -- `--mentioned-open-conversation-id` 接受群 `openConversationId`,可重复传入或逗号分隔;CLI 稳定去重并拒绝空白值,不会转换成内部 `cid`。 -- 群 mention 仅支持全文评论 `create`、`update`、`reply`;`create-inline` 后端当前未提供该字段,不支持 @群。 -- 真正的用户 mention 必须传 `mentionedUserIds`,真正的群 mention 必须传 `mentionedOpenConversationIds`。只在 `content` 写 `@名称` 是普通文本,不会创建 mention 节点或通知。 -- 用户直接给 openConversationId 时直接使用。用户给群名时,使用当前 DWS profile 执行 `dws chat search --query "<群名>" --format json`。只在唯一匹配时取 `openConversationId`;多个同名候选必须展示群名称、组织等区分信息让用户选择;无匹配或查询失败时不得调用评论接口。 -- 不按所属组织在客户端过滤或提前拒绝 mention,最终权限以评论接口为准。服务端失败时保留或准确概括 `errorCode`、`errorMessage`、`logId`,不得删除 mention 参数静默重试;若要降级为普通文本必须先征得用户同意。 -- `content` 只保留正文。仅移除已成功解析成真实 mention 的明确 `@群名` token,保持原换行;普通 `@` 文本或未解析名称不得误删。 -- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` / `create-inline` 返回中提取,用于 `reply` / `update` / `delete`。 -- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。 -- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。 -- `reply --emoji` 与群 mention 冲突;CLI 会在调用服务端前报错,不会静默忽略。 -- `delete` 是不可逆操作;AI Agent 必须先让用户确认,再追加 `--yes`,避免 CLI 进入交互等待。 - -## 上下文传递 - -| 从返回中提取 | 用于 | -|-------------|------| -| `commentList[].commentKey` | `comment reply/update/delete` 的 `--comment-key` | -| `comment create` `commentKey` | `comment reply/update/delete` 的 `--comment-key` | -| `comment create-inline` `commentKey` | `comment reply/update/delete` 的 `--comment-key` | -| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.id` | `comment create-inline` 的 `--block-id` | -| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.paragraph.text` | 计算 `create-inline` 的 `--start` / `--end` 偏移量 | -| `dws aisearch person` 的 `userId` | `comment create/reply/create-inline` 的 `--mention` | -| `dws chat search` 的 `openConversationId` | `comment create/update/reply` 的 `--mentioned-open-conversation-id` | - -## 常用模板 +## Review 聚合 ```bash -# 查看文档全部评论 -dws doc comment list --node --format json - -# 仅看未解决的划词评论 -dws doc comment list --node --type inline --resolve-status unresolved --format json - -# 创建全文评论 -dws doc comment create --node --content "这里需要补充数据来源" --format json - -# 创建评论 + @人(先 aisearch person 拿 userId) -dws aisearch person --keyword "张三" --dimension name --format json -# 提取 userId 后: -dws doc comment create --node --content "请确认这部分" --mention , --format json - -# 创建评论 + @群(搜索结果唯一后取 openConversationId) -dws chat search --query "项目群" --format json -dws doc comment create --node --content "请群内关注" --mentioned-open-conversation-id --format json - -# 文字回复 -dws doc comment reply --node --comment-key --content "已修改" --format json - -# 回复 + @群 -dws doc comment reply --node --comment-key --content "请群内确认" --mentioned-open-conversation-id --format json - -# 更新评论 -dws doc comment update --node --comment-key --content "已按最新数据修正" --format json - -# 更新 + 同时 @用户和 @群 -dws doc comment update --node --comment-key --content "请大家确认" --mention --mentioned-open-conversation-id --format json - -# 删除评论(不可逆;必须用户确认后再加 --yes) -dws doc comment delete --node --comment-key --yes --format json - -# 表情回复(--content 填表情名称) -dws doc comment reply --node --comment-key --content "比心" --emoji --format json - -# 划词评论(先 block list 取 blockId + paragraph.text,计算 start/end) -dws doc block list --node --format json -# 计算偏移后: -dws doc comment create-inline --node --block-id --start 0 --end 10 --content "这里需要修改" --format json - -# 划词评论 + 引用原文 + @人 -dws doc comment create-inline --node --block-id --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention , --format json +dws doc +review --node --format json ``` -## 参考 +需要一次看到未解决评论、引用原文和 block 上下文时优先使用 `+review`。从真实返回取得 `commentKey` 和 `blockId`,禁止按数组位置或猜测 ID。 -- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族) -- [`./doc-block.md`](./doc-block.md)(取 blockId 与块内文本以计算划词偏移) -- `dws aisearch person`(取 mention 用的 userId,跨产品命令) +## 精确评论动作 + +```bash +dws doc +comment-list --node --resolve-status unresolved --limit 20 --format json +dws doc +comment-list --node --limit 20 --cursor --format json +dws doc +comment-create --node --content "这里需要补充证据" +dws doc +comment-create --node --selection "计划下周发布" --content "请确认日期" +dws doc +comment-reply --node --comment-key --content "已补充" +dws doc +comment-update --node --comment-key --content "修订后的意见" +dws doc +comment-delete --node --comment-key +``` + +- 全文与划词评论统一使用公开的 `+comment-create`:优先传唯一 `--selection`;已知真实 block 时传 `--block-id --start --end`,CLI 自动回读并校验 `selectedText`。不要选用未公开的 `+comment-create-inline`。 +- `--mention` 传单个 uid 或逗号分隔列表,例如 `--mention 550582,123456`;不要传 JSON 数组。 +- 列表用 `--limit` 控制页大小(兼容 `--page-size`),有 `nextToken` 时原样传给 `--cursor`;不能把单页当作全部评论。 +- 创建、回复、删除等写操作执行前消费 leaf Schema `confirmation`;需确认时先询问,示例不得预填 `--yes`。 +- 删除不可恢复,必须核对 node 与 commentKey。部分或未知结果不得自动重试。 + +只有 shortcut 未公开必要字段时,才读取精确原子 leaf Schema;不要加载整份评论参考或产品 Catalog 来猜参数。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-create.md b/skills/multi/dingtalk-doc/references/doc/doc-create.md index bf11a677..c5a23e3e 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-create.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-create.md @@ -1,96 +1,42 @@ -# doc create(创建文档) +# 创建在线文字文档 -> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: -> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 -> 2. [`./style/doc-create-workflow.md`](./style/doc-create-workflow.md) — 创建工作流(标题、位置、骨架、回读校验) -> 3. [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md) — 排版规范(草稿元素清单、骨架样板) -> 4. [`./doc-update.md` §内容写入管道](./doc-update.md) — 长内容自动分片、`--content-file` vs `--content` 选择 -> 5. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读 +本页只处理钉钉在线文字文档(`adoc`)创建。普通文件上传走 `dingtalk-drive`,表格和多维表分别走对应产品。 -## 创建路由前置判断(必看) - -> `dws doc create` 只能创建在线文字文档(adoc),**不要**用它承接所有「新建 xxx」请求。收到「创建/新建」类需求时,必须先按文件类型分流: -> -> - 用户说「创建表格 / 新建表格 / 建个电子表格 / 在线表格 / 销售数据表」等 → 走 [`dws sheet create`](../../../dingtalk-misc/references/sheet/sheet-workbook.md#创建钉钉表格文档)(钉钉在线电子表格 `axls`),**不要**走 `doc create` -> - 用户说「创建多维表格 / 新建 AI 表格 / 建个 base / 数据库表」等 → 走 [`dws aitable base create`](../../../dingtalk-aitable/references/aitable.md#base-base-管理)(多维表格 `able`),**不要**走 `doc create` -> - 用户说「创建文档 / 新建文档 / 写篇文档 / 会议纪要 / 周报 / 方案」等文字型内容 → 才走 `dws doc create` -> -> 一句话口诀:表格 → sheet/aitable;文档 → doc。 - -## 命令格式 - -``` -Usage: - dws doc create [flags] -Example: - dws doc create --name "项目周报" - dws doc create --name "Q1 总结" --content "# Q1 总结" --folder - dws doc create --name "知识库文档" --workspace - dws doc create --name "周报" --content-file ./weekly.md --folder - cat report.md | dws doc create --name "月报" --content - -Flags: - --name string 文档名称 (必填) - --folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID - --workspace string 目标知识库 ID - --content string 文档初始内容(短文本字面量);传 - 表示从 stdin 读取 - --content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用 - --content-format string 内容格式: 默认为 markdown,可选 jsonml - --fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用 -``` - -## 关键说明 - -- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。 -- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。 -- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。 -- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。 -- 长内容(>30000 字符)CLI 自动分片:先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append;调用方无需手动编排。 - -## 上下文传递 - -| 从返回中提取 | 用于 | -|-------------|------| -| `nodeId` | [`./doc-update.md`](./doc-update.md) / [`./doc-block.md`](./doc-block.md) / [`./doc-media.md`](./doc-media.md) 的 `--node` | -| `docUrl` | 最终交付给用户的链接;缺失时用 [`./doc-info.md`](./doc-info.md) 补查 | -| `chunksWritten` | 判断是否触发自动分片;> 1 时重点检查章节顺序 | - -## 回读验收(必读) - -CLI **不会**自动回读校验。**每次创建后**都必须执行 `doc read --node ` 校验关键标题、段落首句、表格表头是否完整。详见 [`./style/doc-create-workflow.md` «回读验收»](./style/doc-create-workflow.md)。 - -## 常用模板 +## 唯一推荐入口 ```bash -# 默认创建到「我的文档」根目录(推荐文件路径) -dws doc create --name "<文档名>" --content-file /tmp/.md --content-format markdown - -# 创建到指定文件夹 -dws doc create --name "<文档名>" --content-file /tmp/.md --folder --content-format markdown - -# 创建到知识库 -dws doc create --name "<文档名>" --content-file /tmp/.md --workspace --content-format markdown - -# 创建空文档(仅取 nodeId 后再分步写入,适合 >200KB 兜底) -dws doc create --name "<文档名>" [--folder | --workspace ] --content-format markdown - -# 短纯文本字面量(< 2KB 且无换行/表格才允许) -dws doc create --name "<文档名>" --content "短内容" --content-format markdown - -# stdin(heredoc / pipe) -cat report.md | dws doc create --name "月报" --content - --content-format markdown - -# JSONML 起稿(决策型 / 对展示效果有要求时直接用 JSONML 构造) -# 详见 doc-create-workflow.md §JSONML 起稿判定 -dws doc create --name "<文档名>" --content-file /tmp/.json --content-format jsonml - -# JSONML 创建到指定文件夹 -dws doc create --name "<文档名>" --content-file /tmp/.json --content-format jsonml --folder +dws doc +create --name "<文档名>" [--content "短文本"] [--folder | --workspace ] --format json +dws doc +create --name "<文档名>" --content @body.md --format json +dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --format json ``` -## 参考 +- 统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。 +- `@file` 禁止绝对路径和 `..` 逃逸;不要直接引用宿主临时目录。 +- `--name` 是文档名称,不等于正文中的 heading。用户明确要求“正文一级标题”时,正文仍须包含对应 H1。 +- JSONML 顶层必须是数组;仅在确实需要富结构时加载 JSONML cookbook。 -- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令) -- [`./doc-update.md`](./doc-update.md)(写入管道、长 markdown、追加段落、回读补救) -- [`./style/doc-create-workflow.md`](./style/doc-create-workflow.md)(创建流程 + 回读验收) -- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(草稿排版规范) -- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构与范例) +`+create` 负责创建、长 Markdown 分片和最终回读验证。正常成功结果至少包含: + +```json +{ + "status": "success", + "complete": true, + "data": { + "nodeId": "...", + "verified": true + } +} +``` + +## 结果处理 + +- `status=success` 且 `verified=true`:可以报告创建完成,并保留真实 `nodeId`/URL。 +- `status=partial_success`:文档或部分分片已经创建;按 `steps` 回读现状,禁止重跑整条创建。 +- `status=unknown`:服务端可能已经提交;先定位并读取文档,禁止自动重试。 +- 没有真实 `nodeId` 或写回执时,禁止声称“已创建”。 + +## 高级通道 + +只有 shortcut 未公开所需的底层参数或需要原始响应时,才读取精确 leaf Schema 后使用 `dws doc create`。不要因为熟悉旧参数就默认退回原子命令,也不要使用已删除的 Python 创建脚本。 + +复杂排版按需读取 [doc-style-guideline.md](style/doc-style-guideline.md);命令选路仍以本页的 `+create` 为准。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-export.md b/skills/multi/dingtalk-doc/references/doc/doc-export.md index 319b2aee..5f7c8e06 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-export.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-export.md @@ -1,110 +1,30 @@ -# doc export(在线文档导出为 docx/markdown/pdf) +# 导出在线文档 -> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: -> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 - -> **路由前置判断**:用户说「下载/导出」时**必须**先用 `dws drive info --node --format json` 查 `extension`: -> - `extension` 为 `adoc`(在线文档)→ **必须用 `export`**,禁止用 `download` -> - `extension` 为其他(txt/pdf/docx/md/xlsx 等)→ 用 `dws drive download`(详见 [`../drive.md`](../../../dingtalk-drive/references/drive.md)) -> -> `drive download` 只能下载**已有文件**(原样下载),`export` 是将**在线文档格式转换**后导出为 docx、markdown 或 pdf,两者完全不同。 - ---- - -## doc export(一体化命令) - -``` -Usage: - dws doc export [flags] -Example: - dws doc export --node "https://alidocs.dingtalk.com/i/nodes/xxx" --output ./exported.docx - dws doc export --node --output ~/downloads/ - dws doc export --node --export-format markdown --output ./exported.md - # 异步模式:只提交不等待,立即返回 jobId -Flags: - --node string 要导出的文档标识,支持文档 URL 或 dentryUuid (必填) - --output string 本地保存路径,文件路径或目录 (必填) - --export-format string 导出格式,支持 docx(默认)、markdown(或 md)和 pdf -``` - -CLI 内部自动完成:提交导出任务 → 渐进式退避轮询(最多约 5 分钟)→ 成功后自动下载文件。 -**只需一条命令,无需手动轮询。** - -**任务查询**:`doc export` 内部自动轮询直到完成,通常不需要手动查询;仅在超时或中断后用 `dws doc export get --job-id ` 兜底查询。 - ---- - -## doc export get(手动兜底查询任务) - -> 任务状态:PROCESSING(处理中)/ SUCCESS(导出成功,返回 downloadUrl)/ FAILED(导出失败)。 - -``` -Usage: - dws doc export get [flags] -Example: - dws doc export get --job-id -Flags: - --job-id string 导出任务 ID (必填) -``` - -仅在 `dws doc export` 超时或中断后,用于手动查询任务状态。通常不需要调用。 - -## 关键说明 - -- `export` 是一体化命令,一条命令自动完成提交→轮询→下载,**无需手动编排轮询**。CLI 内部使用渐进式退避轮询(最多约 5 分钟)。 -- `export` 超时或中断后,CLI 会输出 `jobId`,可用 `dws doc export get --job-id ` 手动查询任务状态。 -- `export` 支持钉钉在线文档(alidocs,`contentType=ALIDOC`)导出为 `docx`、`markdown` 或 `pdf`,**在线表格导出请使用其他命令**。 -- `--export-format` 支持 `docx`(默认)、`markdown`(或 `md`)和 `pdf` 三种格式。 -- `--output` 既可以是文件完整路径,也可以是目录(CLI 自动按文档名生成 `.docx`、`.md` 或 `.pdf`)。 -- `doc export get` 主参数为 `--job-id`。 - -## 上下文传递 - -| 从返回中提取 | 用于 | -|-------------|------| -| `localPath` | 用户可访问的本地文件路径(同步模式) | -| 超时/中断时返回的 `jobId` | `export get` 的 `--job-id`(查询后取 downloadUrl 获取下载地址) | - -## 常用模板 +## 唯一推荐入口 ```bash -# 一体化导出为 docx(最常用) -dws doc export --node --output ./exported.docx - -# 导出为 markdown -dws doc export --node --export-format markdown --output ./exported.md - -# 使用 md 缩写导出为 markdown -dws doc export --node --export-format md --output ./exported.md - -# 导出为 pdf -dws doc export --node --export-format pdf --output ./exported.pdf - -# 输出到目录(自动按文档名命名,docx 格式) -dws doc export --node --output ~/downloads/ - -# 输出到目录(markdown 格式,自动生成 .md 扩展名) -dws doc export --node --export-format markdown --output ~/downloads/ - -# alidocs URL 直传 -dws doc export --node "https://alidocs.dingtalk.com/i/nodes/" --output ./exported.docx - -# 兜底:超时或中断后手动查任务 -dws doc export get --job-id --format json +dws doc +export --node --export-format docx --output ./exports/ --format json +dws doc +export --node --export-format markdown --output ./document.md --format json +dws doc +export --node --export-format pdf --output ./document.pdf --format json ``` -## 输出规范 +`+export` 一次完成提交、轮询和原子下载。输出路径必须位于工作目录内,默认 no-clobber;只有用户明确允许覆盖时使用 `--overwrite`。 -导出完成后的输出必须遵循以下规则: +`--export-format` 必填;全局 `--format json` 只控制 CLI 输出,不能代替业务导出格式。禁止依赖默认 docx,也不要猜测 `--type`。 -- **仅返回本地文件路径**:导出成功后,直接告知用户文件已保存到本地的具体路径 -- **禁止上传 CDN**:导出完成后不要调用任何上传命令将文件上传到钉钉 CDN -- **禁止输出下载链接**:不得生成或展示 `down.dingtalk.com` 等 CDN 下载链接 -- 正确示例:`文档已导出到: ./文档名.docx` -- 错误示例:上传文件并展示 CDN 下载链接 +异步状态 `INIT` 与 `PROCESSING` 都表示任务仍可继续轮询;只有终态失败才停止,不能把 `INIT` 当导出失败。 -## 参考 +## 类型边界 -- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令) -- [`./doc-info.md`](./doc-info.md)(前置:判断 contentType=ALIDOC 才走 export) -- [`../drive.md`](../../../dingtalk-drive/references/drive.md)(非 ALIDOC 文件用 `dws drive download`) +- 在线文字文档(`adoc`)转为 docx/markdown/pdf:`+export`。 +- 已存在的普通文件原样下载:切换到 `dingtalk-drive`,使用 drive download。 +- 不要为了导出先把正文读到本地再重新生成文件。 + +## 失败处理 + +- 提交前权限/认证失败:原样报告并停止;不要尝试同义底层命令。 +- 已返回 `jobId` 后轮询失败或超时:保留 `jobId`,只用 `+export-get --job-id ` 恢复查询,禁止重新提交导出。 +- 下载阶段失败:保留 `jobId` 和目标相对路径,用 `+export-get --job-id --output ./目标文件` 通过同一安全下载器恢复;不要直接 `curl` 临时 URL。 +- 禁止安装 `pandoc`、`python-docx` 或其他依赖来隐式伪造导出结果。只有用户明确改成“本地生成文件”任务时,才可作为一个新的独立工作流处理。 + +只有需要显式接管异步 job 的恢复场景才使用 `+export-submit/+export-get`;正常导出不得手工编排它们。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-import.md b/skills/multi/dingtalk-doc/references/doc/doc-import.md index 9ecac64d..c2bfe44e 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-import.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-import.md @@ -1,131 +1,27 @@ -# doc import(本地文件导入为在线文档) +# 导入本地文件:`+import` Golden Route -> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: -> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 - -> **支持的文件格式**:docx, doc, xlsx, xls, md, txt, xmind, mark -> **文件大小限制**:20MB -> **不支持转换的格式**:白名单外的所有格式(html/pdf/zip/无扩展名等)不报错,CLI 自动改走文件上传链路,以原文件形式存入 `--folder`/`--workspace` 指定的文档空间/知识库位置(同样受 20MB 上限约束);JSON 结果带 `fallback=upload`、`converted=false` 标记,表示产出文件对象而非在线文档;需要"可编辑在线文档"时先把内容转为 md 再导入;目标是钉盘时直接用 `dws drive upload`。 - ---- - -## doc import(一体化命令) - -``` -Usage: - dws doc import [flags] -Example: - dws doc import --file ./report.docx --workspace - dws doc import --file ./notes.md --folder - dws doc import --file ./data.xlsx --workspace - dws doc import --file ./draft.md --workspace --name "项目周报" -Flags: - --file string 本地文件路径 (必填) - --folder string 目标文件夹 ID 或 URL (与 --workspace 必填其一) - --workspace string 目标知识库 ID 或 URL (与 --folder 必填其一) - --name string 导入后文档名称 (可选,默认取文件名不含扩展名) -``` - -> 注意:`--folder` 接受文件夹的 dentryUuid(nodeId)或 alidocs 文件夹 URL。 -> 如果通过 `dws doc info` 获取信息,应使用返回的 `nodeId` 字段, -> 而非 `folderId`(后者是父文件夹 ID)。 -> 当 URL 指向的节点 `nodeType` 不是 "folder" 时,应提示用户检查链接。 - -CLI 内部自动完成:创建导入会话 → 上传文件到 OSS → 确认导入触发转换 → 渐进式退避轮询(最多约 5 分钟)。 -**只需一条命令,无需手动轮询。** - ---- - -## doc import get(手动兜底查询任务) - -> 任务状态:processing(转换中)/ completed(导入成功,返回 documentUrl)/ failed(导入失败)。 - -``` -Usage: - dws doc import get [flags] -Example: - dws doc import get --task-id -Flags: - --task-id string 导入任务 ID (必填) -``` - -仅在 `dws doc import` 超时或中断后,用于手动查询任务状态。通常不需要调用。 - -**输出示例**(统一 TaskResult 格式): -```json -{"id":"task-abc123","type":"import","status":"SUCCESS","resultUrl":"https://alidocs.dingtalk.com/i/nodes/xxx","message":"","createTime":"2024-01-15T10:30:00Z"} -``` - -**处理中示例**: -```json -{"id":"task-abc123","type":"import","status":"PROCESSING","resultUrl":"","message":"任务处理中,请稍后查询","createTime":"2024-01-15T10:30:00Z"} -``` - -## 关键说明 - -- `import` 是一体化命令,一条命令自动完成创建会话→上传→确认→轮询,**无需手动编排**。CLI 内部使用渐进式退避轮询(最多约 5 分钟)。 -- `import` 超时或中断后,CLI 会输出 `taskId`,可用 `dws doc import get --task-id ` 手动查询任务状态。 -- 支持的文件格式:docx, doc, xlsx, xls, md, txt, xmind, mark(共 8 种)。 -- 白名单外格式(html/pdf/zip/无扩展名等)不报错:自动改走文件上传链路,以原文件形式存入目标文档空间/知识库(stderr 有改道提示,同样受 20MB 上限约束),JSON 结果带 `fallback=upload`、`converted=false` 标记——返回文件对象而非在线文档,不得报告为"已转换";要"可编辑在线文档"时先转 md 再导入,目标是钉盘时用 `dws drive upload`。 -- 文件大小限制:20MB。超过限制时 CLI 会直接报错,不会发起网络请求。 -- `--folder` 和 `--workspace` 至少传一个,不传会报错。如需导入到"我的文档"根目录,请先获取 workspaceId:`dws wiki space list --type myWikiSpace --format json`,然后传 `--workspace `。 -- `--folder` 优先级高于 `--workspace`:两者都传时以 `--folder` 为准。 - -## 上下文传递 - -| 从返回中提取 | 用于 | -|-------------|------| -| `documentUrl` | 导入完成后的在线文档地址(可直接分享给用户) | -| `documentName` | 导入后的文档名称 | -| `documentType` | 文档类型(DOC / SHEET / MIND) | -| 超时/中断时返回的 `taskId` | `import get` 的 `--task-id`(查询后取 documentUrl 获取文档地址) | - -## 输出规范 - -导入完成后的输出必须遵循以下规则: - -- **返回在线文档 URL**:导入成功后,直接告知用户文档已导入并提供在线文档链接 -- **输出格式**:`文件已导入为在线文档: ` -- 正确示例:`文件已导入为在线文档: https://alidocs.dingtalk.com/i/nodes/xxx` - -## 格式与文档类型映射 - -| 文件扩展名 | 导入后文档类型 | 说明 | -|-----------|--------------|------| -| `.docx` / `.doc` | DOC(文字文档) | Word 文档导入为在线文档 | -| `.xlsx` / `.xls` | SHEET(电子表格) | Excel 文件导入为在线表格 | -| `.md` / `.txt` | DOC(文字文档) | Markdown/纯文本导入为在线文档 | -| `.xmind` | MIND(脑图) | XMind 文件导入为在线脑图 | -| `.mark` | MIND(脑图) | Mark 格式导入为在线脑图 | - -## 常用模板 +## 唯一推荐入口 ```bash -# 导入 Word 文档(最常用) -dws doc import --file ./report.docx --workspace --format json - -# 导入 Markdown 文件 -dws doc import --file ./notes.md --workspace --format json - -# 导入到指定文件夹 -dws doc import --file ./data.xlsx --folder --format json - -# 导入到知识库根目录 -dws doc import --file ./plan.docx --workspace --format json - -# 自定义文档名称 -dws doc import --file ./draft.md --workspace --name "项目方案" --format json - -# 导入 XMind 脑图文件 -dws doc import --file ./mindmap.xmind --workspace --format json - -# 兜底:超时或中断后手动查任务 -dws doc import get --task-id --format json - -# 异步模式:提交后立即返回,不等待格式转换完成 +dws doc +import --file ./report.docx --format json +dws doc +import --file ./report.docx --folder --format json +dws doc +import --file ./notes.md --workspace --name "会议纪要" --format json ``` -## 参考 +`+import` 一次完成创建会话、上传、确认转换和轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。 -- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令) -- [`./doc-info.md`](./doc-info.md)(前置:理解文档标识) +## 本地文件边界 + +- `--file` 只接受当前工作目录内已存在的相对路径;禁止绝对路径、`..` 或符号链接逃逸。 +- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录,两者都有时优先 `--folder`。 +- CLI 负责上传和格式转换。不要先用 Python/Office 库解析文件,不要安装本地依赖来伪造在线导入结果,也不要手写 HTTP 上传。 +- 白名单外格式(如 HTML/PDF)自动改走原文件上传,返回 `fallback=upload`、`converted=false`;不得报告成已经转换为可编辑在线文档。 + +## 失败处理 + +- 发起前的格式、大小或路径校验失败:修正输入后再执行。 +- 已返回 `taskId` 后超时或中断:保留该 `taskId`,读取精确恢复命令 Schema 后只查询原任务;禁止重新提交导入。 +- 返回状态未知时原样报告,不把本地文件内容改走 `+create`,因为这会改变格式保真和任务语义。 +- 白名单外格式如果目标是钉盘而非文档空间,切换到 `dingtalk-drive` 上传。 + +正常导入不得手工编排原子 `doc import` 子步骤。只有 shortcut 未公开必要的恢复参数时,才按精确 leaf Schema 使用原子查询命令。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-info.md b/skills/multi/dingtalk-doc/references/doc/doc-info.md index da3411d4..6b2d6c6b 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-info.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-info.md @@ -1,139 +1,12 @@ -# doc info(获取文档元信息 + URL 解析) - -> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: -> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 -> 2. [`url-patterns.md`](../../../dingtalk-shared/references/url-patterns.md) — 仅当用户原始 `alidocs` URL 需要 probe 时 -> -> **探测入口变更**:alidocs URL 的类型探测现在统一走 `dws drive info`(详见 [链接规范](../../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程))。`drive info` 检测到 `extension=adoc/axls/able` 时会自动调用 `doc info` 返回更详细的文档信息。**仅在 `drive info` 已确认是 ALIDOC 类型后**,才需要直接使用 `doc info`。 -> -> **同任务常配合**:`dws drive search` / `dws wiki node search`(先定位 nodeId)/ [`doc-read.md`](doc-read.md)(确认是 ALIDOC 后读正文) - -## 命令格式 - -``` -Usage: - dws doc info [flags] -Example: - dws doc info --node - dws doc info --node "https://alidocs.dingtalk.com/i/nodes/" - dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=" - dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=" -Flags: - --node string 文档 ID 或 URL (必填) -``` - -## URL 识别与 DOC_ID 提取 - -当用户输入包含钉钉文档 URL 时,**必须先识别并提取 DOC_ID**,再判断意图。 - -补充:如果这是用户直接提供的原始 `alidocs` URL,必须先按 [链接规范](../../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程) 用 `dws drive info` 探测真实类型。只有 `extension=adoc` 时才继续走 `doc`;其他类型(pdf/docx/txt/md/xlsx 等)走 `drive`。 - -### 支持的 URL 格式 - -| 格式 | 示例 | DOC_ID 提取方式 | -|------|------|----------------| -| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | -| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` | - -### 提取规则 - -1. 匹配 URL 中 `alidocs.dingtalk.com` 域名 -2. 取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment) -3. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析) -4. 对用户直接提供的原始 `alidocs` URL,先按 [链接规范](../../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程) 用 `dws drive info` 探测;只有探测确认 `extension=adoc` 时,才继续走 `doc` - -## ID 边界与参数映射 - -- `nodeId` 是 `doc` 命令的统一节点标识;文档、文件夹、文件都通过 `nodeId` 或完整 `alidocs` URL 传给 `--node` / `--folder`。 -- `dentryUuid` 是 `alidocs` URL `/i/nodes/{dentryUuid}` 的最后一段,在 `doc` 场景中等价于可传入 CLI 的 `nodeId`;不要把它改写成数字 ID。 -- `dentryId` 通常是纯数字,**不是** `doc` 的 `nodeId`,也不是 `doc --folder` 的目标文件夹 ID;不要把数字 `dentryId` 当作 `--node`、`--folder` 或 `--parent-id` 使用。 -- `parentId` / `--parent-id` 不是 `doc` 命令参数;`doc` 里目标父文件夹统一使用 `--folder `,目标知识库使用 `--workspace `。 -- 如果上下文只有数字 `dentryId`,但用户要读、改、移动、复制、重命名文档,先通过 `dws drive search` / `dws drive list` / 用户提供的 `alidocs` URL 获取 `nodeId` / `dentryUuid`,不要用数字 `dentryId` 重试为父目录参数。 - -## 处理流程 - -``` -用户输入含 alidocs.dingtalk.com URL - → 若是用户直接提供的原始 URL,先按链接规范做 probe - → 提取 DOC_ID(URL 路径最后一段) - → 结合用户意图选择命令(adoc 默认 `doc read`,folder 默认 `drive list`,file 默认 `drive download`) - → 将 DOC_ID 传给 --node 参数 -``` - -## 获取 nodeId 的三种方式(按场景选择,**无需全部执行**) - -执行 `read` / `info` / `update` / `copy` / `move` / `rename` / `delete` / `block` 等所有需要 `--node` 的命令前,按以下顺序确定 nodeId 来源,**命中即停**,不要走多余的 search/list 浪费一次调用: - -| 方式 | 触发条件 | 操作 | -|------|----------|------| -| **A** | 用户**直接提供文档 URL 或 nodeId** | **直接传给 `--node`**,无需额外查询;优先使用此方式 | -| **B** | 用户给出关键字 / 文档名 | `dws drive search --query "<关键字>" --format json` 或 `dws wiki node search --workspace --query "<关键字>"` 从返回中提取 nodeId | -| **C** | 用户指向某个文件夹下的文档 | `dws drive list --workspace --format json` 或 `dws wiki node list --workspace ` 从返回中提取 | - -> **关键节省**:方式 A 命中时,禁止再调 search/list "确认一下" —— 用户提供的 URL/nodeId 本身就是权威输入。同理,`--folder` 也支持 alidocs 文件夹 URL 直传,不要先 search 把 URL 解析成纯数字 ID 再传。 - -## nodeId 多格式说明 - -所有 `--node` 参数同时支持以下格式,系统自动识别: - -- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` -- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` -- **文档链接(edit/preview)**: `https://alidocs.dingtalk.com/document/{edit|preview}?...&dentryKey={key}`(必须传入完整 URL,不要提取其中的 query 参数单独使用) - -以下命令效果相同: +# 查看文档信息:`+inspect` Golden Route ```bash -dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA -dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA" -dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE" -dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=74993670680&type=d&docKey=Pd6l2Z7V8ZWydl7M&dentryKey=rBGBr2r1HmwanAGW" +dws doc +inspect --node --format json +dws doc +inspect --node --include-permissions --include-history --format json ``` -> **注意**:`document/edit` 和 `document/preview` 格式 URL 中的 `dentryKey` 参数值不是合法的独立 nodeId,禁止提取后单独使用,必须传入完整 URL。URL 中可能包含 `utm_source`、`chInfo` 等追踪参数,无需手动去除,直接传入完整 URL 即可。 +只打开任务需要的 `--include-style/--include-permissions/--include-history/--include-media/--include-comments`,不要默认全取。正文读取使用 `+fetch`,不要用信息查询替代正文接口。 -`--folder` 参数同样支持 alidocs 文件夹 URL 或文档文件夹 nodeId。 +检查返回的真实 `nodeId`、类型、URL 和各可选步骤。若 URL 指向的不是在线文字文档,停止 doc 流程并切换到 drive、sheet、aitable、slides 或 wiki;禁止凭 URL 外形猜类型。 -不要把纯数字 `dentryId` 当成这里的 ID。需要父文件夹时,使用文件夹的 `nodeId` / `dentryUuid` / URL 传给 `--folder`;不能改用 `--parent-id`。如果上一步只拿到了 drive/chat 链路里的纯数字 `dentryId`、`spaceId` 或 `parent-id`,说明还没有拿到 doc 文件夹,应该省略 `--folder` 使用默认文档根目录,或先通过 `dws drive search` / `dws drive list` 找到文档文件夹 nodeId。 - -## 上下文传递 - -| 从返回中提取 | 用于 | -|-------------|------| -| `extension` | 选择 [`./doc-read.md`](doc-read.md)(adoc)/ `dws sheet ...`(axls)/ `dws aitable ...`(able)/ `dws drive download`(其他) | -| `nodeId` / `docUrl` | 后续所有 `--node` 入参 | - -## 常用模板 - -```bash -# 标准用法(nodeId 直传) -dws doc info --node --format json - -# alidocs URL 直传(CLI 自动解析) -dws doc info --node "https://alidocs.dingtalk.com/i/nodes/" --format json - -# document/edit URL 直传(必须完整 URL,禁止单独提取 dentryKey) -dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=" --format json - -# document/preview URL 直传 -dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=" --format json -``` - -**反例**(这些 ID **不能**直接用于 `--node`): - -```bash -# ❌ 纯数字 dentryId(drive 链路) -dws doc info --node 1234567890123 # 错误 - -# ❌ 单独的 dentryKey(必须带完整 URL) -dws doc info --node "wo1g3x54FzVEJ5yE" # 错误 - -# ❌ workspaceId(应作为 --workspace 用) -dws doc info --node # 错误 -``` - -## 参考 - -- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令) -- `dws drive search` / `dws wiki node search`(前置:定位 nodeId 的搜索入口,详见 [`../drive.md`](../../../dingtalk-drive/references/drive.md) / [`../wiki.md`](../../../dingtalk-wiki/references/wiki.md)) -- [`./doc-read.md`](doc-read.md)(extension=adoc 的后续命令) -- [`url-patterns.md`](../../../dingtalk-shared/references/url-patterns.md)(用户原始 alidocs URL 的 probe 流程) +只有 `+inspect` 未公开所需的底层字段或必须获得原始响应时,才读取精确 leaf Schema 后使用原子信息命令。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-media.md b/skills/multi/dingtalk-doc/references/doc/doc-media.md index 079a4f48..b87c2e03 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-media.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-media.md @@ -1,98 +1,34 @@ -# doc media(附件 / 图片:download / insert) +# 文档正文媒体 -> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: -> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 - -> ⚠️ **图片插入硬规则**: -> - 图片来源如果是钉盘/文档空间中的文件,**必须先下载到本地**(`dws drive download --node <图片nodeId> --output /tmp/xxx.png`),再执行 `media insert` -> - **禁止**把钉盘/文档节点 URL(如 `alidocs.dingtalk.com/i/nodes/...`)写进 Markdown `![](...)` 图片语法——这些是页面链接,不是可渲染的图片资源 -> - 创建文档时需要图文并茂:先用 `doc create` 写入纯文本骨架,再对每张图片执行 `media insert`,最后用 `doc block list` 验证附件块存在 - ---- - -## doc media insert(上传附件并插入文档) - -`media insert` 是三步自动完成的流程(获取附件上传凭证 → OSS 上传 → 插入附件块到文档),无需手动分步操作。 - -> **图片插入也走 `media insert`**:用户说"插图 / 加图片 / 嵌入图片"时使用本命令,不要走 [`doc-block.md`](./doc-block.md) `block insert`。 - -``` -Usage: - dws doc media insert [flags] -Example: - dws doc media insert --node --file ./report.pdf - dws doc media insert --node --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream - dws doc media insert --node --file ./image.png --ref-block --where before -Flags: - --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --file string 本地文件路径 (必填) - --name string 附件显示名称 (默认使用文件名) - --mime-type string 文件 MIME 类型 (默认根据扩展名推断) - --index int 插入位置索引 - --where string 相对位置: before / after (配合 --ref-block) - --ref-block string 参考块 ID (配合 --where) -``` - -### 关键说明 - -- `--mime-type` 可选,不指定时根据扩展名自动推断;支持常见文件类型(PDF、Office、图片、视频、压缩包等)。 -- 与 `dws drive upload` 的区别:`drive upload` 将文件上传到文档空间/知识库作为**独立文件**;`media insert` 将文件作为**附件块插入到文档正文中**。 - ---- - -## doc media download(下载文档附件) - -``` -Usage: - dws doc media download [flags] -Example: - dws doc media download --node --resource-id - dws doc media download --node "https://alidocs.dingtalk.com/i/nodes/xxx" --resource-id -Flags: - --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --resource-id string 附件资源 ID,可通过 dws doc block list 获取 (必填) -``` - -### 关键说明 - -- `media download` 用于获取文档正文中附件的**临时下载链接**。 -- `--resource-id` 可通过 [`./doc-block.md`](./doc-block.md) `block list` 返回的 attachment 块获取,或从 [`./doc-read.md`](./doc-read.md) 返回的 OSS 链接 `/att/.ext` 中提取。 - -## 上下文传递 - -| 从返回中提取 | 用于 | -|-------------|------| -| `media insert` `resourceId` | 附件已插入文档;可通过 [`./doc-block.md`](./doc-block.md) `block list` 查看附件块 | -| `media download` `downloadUrl` | 下载文档中的附件资源(临时链接,会过期) | - -## 常用模板 +## Golden Route ```bash -# 基本用法:插入本地文件到文档 -dws doc media insert --node --file ./report.pdf +# 列出图片和附件,取得真实 resourceId/blockId +dws doc +media-list --node --format json -# 指定附件显示名称 -dws doc media insert --node --file ./data.xlsx --name "Q1数据报表.xlsx" +# 插入工作目录内的本地文件 +dws doc +media-insert --node --file ./image.png --format json -# 指定 MIME 类型(扩展名无法推断时) -dws doc media insert --node --file ./data.bin --name "导出数据.dat" --mime-type application/octet-stream +# 下载到工作目录,默认不覆盖 +dws doc +media-download --node --resource-id --output ./downloads/ --format json -# 在指定块之前插入附件 -dws doc media insert --node --file ./image.png --ref-block --where before - -# 完整流程:创建文档 → 写入内容 → 插入附件 -dws doc create --name "项目报告" --content "# 项目报告\n\n以下为相关附件:" --content-format markdown -# 提取 nodeId 后: -dws doc media insert --node --file ./design.pdf -dws doc media insert --node --file ./timeline.xlsx --name "项目时间线.xlsx" - -# 下载文档中的附件(resourceId 从 block list 获取) -dws doc media download --node --resource-id +# 只为临时查看下载到受控临时目录 +dws doc +media-preview --node --resource-id --format json ``` -## 参考 +## 稳定 ID 与结果 -- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族) -- [`./doc-block.md`](./doc-block.md)(block list 取 attachment 的 resourceId) -- [`../drive.md`](../../../dingtalk-drive/references/drive.md)(独立文件上传:`dws drive upload`) -- [`./style/doc-style-guideline.md` §4.9 附件与图片](./style/doc-style-guideline.md)(图示与附件使用规范) +- `resourceId`、`blockId`、`nodeId` 必须来自真实 media/block 返回,不能从标题或本地文件名猜测。 +- 插入后保留 `resourceId` 和插入结果;下载必须检查 `localPath`、`sizeBytes > 0`。 +- 下载输出只接受工作目录内相对路径,默认 no-clobber。 +- 删除源文件是独立的破坏性本地操作,不属于媒体下载;只有用户明确要求且下载验证成功后才能执行。 + +## 失败最短路径 + +- `download_doc_attachment` 失败:保留 `nodeId/resourceId` 和服务端错误,停止。 +- 临时链接过期:重新调用 `+media-download` 获取新链接,由 CLI 内部下载。 +- 禁止把 `+fetch` 返回的临时 OSS URL 交给 `curl/wget`。 +- 禁止安装图片、Office 或 Python 依赖作为隐式降级。 +- 不得把纯数字 dentryId 当作 drive folder 重试;需要文件树定位时切换到 `dingtalk-drive` 并使用真实 dentryUuid。 + +只有 shortcut 缺少必要的底层定位参数时,才读取精确原子 leaf Schema;不要把 `doc media insert/download` 作为默认入口。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-read.md b/skills/multi/dingtalk-doc/references/doc/doc-read.md index 5feebfa8..dc8dd710 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-read.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-read.md @@ -1,121 +1,33 @@ -# doc read(读取文档内容) +# 读取文档:`+fetch` Golden Route -> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: -> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 -> 2. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读 -> -> **同任务常配合**:[`doc-info.md`](doc-info.md)(先解析 URL,确认 extension=adoc)/ [`doc-update.md`](doc-update.md)(读后改写)/ [`doc-block.md`](doc-block.md)(块级精修前先读结构) - -## 命令格式 - -``` -Usage: - dws doc read [flags] -Example: - dws doc read --node - dws doc read --node "https://alidocs.dingtalk.com/i/nodes/" - dws doc read --node --content-format jsonml --output ./doc.json - dws doc read --node --content-format jsonml --scope outline -Flags: - --node string 文档 ID 或 URL (必填) - --content-format string 输出格式: 默认为 markdown,可选 jsonml(返回完整 JSONML 结构) - --output string 输出到本地文件路径(仅 --content-format jsonml 时生效) - --scope string 按 scope 筛选节点(需 --content-format jsonml): outline(全部 h1-h6 标题)/range(区间)/section(单块)/tags(配合 --tags 自定义); 详见下方 §scope/tags 节点筛选 - --tags string 自定义 JSONML tag 列表(逗号分隔, 如 h1,h2,table); 仅在 --scope tags 时使用且必填 - --max-depth int 筛选遍历最大深度, 0 表示不限(仅 --content-format jsonml + --scope/--tags 时生效) - --start-block-id string range/section 起始块 ID(节点 uuid); scope=range/section 时必填 - --end-block-id string range 结束块 ID(节点 uuid); "-1"或空=到文档末尾(仅 scope=range 生效) -``` - -## 关键说明 - -- 默认返回 **Markdown** 格式的文档内容,仅限有"下载"权限的文档。 -- 返回的 Markdown 中,附件以 OSS 临时下载链接形式给出(如 `https://alidocs2.oss-cn-zhangjiakou.aliyuncs.com/res/.../att/.ext?Expires=...`),**链接会过期**。链接过期后从 URL 路径中提取 ``(即 `/att/` 后、扩展名前的 UUID 部分),用 `media download --node --resource-id ` 重新获取下载链接。 -- `--content-format jsonml` 返回完整 JSONML 结构(含 `revision`),用于无损读改写;可直接配合 [`doc-update.md`](./doc-update.md) 的 `--content-format jsonml --content-file` 写回。`revision` 仅在并发敏感场景下需要透传给 update 触发并发检查(详见下方)。 - -## content-format=jsonml 输出 - -输出 JSON 对象,包含 `revision`(版本号)和 `jsonml`(JSONML body 数组): - -```json -{ - "revision": 42, - "jsonml": ["root", {"sectPr": {}}, ["p", {"uuid": "abc"}, "Hello"], ...] -} -``` - -可直接用于 `doc update --content-format jsonml --content-file` 写回。`revision` 字段在普通改写场景下**不需要**透传——`doc update` 默认直接覆盖。仅在担心多 agent 并发覆盖时,才把 `revision` 通过 `--revision` 透传给 update 触发并发检查(详见下方 §并发安全模式)。 - -## scope/tags 节点筛选(返回 JSONML fragment) - -当只想读取文档的**部分节点**(如大纲、某区间、某个块)时,用 `--scope` 筛选,避免拉取整篇。**筛选是 JSONML 链路能力,MUST 配合 `--content-format jsonml`**(缺失会报错 `--scope/--tags requires --content-format jsonml`)。**`--scope` 必须显式指定为 `outline`|`range`|`section`|`tags` 之一**;`--tags` 仅在 `--scope tags` 时使用(其他组合会报错): - -- **tag 类**(outline / tags):按节点类型筛选。`outline`=全部 h1-h6 标题预设;`--scope tags --tags h1,table` 按自定义 tag 子集筛选(tables/images/code 预设已下线,改用 `--scope tags --tags table`/`img`/`code`)。 -- **锚点类**(range/section):按位置截取。`range`=顶层 `[start,end]` 闭区间(`--end-block-id "-1"` 或留空=到文末);`section`=只取 `--start-block-id` 指向的单块(完整子树)。块 ID(节点 uuid)可从 [`doc-block.md`](./doc-block.md) 的 `list_document_blocks` 或 JSONML 节点的 `uuid` 属性获得;`scope=range/section` 时 `--start-block-id` 必填。 - -### 返回:JSONML fragment 容器(MUST 剥壳消费) - -筛选结果是一个 **JSONML fragment 容器**(承载在 `jsonml` 字段): - -```json -["fragment", {"source": "outline"}, ["h1", {"uuid": "h1a"}, "一级标题"], ["h2", {"uuid": "h2a"}, "二级标题"]] -``` - -- `source` 标识产生结果的操作(range/section/outline/tags)。 -- fragment 是**查询结果虚拟容器**,仅用于读出: - - **消费时 MUST 剥掉 fragment 外层**(跳过 index 0 的 `"fragment"` 与 index 1 的 attrs 对象),取其后的 children 使用。 - - **MUST NOT 把 fragment 整体写回文档**(如透传给 `doc update --content-format jsonml`)——服务端 JSONML 写校验会拦截。若要写回,只写剥壳后的 children。 +## 唯一推荐入口 ```bash -# 读文档大纲(目录) -dws doc read --node --content-format jsonml --scope outline -# 读某区间(从块 A 到块 B)的 JSONML -dws doc read --node --scope range --start-block-id --end-block-id --content-format jsonml -# 读单个块及其子树 -dws doc read --node --content-format jsonml --scope section --start-block-id -# 按自定义 tag 读取(如所有表格 / 图片) -dws doc read --node --content-format jsonml --scope tags --tags table,img +dws doc +fetch --node --format json +dws doc +fetch --query "项目周报" --scope keyword --keyword "结论" --format json ``` -## 上下文传递 +- 已知 ID 或 URL:传 `--node`。 +- 只知道标题:传 `--query`;跨页解析必须唯一命中,否则停止并要求用户选择。 +- `--node` 与 `--query` 必须且只能提供一个。 +- 默认 `--detail simple --scope full`,适合普通阅读,避免加载不必要的 JSONML。 -| 从返回中提取 | 用于 | -|-------------|------| -| Markdown 正文 | 用户可读输出 / 二次处理 | -| JSONML `jsonml`(完整 body,无 scope/tags 时) | [`doc-update.md`](./doc-update.md) 的 `--content-file` + `--content-format jsonml`(可整体写回) | -| JSONML `jsonml`(fragment,scope/tags 筛选时) | 剥掉 fragment 外层取 children 使用;**MUST NOT 整体写回** | -| JSONML `revision` | [`doc-update.md`](./doc-update.md) 的 `--revision`(可选;担心被并发覆盖时使用) | -| 附件链接中的 `resourceId` | [`doc-media.md`](./doc-media.md) 的 `--resource-id`(链接过期后续期) | - -## 常用模板 +## 局部读取 ```bash -# 默认 Markdown 输出(最常用) -dws doc read --node --format json - -# alidocs URL 直传 -dws doc read --node "https://alidocs.dingtalk.com/i/nodes/" --format json - -# JSONML 完整结构 → 文件(无损改写前置) -dws doc read --node --content-format jsonml --output /tmp/doc.json -# 之后修改 /tmp/doc.json 中的 jsonml 数组,再用: -# dws doc update --node --content-file /tmp/doc.json --content-format jsonml --mode overwrite -# 担心被并发覆盖时,再加 --revision <从上面 read 拿到的 revision> +dws doc +fetch --node --scope outline --detail with-ids --format json +dws doc +fetch --node --scope section --start-block-id --detail full --format json +dws doc +fetch --node --scope range --start-block-id --end-block-id --detail full --format json +dws doc +fetch --node --scope tags --tags table,img --detail full --format json +dws doc +fetch --node --scope keyword --keyword "风险|结论" --context-before 120 --context-after 240 --format json ``` -## 并发安全模式(担心被并发覆盖时使用) +只有需要块 ID、revision 或 JSONML 保真结构时才提高 `--detail`;先读取最小必要范围,避免把整篇大文档放入上下文。 -如果你担心在编辑期间别人也在改这个文档,可以把 read 返回的 `revision` 透传给 update 触发服务端并发检查: +## 后续路由 -1. `dws doc read --node --content-format jsonml --output /tmp/doc.json` — 输出 JSON 中的 `revision` 字段(如 `42`)记下来。 -2. 编辑 `/tmp/doc.json` 中的 `jsonml` 字段。 -3. `dws doc update --node --content-file /tmp/doc.json --content-format jsonml --mode overwrite --revision 42` — 文档若在期间被改过,服务端返回 `VersionConflict`,重做第 1 步即可。 +- 读后修改:把稳定的 `nodeId` 交给 [`doc-update.md`](doc-update.md) 的 `+update` 或 `+checkpoint-update`。 +- 附件/图片:先用 [`doc-media.md`](doc-media.md) 的 `+media-list` 取得稳定 `resourceId`,再下载;不要复用正文中的临时签名 URL。 +- 富结构专家编辑:确实需要原始 JSONML 或 shortcut 未公开的参数时,先读取精确 leaf Schema,再使用原子 `doc read`/`doc block`。 -不带 `--revision` 时服务端不做并发检查,直接覆盖;普通单 agent 编辑场景下默认不传即可。 - -## 参考 - -- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令) -- [`./doc-info.md`](./doc-info.md)(前置:判断 contentType / extension) -- [`./doc-update.md`](./doc-update.md)(读后改写) -- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构) +禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-update.md b/skills/multi/dingtalk-doc/references/doc/doc-update.md index 026b33c4..502103c7 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-update.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-update.md @@ -1,236 +1,50 @@ -# doc update(更新文档内容) +# 更新在线文字文档 -> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: -> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 -> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、分片 append、回读验收) -> 3. [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md) — 排版规范 -> 4. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读 -> -> **同任务常配合**:[`doc-read.md`](./doc-read.md)(改写前必读,jsonml 模式拿当前结构;担心被并发覆盖时再取 revision)/ [`doc-block.md`](./doc-block.md)(单 block 改写优先;本命令更适合追加 / 整篇 overwrite) +## 唯一推荐入口 -## 命令格式 - -``` -Usage: - dws doc update [flags] -Example: - dws doc update --node --content "# 追加内容" --mode append - dws doc update --node --content "# 完整替换" --mode overwrite - dws doc update --node --content-file ./part1.md --mode append - dws doc update --node --content "# 插入到第3个block前" --mode append --index 2 - dws doc update --node --content-file ./body.json --content-format jsonml --mode overwrite - cat part2.md | dws doc update --node --content - --mode append -Flags: - --node string 文档 ID 或 URL (必填) - --content string 文档内容(短文本字面量);传 - 表示从 stdin 读取 - --content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用 - --mode string 更新模式: overwrite=覆盖, append=追加 (必填) - --content-format string 内容格式: 默认为 markdown,可选 jsonml - --revision int 文档版本号(仅 --content-format jsonml 时生效,可选);传入后服务端做并发检查,版本不一致时返回 VersionConflict。不传则直接覆盖,不做并发检查 - --fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用 - --index int 插入位置(从 0 开始),仅在 mode=append 时生效。指定将内容插入到文档第几个 block 之前。不传时追加到末尾。block 的 index 可通过 doc block list 获取。插入成功后,该位置及之后所有 block 的 index 会依次 +1 -``` - -## 关键说明 - -- `--mode` 必填,无默认值。`overwrite` **清空原内容后重写**,谨慎使用;`append` 更安全。 -- 整篇 overwrite 大文档前**必须**先向用户提示风险并等待确认(详见 [`./style/doc-update-workflow.md` §4.5](./style/doc-update-workflow.md))。 -- `--content` 中的换行必须是**真实换行符**(Unicode `U+000A`),不是字面量 `\n`;多行/表格/长文本优先 `--content-file` 或 `--content -`。 -- **写入后必须回读**——返回 `success=true` 不等于内容真的写入完整(详见 [`./style/doc-update-workflow.md` §6](./style/doc-update-workflow.md))。 - -## JSONML 格式写入 - -使用 `--content-format jsonml` 可以 JSONML 结构直接写入文档,实现无损读写。当前仅支持 `--mode overwrite`。 - -**输入格式**:JSON 对象,包含 `jsonml` 字段(文本必须用 `span/data-type=text + span/data-type=leaf` 包裹,详见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)): - -```json -{"jsonml": ["root", {"sectPr": {}}, - ["p", {"uuid": "p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "hello"]]], - ["p", {"uuid": "p2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "world"]]] -]} -``` - -> CLI 不做结构修复——裸字符串、缺 uuid 等错误会被 validator 直接抦下。body 必须以 `["root", ...]` 为根节点,缺少会报错。如果输入来自 LLM 生成且可能有 JSON 语法错误(缺括号/逗号),加 `--fix-jsonml` 启用 JSON 语法修复。 - -**典型流程**(无损读改写): - -1. `dws doc read --node --content-format jsonml --output ./doc.json` — 获取文档 JSONML 结构 -2. 修改 `doc.json` 中的 jsonml 数组内容 -3. `dws doc update --node --content-file ./doc.json --content-format jsonml --mode overwrite` — 写回 - -> 默认不传 `--revision`,服务端直接覆盖,不做并发检查。担心多 agent 同时改时,按下方 §并发安全模式 加 `--revision`。 - -**节点结构参考**:[`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md) - -### 并发安全模式(担心被并发覆盖时使用) - -如果你担心在编辑期间别人也在改这个文档,可以传 `--revision` 触发服务端并发检查: - -1. `dws doc read --node --content-format jsonml --output /tmp/doc.json` — 返回的 JSON 里有一个 `revision` 字段(比如 `42`)。 -2. 编辑 `/tmp/doc.json` 里的 `jsonml` 字段。 -3. `dws doc update --node --content-file /tmp/doc.json --content-format jsonml --mode overwrite --revision 42` — 如果文档在期间被改过,服务端返回 `VersionConflict`,此时重新执行第 1 步即可。 - -不带 `--revision` 时,服务端不做并发检查,直接覆盖。普通单 agent 编辑场景下默认不传即可。 - -## 内容写入管道(create / update 共用) - -> **关键原则**:CLI 内置自动分片。超长内容(>30000 字符)自动按 markdown 结构切分后逐片写入,对调用方透明。写入完成后由调用方自行决定是否回读确认。 - -### 输入方式选择 - -| 场景 | 推荐方式 | 说明 | -|------|---------|------| -| 短文本(<2KB,无换行/表格/特殊字符) | `--content "..."` | 字面量传入,最简单 | -| 长文本(≥2KB)、含换行、含表格 | `--content-file ./file.md` | **必须**用文件路径,避免 shell escape 和截断 | -| 含特殊字符(`"`、`\`、`$`、`` ` ``) | `--content-file ./file.md` | 字面量传入会被 shell 转义破坏 | -| 管道/heredoc 输入 | `--content -` 或 `cat file \| dws doc ...` | 从 stdin 读取 | - -### 自动分片行为 - -当内容超过 30000 字符时,CLI 自动执行: - -1. **create**: 先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append -2. **update (overwrite)**: 第一片用 overwrite,后续片用 append -3. **update (append)**: 所有片段用 append - -分片策略按优先级:H1 标题 → H2 标题 → H3 标题 → 空行(段落边界)→ 硬切(保留表格/代码块完整性) - -如果某片写入超时,自动将分片大小减半重试(最小 5000 字符,低于此值报错)。 - -### 输出格式 - -写入成功后输出 JSON(混合 `[INFO]` 进度行): - -```json -{"success": true, "nodeId": "xxx", "chunksWritten": 3} -``` - -| 字段 | 说明 | -|------|------| -| `nodeId` | 文档节点 ID,可用于后续读取或追加 | -| `chunksWritten` | 实际写入的分片数(1 = 单次写入) | - -### 内容完整性验证(必读) - -CLI **不会**自动执行回读验证。**你必须在文档写入完成后主动回读确认**: - -1. 使用 `dws doc read --node ` 读取写入后的文档内容 -2. 检查关键段落是否完整、顺序是否正确 -3. 如发现内容缺失或异常,使用 `dws doc update --mode append` 补写缺失部分 - -> **何时回读**:每次 create/update 操作完成后都应回读。如果是连续多次编辑同一文档,可以在全部编辑完成后统一回读一次。 - -### 进度输出示例 - -``` -[INFO] 内容较长 (45000 字符),自动分片写入... -[INFO] 已创建空文档 (nodeId=abc123),开始分片写入... -[INFO] 写入分片 (1/3),15000 字符... -[INFO] 写入分片 (2/3),15000 字符... -[INFO] 写入分片 (3/3),15000 字符... -[INFO] 全部 3 个分片写入完成 -{"success": true, "nodeId": "abc123", "chunksWritten": 3} -``` - -### CONTENT_TRUNCATED 错误 - -当分片写入持续超时且减半到最小阈值仍失败时,返回 `CONTENT_TRUNCATED` 错误码。应对策略: - -1. 检查网络和后端服务状态 -2. 已写入的部分内容可通过 `dws doc read --node ` 查看 -3. 从断点处手动用 `dws doc update --mode append` 继续追加 - -## 长 Markdown 写入 - -**核心规则**:含多行、表格、`\n` 或长度 >2KB 的 Markdown **必须**通过 `--content-file` 或 `--content -`(stdin)传入,禁止直接作为 `--content` 命令行字符串——shell escape 会破坏换行和表格,且命令行长度受限。 - -`dws doc create` 和 `dws doc update` 支持两种内容来源(`--content-file` 优先于 `--content`): - -| 形式 | 说明 | -|------|------| -| `--content "..."` | 字面量(仅推荐短文本 <2KB 且无换行/表格) | -| `--content -` | 从 stdin 读取(可配合 heredoc/pipe) | -| `--content-file path` | 从文件读取(UTF-8),推荐 | - -### 短/中等长度(< 200KB)— 单步写入 +普通追加、覆盖和 block 编辑统一使用 `+update`: ```bash -# 1. 把内容写入 UTF-8 文本文件: -# Linux/Mac: /tmp/.md;Windows: %TEMP%\.md -# 2. 一步写入: -dws doc update --node --content-file --mode overwrite --content-format markdown +dws doc +update --node --command append --content "补充说明" --format json +dws doc +update --node --command append --content @append.md --format json +dws doc +update --node --command overwrite --content @full.md --format json +dws doc +update --node --command overwrite --doc-format jsonml --content @full.json --expected-revision --format json +dws doc +update --node --command block_replace --block-id --content "新内容" --format json ``` -### 超长(> 200KB 兜底)— 分片追加 +重要覆盖或明确要求恢复点时使用: ```bash -# 1. 按 markdown 标题或段落边界切成 ≤200KB 的片段(不要切断表格) -# 2. 逐个追加: -dws doc update --node --content-file --mode append --content-format markdown +dws doc +checkpoint-update --node --mode overwrite --content @full.md --format json ``` -> **注意**:分块 append 存在静默失败风险(部分片段返回 success 但实际未写入),执行前**必须**向用户发出截断风险提示并等待确认。完整规范见 [`../../best_practices/04-document.md` «分块 append 截断风险提示»](../04-document.md)。 +`+checkpoint-update` 负责保存版本、写入和回读,不要手工编排 `version save → update → read`。 -### stdin 变体 +## 动作与输入 -```bash -# pipe -cat report.md | dws doc update --node --content - --mode append --content-format markdown +| `--command` | 用途 | 必要参数 | +|---|---|---| +| `append` | 末尾追加 | `--content` | +| `overwrite` | 整篇覆盖 | `--content`;JSONML 可加 `--expected-revision` 做服务端原子条件写 | +| `block_insert_after` | 在指定 block 后插入 | `--after-block-id --content` | +| `block_replace` | 替换指定 block | `--block-id --content` | +| `block_delete` | 删除指定 block | `--block-id` | +| `str_replace` | 唯一普通文本替换 | `--old --new` | +| `block_copy_insert_after` | 复制 block 后插入 | `--block-id --after-block-id` | -# heredoc(真实换行,含表格) -dws doc update --node --mode append --content - --content-format markdown <<'EOF' -## 追加段落 +统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。禁止绝对路径、`..`,也不要猜测 `--content-file`、`--content-format` 或 `replace_all`。 -| 列1 | 列2 | -|---|---| -| a | b | -EOF -``` +block ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表,禁止编造。 -## 上下文传递 +`--expected-revision` 只允许 `--command overwrite --doc-format jsonml`。Markdown、append 和 block 接口没有服务端原子 revision 契约,禁止用写前读取模拟乐观锁。 -| 从返回中提取 | 用于 | -|-------------|------| -| `success` + `chunksWritten` | 判断是否需要回读补救(`chunksWritten > 1` 时重点查章节顺序) | -| 错误码 `CONTENT_TRUNCATED` | 触发 [`./doc-read.md`](./doc-read.md) 查断点 + 再次 `update --mode append` | +## 确认与验证 -## 常用模板 +- 执行前从精确 leaf Schema 消费 `confirmation`;需要确认时先 `--dry-run`/询问,用户确认同一参数后追加 `--yes`,禁止先失败探测门禁。 +- `+update` 已负责回读验证。除非结果为 `unknown` 或任务需要额外结构验收,不要再做一次全篇读取。 +- `doc_write_verification_failed` 表示写入已经发生,必须先读取现状;禁止直接重复写入。 +- `partial_success` 只恢复未完成步骤,不能重放成功步骤。 -```bash -# overwrite 整段(用户已确认) -dws doc update --node --content-file /tmp/.md --mode overwrite --content-format markdown +## 高级通道 -# append 末尾追加 -dws doc update --node --content-file /tmp/-append.md --mode append --content-format markdown - -# append 到指定 block 前(index 通过 doc block list 获取) -dws doc update --node --content-file /tmp/.md --mode append --index 2 --content-format markdown - -# JSONML 整篇无损 overwrite(默认不做并发检查;并发敏感时加 --revision ) -dws doc update --node --content-file /tmp/.json --content-format jsonml --mode overwrite - -# 短文本字面量(<2KB 无换行) -dws doc update --node --content "## 简短追加" --mode append --content-format markdown - -# stdin(pipe) -cat report.md | dws doc update --node --content - --mode append --content-format markdown - -# stdin(heredoc,含表格) -dws doc update --node --mode append --content - --content-format markdown <<'EOF' -## 追加段落 - -| 列1 | 列2 | -|---|---| -| a | b | -EOF -``` - -## 参考 - -- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令) -- [`./doc-read.md`](./doc-read.md)(改写前必读;jsonml 模式拿当前结构,担心并发时再取 revision) -- [`./doc-block.md`](./doc-block.md)(单 block 改写更精准) -- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、分片 append 风险、回读验收) -- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(排版规范) -- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 范例 / 节点结构) +只有 shortcut 未公开底层参数或必须保留原始响应时,才读取精确 leaf Schema 后使用 `dws doc update` / `doc block`。富结构保真按需读取 [doc-update-workflow.md](style/doc-update-workflow.md),但执行入口仍优先使用 `+update/+checkpoint-update`。 diff --git a/skills/multi/dingtalk-doc/references/doc/format/doc-jsonml-cookbook.md b/skills/multi/dingtalk-doc/references/doc/format/doc-jsonml-cookbook.md index b0847ca3..0cf6f925 100644 --- a/skills/multi/dingtalk-doc/references/doc/format/doc-jsonml-cookbook.md +++ b/skills/multi/dingtalk-doc/references/doc/format/doc-jsonml-cookbook.md @@ -1,13 +1,13 @@ # JSONML Cookbook -> 本文档提供 `dws doc create/update --content-format jsonml` 和 `dws doc block insert/update --content-format jsonml` 的**完整可用范例**。 +> 本文档提供 JSONML **结构范例**。正常执行入口是 `+create --content @./相对文件 --doc-format jsonml` 与 `+update --content @./相对文件 --doc-format jsonml`;原子 `doc create/update/block` 仅用于 shortcut 未公开字段的专家路径,使用前读取精确 leaf Schema。 > 所有示例均基于真实文档 serialize 输出验证。节点结构详细定义见 [doc-jsonml-schema.md](./doc-jsonml-schema.md)。 > 合法节点类型和属性的权威参考为 `wukong/products/jsonml-schema-v2.json`。 ## 决策型文档骨架范例(doc create 用) 以下是一个"方案对比汇报"的完整 JSONML 文件内容,展示摘要 callout + 彩色表格 + 状态高亮。 -**可直接复制到 `/tmp/.json` 后用 `dws doc create --name "..." --content-file /tmp/.json --content-format jsonml` 创建。** +**可保存到工作目录内的 `./drafts/.json`,再用 `dws doc +create --name "..." --content @./drafts/.json --doc-format jsonml` 创建。** ```json ["root", {}, diff --git a/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md b/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md index e055629e..fee839d2 100644 --- a/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md +++ b/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md @@ -1,6 +1,8 @@ # 钉钉文档创建流程 -本文只处理一件事:用 `dws doc create` 创建一篇钉钉文档,并确认内容真的写进去。资料采集、汇报生成、转发通知、权限分发等都不是本文范围,应由对应 recipe 或产品参考负责。 +本文只处理文档结构和排版设计。普通创建的执行入口固定为 `dws doc +create`,命令内置分片与回读验证;原子 `doc create/read/update` 仅用于 shortcut 未公开所需参数的专家路径,使用前必须读取精确 leaf Schema。 + +> **路由硬约束:** 正文文件使用 `--content @./相对路径 --doc-format markdown|jsonml`;禁止 `/tmp`、绝对路径、已删除的 Python 脚本以及“创建后再额外 read”固定流水。 > 改写已有文档见 [doc-update-workflow.md](./doc-update-workflow.md)。排版规范见 [doc-style-guideline.md](./doc-style-guideline.md)。 @@ -28,8 +30,7 @@ - 文档标题和创建位置确认 - 正文草稿准备 -- `doc create` 写入 -- 写入后回读验收 +- `+create` 写入并自动回读验收 - 内容缺失时的补救写入 本文不覆盖: @@ -220,8 +221,8 @@ callout 可通过 `"showstk": true, "sticker": "图标名"` 配置顶部贴纸 | 策略 | 条件 | 执行流程 | |------|------|----------| -| **直接 JSONML** | 短文档(≤ 15 块级节点)且结构简单 | 在 `/tmp/.json` 手写完整 JSONML 树 → `doc create --content-format jsonml` | -| **Markdown 脚手架 + JSONML 精修** | 长文档且结构较线性 | ① Markdown 建立内容骨架 → `doc create --content-format markdown` ② `doc read --node --content-format jsonml --output /tmp/.json` 拉回 JSONML(已含 uuid) ③ **Read `-rfc.md` + `-design.md`** 回顾规划 ④ 逐章对照执行结构变换 + 叠加样式 | +| **直接 JSONML** | 短文档(≤ 15 块级节点)且结构简单 | 在 `./drafts/.json` 编写完整 JSONML 树 → `+create --content @./drafts/.json --doc-format jsonml` | +| **Markdown 脚手架 + JSONML 精修** | 长文档且结构较线性 | ① Markdown 建立内容骨架 → `+create` ② 需要精修时用 `+fetch --detail full` 拉回 JSONML ③ 逐章执行结构变换与样式叠加 | | **直接 JSONML 分段构造** | 长文档且含大量富结构 | 按章节分段构造 JSONML,每段写完校验通过后再写下一段,最后拼接 | > **脚手架策略警示**:Markdown 无法表达分栏/callout/色彩表头,拉回的 JSONML 只有纯文本骨架。精修阶段不是“在现有结构上加色”,而是“参照 RFC/Spec 重组结构”。 @@ -295,20 +296,18 @@ callout 可通过 `"showstk": true, "sticker": "图标名"` 配置顶部贴纸 ### 写入 ```bash -dws doc create --name "<文档名>" --content-file /tmp/.json --content-format jsonml +dws doc +create --name "<文档名>" --content @./drafts/.json --doc-format jsonml ``` -### 回读验收 +### 验收 -```bash -dws doc read --node --content-format jsonml --output /tmp/-readback.json -``` +读取 `+create` 返回的 `verified`、`steps`、`nodeId` 和验证结果。只有返回 `verified=false` 或结构化 partial/unknown 错误时,才进入恢复流程。 --- ## 正文准备(未命中 JSONML 判定时) -正文草稿先在本地临时 Markdown 文件中完成,推荐路径形如 `/tmp/.md`。 +正文草稿先在工作目录内的 Markdown 文件中完成,推荐路径形如 `./drafts/.md`。 准备规则: @@ -320,32 +319,32 @@ dws doc read --node --content-format jsonml --output /tmp/-readba - 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。 - 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。 - Markdown 草稿阶段**不要**写 callout / 分栏 / 附件——这些留到「创建后的精修」用 `doc block insert` 操作(style-guideline §1.3)。 -- **图片素材闭环(硬规则)**:正文需求含图片/截图/图文并茂时,**禁止**在 Markdown 中写 `![](...)` 图片语法(包括真实存在的 alidocs URL)。正确做法:Markdown 只写文本骨架和图片占位说明(如 `📌 此处插入:xxx 产品截图`),创建文档后逐个执行 `dws doc media insert --node --file <本地图片路径>` 插入,最后用 `dws doc block list --node ` 验证图片块存在。图片来源如果是钉盘文件,必须先 `dws drive download --node <图片nodeId> --output /tmp/xxx.png` 下载到本地再 insert。 +- **图片素材闭环(硬规则)**:正文需求含图片/截图/图文并茂时,Markdown 只写文本骨架和图片占位说明;创建后用 `dws doc +media-insert --node --file ./相对路径` 插入,再用 `+media-list` 验证稳定 `resourceId`。禁止正文临时 URL、绝对路径、curl/wget 和本地依赖安装兜底。 ## 创建写入 -优先用 `--content-file` 一次创建并写入: +优先用工作目录相对文件一次创建并写入: ```bash -dws doc create --name "<文档名>" --content-file /tmp/.md --content-format markdown +dws doc +create --name "<文档名>" --content @./drafts/.md --doc-format markdown ``` 创建到指定文件夹: ```bash -dws doc create --name "<文档名>" --content-file /tmp/.md --folder --content-format markdown +dws doc +create --name "<文档名>" --content @./drafts/.md --folder --doc-format markdown ``` 创建到知识库: ```bash -dws doc create --name "<文档名>" --content-file /tmp/.md --workspace --content-format markdown +dws doc +create --name "<文档名>" --content @./drafts/.md --workspace --doc-format markdown ``` 短纯文本才允许直接传 `--content`: ```bash -dws doc create --name "<文档名>" --content "短内容" --content-format markdown +dws doc +create --name "<文档名>" --content "短内容" --doc-format markdown ``` 返回后立即记录: @@ -356,13 +355,9 @@ dws doc create --name "<文档名>" --content "短内容" --content-format markd | `docUrl` | 最终交付给用户的链接;缺失时用 `doc info` 补查 | | `chunksWritten` | 判断是否触发自动分片;大于 1 时重点检查章节顺序 | -## 回读验收 +## 内置回读验收 -创建命令返回成功不等于正文完整。每次创建后都必须回读: - -```bash -dws doc read --node -``` +`+create` 已在同一执行内回读验证,禁止再固定追加一次 `doc read`。检查结构化返回: 验收要点: @@ -382,15 +377,15 @@ DWS 写入管道会自动处理长内容分片。只有出现以下情况才手 补救流程: -1. 用 `doc read` 确认已经写到哪个章节。 -2. 从原始临时 Markdown 中截取缺失部分,写入 `/tmp/-resume.md`。 +1. 用 `+fetch` 的最小 scope 确认已经写到哪个章节。 +2. 从原始 Markdown 中截取缺失部分,写入 `./drafts/-resume.md`。 3. 追加缺失内容: ```bash -dws doc update --node --content-file /tmp/-resume.md --mode append --content-format markdown +dws doc +update --node --command append --content @./drafts/-resume.md --doc-format markdown ``` -4. 再次 `doc read`,确认缺失章节已补齐。 +4. 使用 `+update` 返回的验证结果确认缺失章节已补齐;结果未知时再定点 `+fetch`。 ## 创建后的精修 @@ -400,7 +395,7 @@ dws doc update --node --content-file /tmp/-resume.md --mode appen - 单 block JSONML 精修(首选):`doc block list --node --content-format jsonml --block-id ` 取子树 → `doc block update --node --block-id --content-format jsonml --element '[...]'` 写回(uuid 必须 == --block-id;写入端默认执行 schema validate,详见 [doc-update-workflow.md §4.4](./doc-update-workflow.md)) - 整篇 JSONML 无损:`doc update --content-format jsonml --mode overwrite`(默认直接覆盖,适合一次改多处或改 root sectPr;担心并发覆盖时加 `--revision ` 触发并发检查) -- 插入附件 / 图片:`doc media insert`(无 JSONML 形态,直接走 element) +- 插入附件 / 图片:`+media-insert`,之后用 `+media-list` 验证稳定 `resourceId` - element JSON 次选:`doc block insert` / `doc block update` 不带 `--content-format jsonml` 时按老接口 JSON 解析;仅在 JSONML 不支持某字段时使用 - markdown 兜底:`doc update --mode append`(末尾追加纯文本段落,无富结构需保留时) diff --git a/skills/multi/dingtalk-doc/references/doc/style/doc-style-guideline.md b/skills/multi/dingtalk-doc/references/doc/style/doc-style-guideline.md index 1e00cad5..cc3d0939 100644 --- a/skills/multi/dingtalk-doc/references/doc/style/doc-style-guideline.md +++ b/skills/multi/dingtalk-doc/references/doc/style/doc-style-guideline.md @@ -2,7 +2,7 @@ 本文规定 DWS 创建或编辑钉钉文档时的排版判断方法。核心流程:**确定文档类型 → 选骨架 → 按读者任务选元素 → 按视觉语义统一表达 → 软约束自检**。 -> 写入流程见 [doc-create-workflow.md](./doc-create-workflow.md)。改写老文档见 [doc-update-workflow.md](./doc-update-workflow.md)。callout / 分栏等块的字段以 [doc.md](../../doc.md) 与 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md) 为准。 +> 本文只定义内容结构和视觉规范,不定义命令路由。写入统一使用 `+create/+update/+checkpoint-update`,读取使用 `+fetch`,媒体使用 `+media-*`;原子命令仅用于精确 Schema 支持的专家路径。流程见 [doc-create-workflow.md](./doc-create-workflow.md) 和 [doc-update-workflow.md](./doc-update-workflow.md)。 ## 快速入口 @@ -30,7 +30,7 @@ 3. **Markdown 草稿阶段只用稳定元素**:标题、段落、列表、checklist、表格、代码块;callout / 分栏 / 附件 / 复杂嵌套留到创建后用 `doc block insert` / `doc media insert` 精修 4. **引用块只用于原文**:用户原话、会议摘录、外部材料原文;不许包装作者自己的结论 5. **不编造 URL**:图片、链接、文档 ID 不确定时留 TODO 占位,向用户求证 -6. **写入后必须回读**:见 [doc-create-workflow.md «回读验收»](./doc-create-workflow.md) 与 [doc-update-workflow.md §6](./doc-update-workflow.md) +6. **以 shortcut 内置验证为准**:正常成功不追加整篇回读;partial/unknown 或富结构定点检查才使用最小范围 `+fetch` --- @@ -236,7 +236,7 @@ ~~~~markdown ```bash -dws doc read --node abc123 +dws doc +fetch --node abc123 ``` ~~~~ @@ -307,13 +307,13 @@ dws doc block insert --node --content-format element \ ### 4.9 附件与图片 ```bash -dws doc media insert --node --file ./diagram.png +dws doc +media-insert --node --file ./diagram.png ``` - 插入后在前后用一句话说明它支持哪个结论 - 插入后用 `doc block list` 验证存在 - **禁止**在 Markdown 里编造无法访问的图片 URL -- **禁止**把 `alidocs.dingtalk.com/i/nodes/...`、`alidocs.dingtalk.com/i/document/...` 或任何文档/节点页面 URL 当作图片 src——这些是页面链接,不是图片资源,写入后无法渲染。图片必须先下载到本地,再通过 `dws doc media insert --node --file <本地路径>` 插入 +- **禁止**把 `alidocs.dingtalk.com/i/nodes/...`、`alidocs.dingtalk.com/i/document/...` 或任何文档/节点页面 URL 当作图片 src。图片必须位于工作目录内,再通过 `dws doc +media-insert --node --file ./相对路径` 插入 - 何时主动建议用户提供图见 §6 --- @@ -360,7 +360,7 @@ dws doc media insert --node --file ./diagram.png 1. 关键流程/架构/趋势能图示就图示,不用纯文本承载 2. **禁止**在 Markdown 里编造图片 URL(如 `![](https://example.com/diagram.png)`) 3. 正文里留占位(如 `📌 待补充:架构图`),向用户主动询问能否提供截图 -4. 用户提供后用 `dws doc media insert --node --file ./xxx.png` 插入,并在图前后补一句说明 +4. 用户提供后用 `dws doc +media-insert --node --file ./xxx.png` 插入,并在图前后补一句说明 --- diff --git a/skills/multi/dingtalk-doc/references/doc/style/doc-update-workflow.md b/skills/multi/dingtalk-doc/references/doc/style/doc-update-workflow.md index 88d8c68f..059efe2d 100644 --- a/skills/multi/dingtalk-doc/references/doc/style/doc-update-workflow.md +++ b/skills/multi/dingtalk-doc/references/doc/style/doc-update-workflow.md @@ -1,6 +1,6 @@ # 钉钉文档改写流程 -本文只处理一件事:用户给已有 nodeId 或 alidocs 链接,需要改写、润色、补充章节、转换段落形态时,按本文操作。从零创建新文档见 [doc-create-workflow.md](./doc-create-workflow.md)。 +本文只处理已有文档的结构和排版策略。普通执行入口固定为 `+fetch`、`+update` 或 `+checkpoint-update`;这些 shortcut 统一确认、写入与验证。原子 `doc read/update/block` 只保留给 shortcut 未公开参数的 JSONML 专家路径,使用前必须读取精确 leaf Schema。 ## 适用边界 @@ -64,7 +64,7 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/ - **分块 append 内容截断**:超长文档分片写入时部分片段丢失或顺序错乱 - **编码/通道问题**:特殊终端下 UTF-8 内容传输乱码 -因此 **每次 `doc update` 后必须回读校验**(见 §6)。整篇 overwrite 大文档前还要先向用户提示风险并等待确认。 +因此写入必须通过带确认与验证的 shortcut。`+update/+checkpoint-update` 返回的验证结果是主证据;禁止无条件追加一次原子 `doc read`,只有 partial/unknown 或需要定点结构检查时才用 `+fetch`。 --- @@ -75,9 +75,9 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/ | 用户需求 | 读取方式 | 定位方法 | |----------|----------|----------| | 单块精修(首选)| `doc block list --node --content-format jsonml` → 拿 uuid → `doc block list --node --content-format jsonml --block-id ` 读子树 | 节点结构见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md) | -| 多处保真改写 / 改 root sectPr | `doc read --node --content-format jsonml --output /tmp/doc.json` | 解析 JSON,按 schema 操作;担心并发覆盖时记下 `revision` 供 update 透传 | -| 整篇按新骨架重写(纯文本场景)| `doc read --node `(markdown 输出)| 直接处理 markdown 全文,定位用 `grep -n "<章节关键词>"` | -| 末尾追加纯文本章节 | 不必读全文,直接 §4.2 append | 必要时 `doc read` 看末尾衔接 | +| 多处保真改写 / 改 root sectPr | `+fetch --node --detail full` | 解析 JSONML;担心并发覆盖时记下 revision | +| 整篇按新骨架重写(纯文本场景)| `+fetch --node ` | 直接处理 markdown 全文 | +| 末尾追加纯文本章节 | 不必读全文,直接 §4.2 append | 必要时 `+fetch --scope section` 看末尾衔接 | | 老接口快速找 BLOCK_ID(无需 jsonml 时)| `doc block list --node ` | 默认输出 JSON;用 `grep -B2 -A2 "<关键词>"` 在 children 里定位(结构 `{"blocks":[{...,"children":[...]}]}`,jq 需 `..\|.text? // empty` 递归查文本) | 读取后,把改写计划告诉用户(要改哪几节、走 JSONML 还是 markdown、改成什么形态),等用户确认后再写。 @@ -113,13 +113,13 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/ > 适用范围:**纯文本**改写一段或替换某节内容。若该段含 callout / 分栏 / 颜色 / @人 / 附件 / 嵌套结构,**改走 §4.4 路径 B**——markdown 还原会丢失这些元素。 ```bash -dws doc update --node --content "<新内容>" --mode overwrite --content-format markdown +dws doc +update --node --command overwrite --content "<新内容>" --doc-format markdown ``` 或写入临时文件: ```bash -dws doc update --node --content-file /tmp/-section.md --mode overwrite --content-format markdown +dws doc +update --node --command overwrite --content @./drafts/-section.md --doc-format markdown ``` > ⚠️ **overwrite 须用户确认**——尤其是整篇文档 overwrite。 @@ -129,7 +129,7 @@ dws doc update --node --content-file /tmp/-section.md --mode over > 适用范围:在文档末尾加 X 章 / 补充纯文本段落。追加内容若含 callout / 分栏等富结构,先用本节 append 一个占位段落,再用 §4.4 路径 B 的 `block insert --content-format jsonml` 替换/精修。 ```bash -dws doc update --node --content-file /tmp/-append.md --mode append --content-format markdown +dws doc +update --node --command append --content @./drafts/-append.md --doc-format markdown ``` 按 [doc-style-guideline.md](./doc-style-guideline.md) 的元素选择规则准备追加内容。 @@ -187,16 +187,16 @@ dws doc block insert --node --ref-block --where after --co ```bash # 1. 读出完整 JSONML 结构(输出含 revision,普通改写场景下不需要) -dws doc read --node --content-format jsonml --output /tmp/doc.json +dws doc +fetch --node --detail full --format json # 2. 解析 JSON,修改 jsonml 数组中的目标节点 # 节点结构见 doc-jsonml-schema.md,可复制范例见 doc-jsonml-cookbook.md -# 3. 写回临时文件 /tmp/doc_modified.json,格式 {"jsonml": [...]} +# 3. 写回工作目录内相对文件 ./drafts/doc_modified.json,格式 {"jsonml": [...]} # 4. 提交修改(默认直接覆盖,不做并发检查) -dws doc update --node --content-file /tmp/doc_modified.json \ - --content-format jsonml --mode overwrite +dws doc +update --node --command overwrite --content @./drafts/doc_modified.json \ + --doc-format jsonml ``` > **并发安全模式(担心被并发覆盖时使用)**:如果担心多 agent 同时改这篇文档,可以把第 1 步 read 返回的 `revision` 通过 `--revision ` 透传给第 4 步:服务端会做并发检查,版本不一致返回 `VersionConflict`,此时回到第 1 步重读重写即可。普通单 agent 改写场景默认不传 `--revision`。 @@ -249,10 +249,10 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"}," --content-file /tmp/-full.md --mode overwrite --content-format markdown +dws doc +checkpoint-update --node --mode overwrite --content @./drafts/-full.md ``` -**写入后必须回读**(§6)。如果发现旧内容残留,按 §6 的修复路径处理。 +读取 `+checkpoint-update` 的 checkpoint、write、verify 步骤;只有 partial/unknown 时才按 §6 恢复。 ### 4.6 超长内容追加(分块 append) @@ -281,11 +281,7 @@ update 场景下的额外约束: ## 六、回读验收 -**所有 `doc update` 完成后都必须回读**,无论 overwrite 还是 append: - -```bash -dws doc read --node --content-format jsonml -``` +`+update/+checkpoint-update` 已统一确认与验证。正常成功禁止额外整篇读取;partial/unknown 或确需检查富结构时,使用最小范围 `+fetch`。 校验要点: diff --git a/skills/multi/dingtalk-doc/references/intent-guide.md b/skills/multi/dingtalk-doc/references/intent-guide.md index 95684503..aa324e66 100644 --- a/skills/multi/dingtalk-doc/references/intent-guide.md +++ b/skills/multi/dingtalk-doc/references/intent-guide.md @@ -16,4 +16,4 @@ | "搜一下智能化方案/最近 OKR 相关邮件/最近发版相关消息" | 搜企业知识内容 | `aisearch enterprise` | `doc search` / `mail search` / `chat message search` | 跨文档、消息、日程、听记、邮件等企业内容语义检索走 enterprise;具体 `queries/types/time-range` 抽槽见 `aisearch.md` | | "我发给某人的消息/邮件/文档/今天我干了什么" | 搜行为记录 | `aisearch behavior` | `chat` / `mail` / `doc` / `report` | 关注“谁对什么做过什么”,走 behavior;具体 `behavior-type/direction/chat-scope` 抽槽见 `aisearch.md` | | "把这段文字翻译成英文/translate this" | 通用文本翻译 | `chat text translate` | `doc` / `aisearch` | 纯文本翻译,不是文档编辑或语义搜索 | -| "帮我把这个文档翻译成日文" | 文档内容翻译 | 先 `doc get` 再 `chat text translate` | `chat text translate` 直接传文件 | translate 仅支持纯文本,需先提取文档内容 | +| "帮我把这个文档翻译成日文" | 文档内容翻译 | 先 `dws doc +fetch --node` 再 `chat text translate` | `chat text translate` 直接传文件 | translate 仅支持纯文本,需先提取文档内容;普通读取统一走 shortcut | diff --git a/skills/multi/dingtalk-doc/references/lite-recipes.md b/skills/multi/dingtalk-doc/references/lite-recipes.md index a502c794..9679e217 100644 --- a/skills/multi/dingtalk-doc/references/lite-recipes.md +++ b/skills/multi/dingtalk-doc/references/lite-recipes.md @@ -6,40 +6,27 @@ ### query-doc -1. 用户已提供 URL / `nodeId` 时直接使用原值;否则全局搜索 `dws drive search --query "<关键词>" --format json` → `nodeId`(聚合钉盘+文档空间),或在指定空间执行 `dws wiki node search --workspace --query "<关键词>" --format json` → `nodeId`。 -2. 对选中的候选执行 `dws drive info --node --format json`,读取真实 `extension`。 -3. 按类型分流:`adoc` → `dws doc read --node --format json`(大文档只抽章节);`md` → 切 `dingtalk-misc` 的 `references/markdown.md`,用 `dws markdown fetch` 读取原文;`axls` → 切 `dingtalk-misc` 的 `references/sheet.md`;`able` → 切 `dingtalk-aitable`;xlsx/xls/xlsm/csv 和其他普通文件 → 切 `dingtalk-drive`。非 `adoc` 禁止执行 `doc read`。 +1. 用户已提供 URL / `nodeId` 时执行 `dws doc +fetch --node <目标> --format json`。 +2. 只有标题时执行 `dws doc +fetch --query "<唯一标题>" --format json`,让 Runtime 解析候选与类型。 +3. 零命中、多候选、分页不完整或非 `adoc` 时停止并按返回建议切换产品;禁止无界穷举搜索。 ### list-folder-docs -`drive list --workspace ` 或 `wiki node list --workspace ` +`dws doc +list --workspace --format json`;知识库层级管理切 `dingtalk-wiki`。 ### import-file -将本地文件导入为钉钉在线文档。**一条命令完成上传+格式转换+创建**,无需先读取文件内容。 +将本地文件导入为钉钉在线对象。**一条命令完成上传、转换和创建**,无需先读取文件内容。 ```bash -dws doc import --file ./report.docx --format json +dws doc +import --file ./report.docx --folder --format json ``` -1. 确认文件路径(用户提供的本地文件路径) -2. 执行:`dws doc import --file <文件路径> --format json`(可选 `--folder <文件夹ID>` / `--workspace <知识库ID>` / `--name "文档名"`) -3. 从返回中提取 `documentUrl`,告知用户导入完成并提供链接 -4. 超时或中断时 CLI 返回 `taskId`,用 `dws doc import get --task-id --format json` 手动查询 +1. 确认文件是工作目录内的相对路径。 +2. 执行 `+import`,可选 `--folder`、`--workspace` 和 `--name`。 +3. 从返回中提取 `documentUrl`;若 `fallback=upload`、`converted=false`,明确说明结果是原文件对象。 +4. `partial_success/unknown` 时按返回的 `taskId/steps` 恢复,不得重跑整个导入。 -**`--folder` 参数传值规则**: -- 首选路径:用户提供 alidocs URL 时,直接将完整 URL 传入 `--folder`,无需先调 `drive info` -- 预检路径:若需确认 URL 指向的是文件夹,可先调 `dws drive info --node `: - - `nodeType == "folder"` → 使用 `nodeId` 或原始 URL 作为 `--folder` 值 - - `nodeType` 不是 folder → 提示用户:该链接指向的不是文件夹 -- 禁止:不得使用 `drive info` 返回的 `folderId` 字段作为 `--folder` 的值(`folderId` 是父文件夹 ID,非当前节点 ID) - -格式与文档类型映射: -- `.docx` / `.doc` → 文字文档(DOC) -- `.xlsx` / `.xls` → 电子表格(SHEET) -- `.xmind` / `.mark` → 脑图(MIND) -- `.md` / `.txt` → 文字文档(DOC) - -> **禁止先 Read 文件再 `doc create` + `doc update`**。`doc import` 是服务端格式转换,客户端无需解析文件内容。 -> 详见 [./doc/doc-import.md](./doc/doc-import.md)。 +`--folder` 首选用户提供的 alidocs URL 或真实 `nodeId`;不得使用 `drive info` 返回的父级 `folderId`。 +> 禁止先 Read 文件再 `doc create` + `doc update`。详见 [./doc/doc-import.md](./doc/doc-import.md)。 diff --git a/skills/multi/dingtalk-doc/scripts/doc_create_and_write.py b/skills/multi/dingtalk-doc/scripts/doc_create_and_write.py deleted file mode 100644 index fbf52f8d..00000000 --- a/skills/multi/dingtalk-doc/scripts/doc_create_and_write.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -""" -在指定目录创建文档并写入 Markdown 内容(一键完成) - -用法: - python doc_create_and_write.py \ - --name "项目周报" \ - --content "# 本周总结\n\n## 完成事项\n- 任务A" - - python doc_create_and_write.py \ - --name "会议纪要" \ - --content-file notes.md - - python doc_create_and_write.py \ - --name "知识库文档" --content "# 内容" --folder FOLDER_ID - - python doc_create_and_write.py --name "test" --content "hello" --dry-run -""" - -import sys -import json -import time -import subprocess -import argparse -from pathlib import Path -from typing import List, Any, Optional - - -def run_dws( - args: List[str], dry_run: bool = False, -) -> Optional[Any]: - cmd = ['dws'] + args - if dry_run: - print(f"[dry-run] {' '.join(cmd)}") - return {'dry_run': True} - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=60 - ) - if result.returncode != 0: - print(f" ✗ 错误:{result.stderr.strip()}") - return None - return json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, - FileNotFoundError) as e: - print(f" ✗ 错误:{e}") - return None - - -def run_dws_with_retry( - args: List[str], - dry_run: bool = False, - max_retries: int = 3, - retry_delay: float = 1.0, -) -> Optional[Any]: - """带重试机制的 dws 命令执行""" - last_error = None - for attempt in range(1, max_retries + 1): - result = run_dws(args, dry_run=dry_run) - if result is not None: - return result - if attempt < max_retries: - print(f" ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...") - time.sleep(retry_delay) - retry_delay *= 1.5 # 指数退避 - return None - - -def main(): - parser = argparse.ArgumentParser( - description='创建文档并写入内容' - ) - parser.add_argument('--name', required=True, help='文档名称') - parser.add_argument('--content', default='', help='Markdown 内容') - parser.add_argument('--content-file', default='', help='内容文件') - parser.add_argument('--folder', default='', help='目标文件夹 ID 或 URL') - parser.add_argument('--workspace', default='', help='目标知识库 ID') - parser.add_argument( - '--mode', default='append', choices=['overwrite', 'append'], - help='写入模式: overwrite=覆盖, append=追加 (默认 append)', - ) - parser.add_argument( - '--max-retries', type=int, default=3, - help='每块写入失败时的最大重试次数 (默认 3)', - ) - parser.add_argument('--dry-run', action='store_true') - args = parser.parse_args() - - content = args.content - if args.content_file: - p = Path(args.content_file) - if not p.exists(): - print(f"错误:文件不存在: {p}") - sys.exit(1) - content = p.read_text(encoding='utf-8') - if not content: - print('错误:需要 --content 或 --content-file') - sys.exit(1) - chunk_size = 10000 - - create_args = ['doc', 'create', '--name', args.name, '--format', 'json'] - if args.folder: - create_args.extend(['--folder', args.folder]) - if args.workspace: - create_args.extend(['--workspace', args.workspace]) - - print(f'\n📝 创建文档: {args.name}') - create_data = run_dws(create_args, dry_run=args.dry_run) - - node_id = None - if not args.dry_run: - if not create_data: - sys.exit(1) - node_id = (create_data.get('nodeId') - or create_data.get('dentryUuid') - or create_data.get('id', '')) - print(f" ✓ 文档已创建 (ID: {node_id})") - - if len(content) <= chunk_size: - mode_label = '追加' if args.mode == 'append' else '覆盖' - print(f'\n✍️ 写入内容 (模式: {mode_label}, {len(content)} 字符)...') - write_data = run_dws([ - 'doc', 'update', - '--node', node_id or '', - '--content', content, - '--mode', args.mode, - '--format', 'json', - ], dry_run=args.dry_run) - if write_data: - print(f" ✓ 内容已写入 ({len(content)} 字符)") - else: - chunks = [] - pos = 0 - while pos < len(content): - end = min(pos + chunk_size, len(content)) - if end < len(content): - newline_pos = content.rfind('\n', pos, end) - if newline_pos > pos: - end = newline_pos + 1 - chunks.append(content[pos:end]) - pos = end - - total_chunks = len(chunks) - print(f'\n✍️ 内容较长 ({len(content)} 字符), 分 {total_chunks} 块写入...') - - success_chunks = 0 - for idx, chunk in enumerate(chunks): - chunk_mode = args.mode if idx == 0 else 'append' - write_data = run_dws_with_retry( - [ - 'doc', 'update', - '--node', node_id or '', - '--content', chunk, - '--mode', chunk_mode, - '--format', 'json', - ], - dry_run=args.dry_run, - max_retries=args.max_retries, - ) - if write_data: - print(f" ✓ 块 {idx + 1}/{total_chunks} 已写入 ({len(chunk)} 字符)") - success_chunks += 1 - elif not args.dry_run: - # 写入失败,报告部分写入状态 - print(f"\n❌ 块 {idx + 1}/{total_chunks} 写入失败(已重试 {args.max_retries} 次)") - print(f"\n⚠️ 文档处于部分写入状态:") - print(f" - 文档 ID: {node_id}") - print(f" - 已写入: {success_chunks}/{total_chunks} 块") - print(f" - 失败位置: 第 {idx + 1} 块") - if args.mode == 'overwrite': - print(f" - 模式: 覆盖模式,文档可能包含不完整内容") - print(f" - 建议: 手动检查文档内容,或删除后重新创建") - else: - print(f" - 模式: 追加模式,已写入内容已保存") - print(f" - 建议: 可手动补充剩余内容,或重新运行脚本") - sys.exit(1) - print('\n✅ 完成!') - - -if __name__ == '__main__': - main() diff --git a/test/unit/skill_docs_policy_test.go b/test/unit/skill_docs_policy_test.go index 93e0ee71..faefe8ca 100644 --- a/test/unit/skill_docs_policy_test.go +++ b/test/unit/skill_docs_policy_test.go @@ -283,6 +283,117 @@ func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) { } } +func TestCrossPlatformCoverageDocSkillPinsExactVersionRoutesAndHelpBudget(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) + paths := []string{ + filepath.Join(root, "skills", "multi", "dingtalk-doc", "SKILL.md"), + filepath.Join(root, "skills", "multi", "dingtalk-doc", "references", "doc.md"), + filepath.Join(root, "skills", "mono", "references", "products", "doc.md"), + } + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + text := string(content) + for _, required := range []string{ + "dws doc +version-save --node", + "dws doc +version-list --node", + "dws doc +version-revert --node", + } { + if !strings.Contains(text, required) { + t.Errorf("%s missing exact version route %q", path, required) + } + } + if strings.Contains(text, "dws doc +history-") { + t.Errorf("%s recommends a history compatibility route", path) + } + } + + rootSkill, err := os.ReadFile(paths[0]) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"Help 不参与选路", "unknown flag", "只查一次 shortcut 清单", "禁止试探后缀"} { + if !strings.Contains(string(rootSkill), required) { + t.Errorf("Doc root Skill missing Help budget rule %q", required) + } + } +} + +func TestCrossPlatformCoverageDocSkillPinsTemplateSearchRequiredQuery(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + path := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..", "skills", "multi", "dingtalk-doc", "SKILL.md")) + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(content) + for _, route := range []string{ + "dws doc +template-list [--source MY\\|PUBLIC]", + "dws doc +template-search --query <名称或关键词>", + "dws doc +create-from-template --template-id <唯一ID>", + } { + if !strings.Contains(text, route) { + t.Errorf("Doc Golden Route does not publish mutually exclusive template route %q", route) + } + } + if strings.Contains(text, "`dws doc +template-search` →") { + t.Fatal("Doc Golden Route still recommends template-search without --query") + } + for _, required := range []string{ + "准备 Help 时,本轮仅查一次", + "--fields use_when,avoid_when,parameters,constraints,confirmation", + "禁用产品级/`--all`", + "Help 不参与选路", + "禁止靠失败探测门禁", + "已有或临时文件先暂存到 cwd", + } { + if !strings.Contains(text, required) { + t.Errorf("Doc root Skill missing bounded Schema/input protocol %q", required) + } + } +} + +func TestCrossPlatformCoverageDocSkillReferenceLoadingBudget(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) + paths := []string{ + filepath.Join(root, "skills", "multi", "dingtalk-doc", "SKILL.md"), + filepath.Join(root, "skills", "mono", "references", "products", "doc.md"), + } + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + text := string(content) + for _, required := range []string{ + "禁止读取 reference", + "最多读取一个", + "复杂 JSONML", + "append/overwrite", + } { + if !strings.Contains(text, required) { + t.Errorf("%s missing bounded Reference rule %q", path, required) + } + } + if strings.Contains(text, "只在任务命中时读取一个精确 reference") { + t.Errorf("%s restores task-match-driven Reference loading", path) + } + } +} + func hasAny(s string, needles []string) bool { for _, needle := range needles { if strings.Contains(s, needle) {