From a30ff648f40f00f267aa73bd876d899f696dabe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=91=9E=E8=BE=BE?= Date: Thu, 13 Aug 2026 20:17:30 +0800 Subject: [PATCH] Align AI Table skill references and scripts --- skills/multi/dingtalk-aitable/SKILL.md | 38 ++-- .../references/06-data-analytics.md | 2 +- .../references/aitable-record-ops.md | 4 +- .../dingtalk-aitable/references/aitable.md | 6 +- .../references/aitable/aitable-attachment.md | 2 +- .../aitable/aitable-dashboard-chart.md | 6 +- .../aitable/aitable-export-import.md | 4 +- .../aitable/aitable-record-create.md | 2 +- .../aitable/aitable-script-recipes.md | 24 +++ .../references/field-rules.md | 4 +- .../scripts/aitable_export_via_task.py | 3 +- .../dingtalk-aitable/scripts/aitable_ops.py | 165 ++++++++++++++++++ test/scripts/aitable_skill_scripts_test.py | 76 ++++++++ 13 files changed, 299 insertions(+), 37 deletions(-) create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-script-recipes.md create mode 100644 skills/multi/dingtalk-aitable/scripts/aitable_ops.py diff --git a/skills/multi/dingtalk-aitable/SKILL.md b/skills/multi/dingtalk-aitable/SKILL.md index ccca4fbc..a2436d99 100644 --- a/skills/multi/dingtalk-aitable/SKILL.md +++ b/skills/multi/dingtalk-aitable/SKILL.md @@ -20,10 +20,11 @@ metadata: 1. 命中下方高频意图时直接使用精确骨架,不先查 Help 或产品级 Schema。 2. 路由优先级固定为:精确 recipe / 可运行脚本 > 匹配的公开 Shortcut > 原子命令。命令已确定且参数清楚时直接执行。 3. 参数、约束或安全语义不确定时只读 leaf Schema:`dws schema --cli-path "aitable " --format json`;只有当前 Cobra flag 不确定时才读对应 `--help`。 -4. 复杂字段、筛选、导入导出、视图、权限或工作流任务,按“低频能力与 Reference”只加载相关文件,不预读整个 `references/aitable/`。 +4. 复杂字段、筛选、视图、权限或工作流任务按“低频能力与 Reference”只加载相关文件。所有下表的 `references/aitable/...` 都是相对本 Skill 根目录的完整精确路径;不得省略中间的 `aitable/`。dashboard/chart、导入、导出、批量字段和附件统一只读 `references/aitable/aitable-script-recipes.md`,再运行 `scripts/aitable_ops.py`;不要预读整个 reference 目录或任何脚本源码。 5. 现有骨架和 reference 都无法定位能力时,才用 Runtime Shortcut Catalog 做最后发现;不得猜 `cli_path` 或 flag。 6. Schema、Help、reference 与实际返回冲突时采用更安全的解释并报告契约漂移;`confirmation=user_required` 时先确认,再添加 `--yes`。 -7. 用户已给足名称、字段、数据和目标时,直接按依赖链完成全部步骤;不要调用 todo 工具、分步汇报或追问已明确的信息。中间返回只用于提取下一步 ID 和判断失败,完成所有请求后再统一回读并答复。 +7. 用户已给足名称、字段、数据和目标时,直接按依赖链完成全部步骤;不要调用 todo 工具、分步汇报或追问已明确的信息。中间返回只用于提取下一步 ID 和判断失败,完成所有请求后再统一回读并答复。bundled script 参数明确时直接运行;只有参数不明确时执行统一入口的操作级 `--help`,只有契约失败、环境异常或用户要求修改脚本时才读取源码。 +8. 用户要求新建 Base 但未指定 Base 名时,根据业务目标生成简短描述性名称(例如仪表盘任务用“数据看板”)并继续;不要仅为可回退的容器名称追问。Base 只接受 Base flags,不得把 table `--fields` 传给 `base create`。 ## Shortcut 发现(按需) @@ -60,19 +61,17 @@ metadata: | 新增记录 | `dws aitable record create --base-id --table-id --records '[{"cells":{"":<值>}}]' --format json` | 单次最多 100;取 `data.newRecordIds[]` 后立即按 ID 回读 | | 更新记录 | `dws aitable record update --base-id --table-id --records '[{"recordId":"","cells":{"":<值>}}]' --format json` | 先 query 拿 recordId;只传需改字段;取 `data.recordIds[]` 后回读 | | 删除记录 | 先 `dws aitable +record-query ...` 定位,再 `dws aitable record delete --base-id --table-id --record-ids ` | 展示目标与影响,得到明确确认后才加 `--yes` | -| 创建 Base / Table | `dws aitable base create --name "<名>"` / `dws aitable table create --base-id --name "<名>" --fields '[...]'` | 使用创建返回的真实 ID;系统改名/加后缀时不得继续猜原名 | -| 创建仪表盘 / 常用图表 | `python3 <本 Skill 绝对目录>/scripts/create_dashboard_chart.py "<仪表盘名>" [--chart-specs ]` | 这是 dashboard/chart 创建的唯一首选 recipe;脚本创建、串联真实 ID、最终回读并输出可评分 ledger。图表 JSON 参数见对应 reference | +| 创建 Base / Table | `dws aitable base create --name "<名>" --format json` → `dws aitable base get --base-id --format json`;`dws aitable table create --base-id --name "<名>" --fields '[...]' --format json` → `dws aitable +table-get --base-id --table-ids --format json` | 使用创建返回的真实 ID 立即回读;创建字段时回读 `fields[]` 的名称、类型与 config;系统改名/加后缀时不得继续猜原名 | +| 创建仪表盘 / 常用图表 | `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard "<仪表盘名>" [--chart-specs ]` | 唯一首选;完整参数与 ledger 契约只读 `references/aitable/aitable-script-recipes.md` | | 复制视图 | `dws aitable view duplicate --base-id --table-id --view-id <源viewId> --new-name "<新名称>" --format json` | 源 viewId 来自当前表的真实返回;不要复制数据表或创建仪表盘替代 | -| 批量追加 CSV / JSON 到已有表 | `python3 scripts/import_records.py [batch_size]` | CSV 表头必须是 fieldId;脚本返回不完整 ledger 时不得宣称全成功 | -| 文件导入为新数据表 | `python3 scripts/aitable_import_via_task.py ` | 与“追加已有 table”不同;走 prepare → PUT → import task | -| 批量创建字段 | `python3 scripts/bulk_add_fields.py fields.json` | 单次最多 15;逐项检查成功/失败结果 | -| 导出 Base / Table / View | `python3 scripts/aitable_export_via_task.py --scope all\|table\|view [...]` | 保存路径、覆盖与异步未完成状态必须显式处理 | -| 上传记录附件 | `python3 scripts/upload_attachment.py ` | 返回 `fileToken` 后仍需按字段格式写入记录并回读 | +| 导入 / 导出 / 批量字段 / 附件 | 先读 `references/aitable/aitable-script-recipes.md`,再运行其中唯一的 `scripts/aitable_ops.py ...` | 不直接选择底层脚本;不读源码;保留统一入口返回的退出状态与 ledger | + +导出完成以统一 ledger 中的真实 `taskId`、`polledTimes`、`status=success`、`fileSize>0` 和 `savedPath` 为证据;不要自己重新轮询、读脚本源码,也不要只用 `ls` 替代异步任务证据。字段类型使用 Runtime camelCase,例如 `singleSelect` / `multipleSelect`;select 写值优先传选项名字符串或 `{id,name}`,不传 `{value:...}`。 ## 记录读写不变量 -- `record create/update` 前必须获取目标字段的 `fieldId`、`type` 与 `config`;`filterUp`、`lookup` 等只读字段不可写。完整格式只在需要时读 [aitable-cell-value.md](references/aitable/aitable-cell-value.md)。 -- 筛选和排序字段使用 `fieldId`;`--filters` 最外层是 `and|or + operands`,`--sort` 使用 `direction: asc|desc`。日期和跨表字段规则按需读 [aitable-filter-sort.md](references/aitable/aitable-filter-sort.md)。 +- `record create/update` 前必须获取目标字段的 `fieldId`、`type` 与 `config`;`filterUp`、`lookup` 等只读字段不可写。完整格式只在需要时读精确路径 `references/aitable/aitable-cell-value.md`。 +- 筛选和排序字段使用 `fieldId`;`--filters` 最外层是 `and|or + operands`,`--sort` 使用 `direction: asc|desc`。日期和跨表字段规则按需读精确路径 `references/aitable/aitable-filter-sort.md`。 - `record query --all` 仍受 `--page-limit` 约束;分页中断或局部富化失败时保留已有结果,输出 completeness 与逐项失败 ledger,不把部分结果描述为全量。 - 创建、更新、导入、批量建字段等写操作必须检查业务 `status`、逐项结果与返回 ID;普通写入按用户明确要求执行后回读,不能只凭退出码宣称成功。 - 长 JSON 使用 `--records-file` / 任务文件;不得为绕过字段错误而静默丢列、改类型或删除失败项。 @@ -81,19 +80,20 @@ metadata: | 场景 | 按需读取 | |---|---| -| 完整命令索引、对象 URL 与一级路由 | [aitable.md](references/aitable.md) | -| 记录 query/create/update/delete/upsert/history/share | 对应 `references/aitable/aitable-record-*.md` | -| 字段创建、字段 config、cellValue、公式与跨表引用 | [aitable-field.md](references/aitable/aitable-field.md)、[aitable-field-properties.md](references/aitable/aitable-field-properties.md)、[aitable-cell-value.md](references/aitable/aitable-cell-value.md)、[aitable-formula-guide.md](references/aitable/aitable-formula-guide.md) | -| 筛选、排序、统计、全量分析 | [aitable-filter-sort.md](references/aitable/aitable-filter-sort.md)、[aitable-data-analysis-sop.md](references/aitable/aitable-data-analysis-sop.md) | -| 导入导出、附件 | [aitable-export-import.md](references/aitable/aitable-export-import.md)、[aitable-attachment.md](references/aitable/aitable-attachment.md) | -| 视图、表单、仪表盘与图表 | [aitable-view-config.md](references/aitable/aitable-view-config.md)、[aitable-view-extras.md](references/aitable/aitable-view-extras.md)、[aitable-form.md](references/aitable/aitable-form.md)、[aitable-dashboard-chart.md](references/aitable/aitable-dashboard-chart.md) | -| 高级权限、自动化工作流、导航节点 | [aitable-advperm.md](references/aitable/aitable-advperm.md)、[aitable-workflow.md](references/aitable/aitable-workflow.md)、[aitable.md](references/aitable.md) 的 section 路由 | +| 完整命令索引、对象 URL 与一级路由 | `references/aitable.md` | +| 记录 query/create/update/delete | `references/aitable/aitable-record-query.md`、`references/aitable/aitable-record-create.md`、`references/aitable/aitable-record-update.md`、`references/aitable/aitable-record-delete.md` 中只读与当前动词一致的一份 | +| 记录 upsert/history/share | `references/aitable/aitable-record-upsert.md`、`references/aitable/aitable-record-history.md`、`references/aitable/aitable-record-share.md` 中只读与当前动词一致的一份 | +| 字段创建、字段 config、cellValue、公式与跨表引用 | `references/aitable/aitable-field.md`、`references/aitable/aitable-field-properties.md`、`references/aitable/aitable-cell-value.md`、`references/aitable/aitable-formula-guide.md` | +| 筛选、排序、统计、全量分析 | `references/aitable/aitable-filter-sort.md`、`references/aitable/aitable-data-analysis-sop.md` | +| dashboard/chart、导入导出、批量字段、附件脚本 | `references/aitable/aitable-script-recipes.md`(精确路径;只读这一份脚本契约) | +| 视图、表单及高级 dashboard/chart 原子回退 | `references/aitable/aitable-view-config.md`、`references/aitable/aitable-view-extras.md`、`references/aitable/aitable-form.md`、`references/aitable/aitable-dashboard-chart.md` | +| 高级权限、自动化工作流、导航节点 | `references/aitable/aitable-advperm.md`、`references/aitable/aitable-workflow.md`、`references/aitable.md` 的 section 路由 | ## 错误恢复 - 路径或 flag 错误:按既定的 leaf Schema → leaf Help 顺序校正一次;仍失败则停止,不连续尝试猜测别名。 - 命令非零、输出非 JSON、业务 `status != success`、必需 ID 缺失、批处理部分失败均视为失败;保留成功项与 ledger,禁止吞错。 -- 同名歧义、权限不足、资源不存在、字段类型漂移、分页无法推进或 Schema/Help 冲突时停止并报告。具体恢复动作按需读 [aitable-error-recovery.md](references/aitable/aitable-error-recovery.md)。 +- 同名歧义、权限不足、资源不存在、字段类型漂移、分页无法推进或 Schema/Help 冲突时停止并报告。具体恢复动作按需读精确路径 `references/aitable/aitable-error-recovery.md`。 - 每次重试都从最新实际输出重新提取下游 ID;删除和其他 `confirmation=user_required` 操作不得自动重试或静默确认。 ## 跨产品协作 diff --git a/skills/multi/dingtalk-aitable/references/06-data-analytics.md b/skills/multi/dingtalk-aitable/references/06-data-analytics.md index 41e8d5bf..0b11614a 100644 --- a/skills/multi/dingtalk-aitable/references/06-data-analytics.md +++ b/skills/multi/dingtalk-aitable/references/06-data-analytics.md @@ -6,6 +6,6 @@ |--------|-------------------| | read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`
2. `aitable field get --base-id --table-id ` → 取 `fieldId`
3. `aitable record query --base-id --table-id ` → 取记录(分页)
  需要筛选时 `--filters` 格式见 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md),根节点必须是 `{"operator":"and\|or","operands":[...]}`
4. 总结数据 | | generate-data-report | 1. 同 read-aitable 步骤 1-3
2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 补充背景
3. `doc create --name "<报告名>" --content "<分析报告>"` | -| create-aitable-record | **批量导入优先**:`python scripts/import_records.py data.csv\|data.json [batch_size]`(自动分批创建)
单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`
2. `aitable field get --base-id --table-id ` → 取 `fieldId` 与类型
3. `aitable record create --base-id --table-id --records '[{"cells":{"":"值"}}]'` | +| create-aitable-record | **批量导入优先**:`python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records data.csv\|data.json [--batch-size N]`
单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`
2. `aitable field get --base-id --table-id ` → 取 `fieldId` 与类型
3. `aitable record create --base-id --table-id --records '[{"cells":{"":"值"}}]'` | | update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`
2. `aitable record query --base-id --table-id ` → 取 `recordId`,**先展示让用户确认**
3. `aitable record update --base-id --table-id --records '[{"recordId":"","cells":{...}}]'` | | search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`
2. 用户选定
3. `aitable base create --name "<表格名>" --template-id ` → 取 `baseId` | diff --git a/skills/multi/dingtalk-aitable/references/aitable-record-ops.md b/skills/multi/dingtalk-aitable/references/aitable-record-ops.md index 48bfab14..3b2c4167 100644 --- a/skills/multi/dingtalk-aitable/references/aitable-record-ops.md +++ b/skills/multi/dingtalk-aitable/references/aitable-record-ops.md @@ -102,11 +102,11 @@ dws aitable record delete --base-id --table-id \ > **不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。 -使用 `upload_attachment.py` 脚本(内部自动完成 prepare + PUT to OSS),**2 步**完成: +使用统一 `aitable_ops.py upload-attachment` 入口(内部委派 prepare + PUT to OSS),**2 步**完成: ```bash # 步骤 1: 一键上传文件 -python3 scripts/upload_attachment.py /path/to/report.pdf +python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment /path/to/report.pdf # 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 } # 步骤 2: 在 record create/update 中使用 fileToken 写入附件字段 diff --git a/skills/multi/dingtalk-aitable/references/aitable.md b/skills/multi/dingtalk-aitable/references/aitable.md index b4e772f6..69d7f49f 100644 --- a/skills/multi/dingtalk-aitable/references/aitable.md +++ b/skills/multi/dingtalk-aitable/references/aitable.md @@ -481,11 +481,7 @@ dws aitable record create --base-id --table-id \ | 脚本 | 场景 | |------|------| -| [bulk_add_fields.py](../scripts/bulk_add_fields.py) | 批量添加字段 | -| [import_records.py](../scripts/import_records.py) | 从 JSON/CSV 批量导入记录 | -| [aitable_import_via_task.py](../scripts/aitable_import_via_task.py) | 导入 CSV/XLS/XLSX 并新建数据表(prepare + PUT + import) | -| [aitable_export_via_task.py](../scripts/aitable_export_via_task.py) | 文件导出(export_data 轮询 + 下载) | -| [upload_attachment.py](../scripts/upload_attachment.py) | 上传附件到 AI 表格记录 | +| `scripts/aitable_ops.py` | dashboard/chart、导入、导出、批量字段与附件的唯一稳定入口;参数见 `references/aitable/aitable-script-recipes.md` | ## 相关产品 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-attachment.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-attachment.md index 46e0e148..cd1ed776 100644 --- a/skills/multi/dingtalk-aitable/references/aitable/aitable-attachment.md +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-attachment.md @@ -23,7 +23,7 @@ Flags: ```bash # 步骤 1: 使用脚本一键上传(内部自动完成 prepare + PUT) -python3 scripts/upload_attachment.py /path/to/report.pdf +python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment /path/to/report.pdf # 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 } # 步骤 2: 在 record create/update 中使用 fileToken 写入 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md index dd4e8d3b..7f7f77d5 100644 --- a/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md @@ -4,14 +4,14 @@ ```bash # 仅建仪表盘 -python3 <本 Skill 绝对目录>/scripts/create_dashboard_chart.py "<仪表盘名>" +python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard "<仪表盘名>" # 建仪表盘和常用图表 -python3 <本 Skill 绝对目录>/scripts/create_dashboard_chart.py "<仪表盘名>" \ +python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard "<仪表盘名>" \ --chart-specs ``` -脚本是 `dashboard create → chart create(可选)→ dashboard get` 的唯一首选 +统一入口的 dashboard 操作是 `dashboard create → chart create(可选)→ dashboard get` 的唯一首选 recipe,并输出 `dws-skill-script-ledger/v1`。不要在脚本前调用 config-example 或 widgets-example,也不要在成功后重复创建或回读。 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-export-import.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-export-import.md index 0030f982..7ae3465a 100644 --- a/skills/multi/dingtalk-aitable/references/aitable/aitable-export-import.md +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-export-import.md @@ -4,7 +4,7 @@ `export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。 -优先使用 `python3 scripts/aitable_export_via_task.py --scope all|table|view [...]`:它检查业务状态、持续轮询、要求 HTTPS 下载地址,并在本地文件已存在时停止而不是静默覆盖。只有需要控制底层轮询参数时才走下面的原子命令。 +优先使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py export --scope all|table|view [...]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;统一入口检查业务状态、持续轮询、要求 HTTPS 下载地址,并在本地文件已存在时停止而不是静默覆盖。只有需要控制底层轮询参数时才走下面的原子命令。 > ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。 @@ -30,7 +30,7 @@ dws aitable export data --base-id --task-id --timeout-ms 300 > **无需手动解析 CSV/Excel 再逐条 record create**,效率极低且容易出错。 -新建数据表导入优先使用 `python3 scripts/aitable_import_via_task.py `,脚本封装 prepare → PUT → import 并检查每一步业务状态。追加到已有表且需要字段级类型控制时,使用 `python3 scripts/import_records.py [batch_size]`;二者语义不同,不要自动互换。 +新建数据表导入优先使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-new `;追加到已有表使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records [--batch-size N]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;二者语义不同,不要自动互换。 ```bash # 第 1 步:申请上传凭证 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-record-create.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-create.md index ebc00e8d..3f2cb5c6 100644 --- a/skills/multi/dingtalk-aitable/references/aitable/aitable-record-create.md +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-record-create.md @@ -47,7 +47,7 @@ dws aitable record query --base-id --table-id \ 创建成功以 `data.newRecordIds[]` 为 ID 来源;不要把整个 `data` 当作单个 recordId,也不要只以退出码作为写入成功证据。 -批量追加本地 JSON/CSV 到已有表时可使用 `python3 scripts/import_records.py [batch_size]`。脚本会逐批检查业务状态、收集 `newRecordIds` 并按 ID 回读;任何批次失败或回读不完整都会输出 ledger 并以非零状态结束,已有成功批次仍会保留在结果中。 +批量追加本地 JSON/CSV 到已有表时使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records [--batch-size N]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;统一入口保留逐批业务状态、`newRecordIds` 回读和部分失败 ledger。 ## cells 写入格式 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-script-recipes.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-script-recipes.md new file mode 100644 index 00000000..7963fd73 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-script-recipes.md @@ -0,0 +1,24 @@ +# Bundled script recipes + +Use this file only for dashboard/chart, import, export, bulk fields, or attachment workflows. +The stable entry point is `scripts/aitable_ops.py`; do not read it or the delegated `.py` files before execution. Run `python3 /scripts/aitable_ops.py --help` or the operation-level `--help` only when an argument is unclear. + +For a create-Base-then-script workflow, create the Base first and take `baseId` from its structured response. If the user did not specify the container name, choose a short descriptive name and continue instead of asking only for that name. Verify the Base with `dws aitable base get --base-id --format json`, then pass that exact `baseId` to this entry point. + +| Intent | Exact command | +|---|---| +| Create dashboard, optionally charts | `python3 /scripts/aitable_ops.py dashboard "" [--chart-specs ]` | +| Import CSV/XLS/XLSX as a new table | `python3 /scripts/aitable_ops.py import-new ` | +| Append JSON/CSV to an existing table | `python3 /scripts/aitable_ops.py import-records [--batch-size 100]` | +| Export Base/table/view | `python3 /scripts/aitable_ops.py export --scope all\|table\|view [--table-id ] [--view-id ] [--output ]` | +| Add up to 15 fields | `python3 /scripts/aitable_ops.py add-fields ` | +| Upload attachment | `python3 /scripts/aitable_ops.py upload-attachment ` | + +## Contracts + +- `dashboard` creates the dashboard/charts, chains returned IDs, performs final dashboard/chart readback, and emits `dws-skill-script-ledger/v1`. Do not add a second guessed dashboard command after it. `--chart-specs` is a JSON array; each item accepts `name`, `chart_type`, `table_id`, `measure_type`, `measure_field_id`, `dimension_field_id`, `aggregation`, and `view_id`. +- `import-new` runs prepare → HTTPS PUT → import task and checks each business status. It is not interchangeable with `import-records`. +- `import-records` requires CSV headers to be field IDs; use JSON for typed boolean/array/object values. It checks batch results and reads back returned record IDs. +- `export --scope table|view` requires `--table-id`; view also requires `--view-id`. The unified ledger exposes the real `taskId`, `polledTimes`, `savedPath`, and `fileSize`; treat success plus a non-empty saved file as the completion evidence, without reading source or substituting `ls` for task polling. Do not overwrite unless the user explicitly requests it. +- `add-fields` accepts at most 15 items and reports partial failure. `upload-attachment` only returns `fileToken`; write that token to the attachment field and read the record back. +- Preserve nonzero exit, partial-success ledger, timeout, and incomplete readback in the final answer. Do not replace a failed deterministic workflow with guessed atomic commands unless its reported error proves that the script contract is unavailable. diff --git a/skills/multi/dingtalk-aitable/references/field-rules.md b/skills/multi/dingtalk-aitable/references/field-rules.md index 87725163..94ff5a8d 100644 --- a/skills/multi/dingtalk-aitable/references/field-rules.md +++ b/skills/multi/dingtalk-aitable/references/field-rules.md @@ -91,11 +91,11 @@ dws aitable table create --base-id --name "产品图片" \ > **不要**使用钉盘 (drive) 上传来替代此流程!钉盘 fileId **无法**写入 attachment 字段。 -附件字段写入使用 `upload_attachment.py` 脚本,**2 步**完成: +附件字段写入使用统一 `aitable_ops.py upload-attachment` 入口,**2 步**完成: ```bash # 步骤 1: 一键上传文件(脚本内部自动完成 prepare + PUT to OSS) -python3 scripts/upload_attachment.py /path/to/photo.png +python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment /path/to/photo.png # 输出: { "fileToken": "ft_xxx", "fileName": "photo.png", "size": 1024 } # 步骤 2: 在 record create/update 中使用 fileToken diff --git a/skills/multi/dingtalk-aitable/scripts/aitable_export_via_task.py b/skills/multi/dingtalk-aitable/scripts/aitable_export_via_task.py index 6921fc24..d76a9787 100644 --- a/skills/multi/dingtalk-aitable/scripts/aitable_export_via_task.py +++ b/skills/multi/dingtalk-aitable/scripts/aitable_export_via_task.py @@ -26,7 +26,8 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen -RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$") +# Runtime AI Table IDs are opaque and some table IDs are seven characters. +RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{6,128}$") ALLOWED_FORMATS = {"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"} diff --git a/skills/multi/dingtalk-aitable/scripts/aitable_ops.py b/skills/multi/dingtalk-aitable/scripts/aitable_ops.py new file mode 100644 index 00000000..3730e6b5 --- /dev/null +++ b/skills/multi/dingtalk-aitable/scripts/aitable_ops.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Stable black-box entry point for bundled AI Table workflows.""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +LEDGER_SCHEMA_VERSION = "dws-skill-script-ledger/v1" + + +def command_for(args: argparse.Namespace) -> list[str]: + if args.operation == "dashboard": + command = [ + "create_dashboard_chart.py", args.base_id, args.dashboard_name, + *(["--chart-specs", args.chart_specs] if args.chart_specs else []), + ] + elif args.operation == "import-new": + command = ["aitable_import_via_task.py", args.base_id, args.file] + elif args.operation == "import-records": + command = [ + "import_records.py", args.base_id, args.table_id, args.file, + str(args.batch_size), + ] + elif args.operation == "export": + command = [ + "aitable_export_via_task.py", args.base_id, "--scope", args.scope, + *(["--table-id", args.table_id] if args.table_id else []), + *(["--view-id", args.view_id] if args.view_id else []), + *(["--output", args.output] if args.output else []), + *(["--export-format", args.export_format] if args.export_format else []), + *(["--overwrite"] if args.overwrite else []), + ] + elif args.operation == "add-fields": + command = [ + "bulk_add_fields.py", args.base_id, args.table_id, args.fields_file, + ] + else: + command = ["upload_attachment.py", args.base_id, args.file] + return [sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]] + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser( + description=( + "Run a bundled AI Table workflow without reading implementation source. " + "Each operation preserves the underlying script ledger and exit status." + ) + ) + sub = root.add_subparsers(dest="operation", required=True) + + dashboard = sub.add_parser("dashboard", help="create and read back a dashboard/chart") + dashboard.add_argument("base_id") + dashboard.add_argument("dashboard_name") + dashboard.add_argument("--chart-specs", help="workspace JSON array of chart specs") + + import_new = sub.add_parser("import-new", help="import CSV/XLS/XLSX as a new table") + import_new.add_argument("base_id") + import_new.add_argument("file") + + import_records = sub.add_parser("import-records", help="append JSON/CSV records to an existing table") + import_records.add_argument("base_id") + import_records.add_argument("table_id") + import_records.add_argument("file") + import_records.add_argument("--batch-size", type=int, default=100) + + export = sub.add_parser("export", help="export a Base, table, or view") + export.add_argument("base_id") + export.add_argument("--scope", choices=("all", "table", "view"), required=True) + export.add_argument("--table-id") + export.add_argument("--view-id") + export.add_argument("--output") + export.add_argument( + "--export-format", + choices=("attachment", "excel", "excel_and_attachment", "excel_with_inline_images"), + ) + export.add_argument("--overwrite", action="store_true") + + fields = sub.add_parser("add-fields", help="create up to 15 fields from JSON") + fields.add_argument("base_id") + fields.add_argument("table_id") + fields.add_argument("fields_file") + + attachment = sub.add_parser("upload-attachment", help="upload a file and return fileToken") + attachment.add_argument("base_id") + attachment.add_argument("file") + return root + + +def normalize_output(raw: str, args: argparse.Namespace | None = None) -> str: + """Make a delegated trusted ledger attributable to this stable entry point.""" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return raw + if ( + isinstance(payload, dict) + and payload.get("schema_version") == LEDGER_SCHEMA_VERSION + and isinstance(payload.get("script"), str) + ): + payload["implementation_script"] = payload["script"] + payload["script"] = Path(__file__).name + return json.dumps(payload, ensure_ascii=False) + if args and args.operation == "export" and isinstance(payload, dict): + status = str(payload.get("status") or "error") + saved_path = str(payload.get("savedPath") or "") + file_size = 0 + if saved_path: + try: + file_size = Path(saved_path).stat().st_size + except OSError: + file_size = 0 + task_id = str(payload.get("taskId") or "") + ledger_status = "success" if status == "success" and task_id and ( + bool(payload.get("downloadUrl")) and (not saved_path or file_size > 0) + ) else status + ledger = { + "schema_version": LEDGER_SCHEMA_VERSION, + "script": Path(__file__).name, + "implementation_script": "aitable_export_via_task.py", + "status": ledger_status, + "result": payload, + "ledger": [{ + "cli_path": "aitable export data", + "status": ledger_status, + "params": { + "base-id": args.base_id, + "scope": args.scope, + **({"table-id": args.table_id} if args.table_id else {}), + **({"view-id": args.view_id} if args.view_id else {}), + }, + "output_ids": { + "taskId": task_id, + "polledTimes": int(payload.get("polledTimes") or 0), + "savedPath": saved_path, + "fileSize": file_size, + }, + "error": "" if ledger_status == "success" else str(payload.get("summary") or "export incomplete"), + }], + } + return json.dumps(ledger, ensure_ascii=False) + return raw + + +def main() -> int: + args = parser().parse_args() + if args.operation == "export": + if args.scope in {"table", "view"} and not args.table_id: + parser().error("export --scope table|view requires --table-id") + if args.scope == "view" and not args.view_id: + parser().error("export --scope view requires --view-id") + result = subprocess.run(command_for(args), check=False, capture_output=True, text=True) + if result.stdout: + print(normalize_output(result.stdout, args), end="" if result.stdout.endswith("\n") else "\n") + if result.stderr: + print(result.stderr, file=sys.stderr, end="" if result.stderr.endswith("\n") else "\n") + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/scripts/aitable_skill_scripts_test.py b/test/scripts/aitable_skill_scripts_test.py index eab3936f..15d3150c 100644 --- a/test/scripts/aitable_skill_scripts_test.py +++ b/test/scripts/aitable_skill_scripts_test.py @@ -25,6 +25,7 @@ BULK_FIELDS = load_module("aitable_bulk_fields", "bulk_add_fields.py") UPLOAD_ATTACHMENT = load_module("aitable_upload_attachment", "upload_attachment.py") EXPORT_TASK = load_module("aitable_export_task", "aitable_export_via_task.py") IMPORT_TASK = load_module("aitable_import_task", "aitable_import_via_task.py") +AITABLE_OPS = load_module("aitable_ops", "aitable_ops.py") class AITableSkillScriptsTest(unittest.TestCase): @@ -59,6 +60,81 @@ class AITableSkillScriptsTest(unittest.TestCase): self.assertEqual(records[0]["cells"]["fldPhone01"], "00123") self.assertEqual(records[0]["cells"]["fldBool01"], "true") + def test_unified_ops_dispatches_without_cross_operation_arguments(self): + cases = [ + ( + ["dashboard", "base12345678", "概览", "--chart-specs", "charts.json"], + "create_dashboard_chart.py", + ["base12345678", "概览", "--chart-specs", "charts.json"], + ), + ( + ["import-new", "base12345678", "data.csv"], + "aitable_import_via_task.py", + ["base12345678", "data.csv"], + ), + ( + ["import-records", "base12345678", "table1234567", "data.json"], + "import_records.py", + ["base12345678", "table1234567", "data.json", "100"], + ), + ( + ["export", "base12345678", "--scope", "view", "--table-id", "table1234567", "--view-id", "view12345678"], + "aitable_export_via_task.py", + ["base12345678", "--scope", "view", "--table-id", "table1234567", "--view-id", "view12345678"], + ), + ( + ["add-fields", "base12345678", "table1234567", "fields.json"], + "bulk_add_fields.py", + ["base12345678", "table1234567", "fields.json"], + ), + ( + ["upload-attachment", "base12345678", "report.pdf"], + "upload_attachment.py", + ["base12345678", "report.pdf"], + ), + ] + parser = AITABLE_OPS.parser() + for argv, script, expected_tail in cases: + with self.subTest(operation=argv[0]): + command = AITABLE_OPS.command_for(parser.parse_args(argv)) + self.assertEqual(Path(command[1]).name, script) + self.assertEqual(command[2:], expected_tail) + + def test_unified_ops_normalizes_delegated_trusted_ledger(self): + payload = { + "schema_version": "dws-skill-script-ledger/v1", + "script": "create_dashboard_chart.py", + "status": "success", + "ledger": [], + } + normalized = json.loads(AITABLE_OPS.normalize_output(json.dumps(payload))) + self.assertEqual(normalized["script"], "aitable_ops.py") + self.assertEqual(normalized["implementation_script"], "create_dashboard_chart.py") + + def test_unified_ops_emits_export_task_ledger_with_nonempty_file(self): + with tempfile.TemporaryDirectory() as raw: + exported = Path(raw) / "result.xlsx" + exported.write_bytes(b"excel") + args = AITABLE_OPS.parser().parse_args([ + "export", "base12345678", "--scope", "table", + "--table-id", "tbl1234", "--output", str(exported), + ]) + payload = { + "status": "success", + "taskId": "task12345678", + "downloadUrl": "https://example.invalid/result.xlsx", + "polledTimes": 2, + "savedPath": str(exported), + } + normalized = json.loads(AITABLE_OPS.normalize_output(json.dumps(payload), args)) + self.assertEqual(normalized["script"], "aitable_ops.py") + self.assertEqual(normalized["ledger"][0]["cli_path"], "aitable export data") + self.assertEqual(normalized["ledger"][0]["output_ids"]["polledTimes"], 2) + self.assertEqual(normalized["ledger"][0]["output_ids"]["fileSize"], 5) + + def test_export_accepts_runtime_seven_character_table_id(self): + self.assertTrue(EXPORT_TASK.validate_resource_id("VS9pVmc")) + def test_import_records_checks_ids_and_readback(self): with tempfile.TemporaryDirectory() as raw: root = Path(raw)