Compare commits

...
Author SHA1 Message Date
瑞达 90f3aa7f8f feat(aitable): align deterministic run5 workflows 2026-08-17 09:52:47 +08:00
瑞达 a30ff648f4 Align AI Table skill references and scripts 2026-08-13 20:17:30 +08:00
17 changed files with 340 additions and 51 deletions
+22 -21
View File
@@ -20,15 +20,16 @@ metadata:
1. 命中下方高频意图时直接使用精确骨架,不先查 Help 或产品级 Schema。
2. 路由优先级固定为:精确 recipe / 可运行脚本 > 匹配的公开 Shortcut > 原子命令。命令已确定且参数清楚时直接执行。
3. 参数、约束或安全语义不确定时只读 leaf Schema:`dws schema --cli-path "aitable <leaf>" --format json`;只有当前 Cobra flag 不确定时才读对应 `--help`。
4. 复杂字段、筛选、导入导出、视图、权限或工作流任务,按“低频能力与 Reference”只加载相关文件,不预读整个 `references/aitable/`。
4. 复杂任务按下表只读相关 Reference;路径相对本 Skill 根目录且必须保留 `aitable/`。脚本型任务只读 `references/aitable/aitable-script-recipes.md`,再运行 `scripts/aitable_ops.py`;不预读目录或源码。
5. 现有骨架和 reference 都无法定位能力时,才用 Runtime Shortcut Catalog 做最后发现;不得猜 `cli_path` 或 flag。
6. Schema、Help、reference 与实际返回冲突时采用更安全的解释并报告契约漂移;`confirmation=user_required` 时先确认,再添加 `--yes`。
7. 用户已给足名称、字段、数据和目标时,直接按依赖链完成全部步骤;不要调用 todo 工具、分步汇报或追问已明确的信息。中间返回只用于提取下一步 ID 和判断失败,完成所有请求后再统一回读并答复。
7. 输入完整时直接完成依赖链,不调用 todo、不分步汇报或重复追问;中间响应只提取 ID/错误,最后统一回读答复。脚本参数明确就执行;不明确才读操作级 `--help`,仅契约失败、环境异常或用户要求修改时读源码。
8. 用户要求新建 Base 但未指定 Base 名时,根据业务目标生成简短描述性名称(例如仪表盘任务用“数据看板”)并继续;不要仅为可回退的容器名称追问。Base 只接受 Base flags,不得把 table `--fields` 传给 `base create`。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcut 发现(按需)
`aitable` 当前有 29 条公开 shortcut。完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation 的子集可通过 leaf Schema 查询。高频产品根 Skill 不重复展开完整清单。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
29 条公开 shortcut 保留在 Runtime Catalog,不在根 Skill 展开。已知意图直接走下方路由;仅参数/安全语义不确定时读 leaf Schema,Cobra flags 不确定时读 leaf Help。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service aitable --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
@@ -60,20 +61,19 @@ metadata:
| 新增记录 | `dws aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":<值>}}]' --format json` | 单次最多 100;取 `data.newRecordIds[]` 后立即按 ID 回读 |
| 更新记录 | `dws aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<id>","cells":{"<fieldId>":<值>}}]' --format json` | 先 query 拿 recordId;只传需改字段;取 `data.recordIds[]` 后回读 |
| 删除记录 | 先 `dws aitable +record-query ...` 定位,再 `dws aitable record delete --base-id <baseId> --table-id <tableId> --record-ids <ids>` | 展示目标与影响,得到明确确认后才加 `--yes` |
| 创建 Base / Table | `dws aitable base create --name "<名>"` / `dws aitable table create --base-id <id> --name "<名>" --fields '[...]'` | 使用创建返回的真实 ID;系统改名/加后缀时不得继续猜原名 |
| 创建仪表盘 / 常用图表 | `python3 <本 Skill 绝对目录>/scripts/create_dashboard_chart.py <baseId> "<仪表盘名>" [--chart-specs <workspace内JSON>]` | 这是 dashboard/chart 创建的唯一首选 recipe;脚本创建、串联真实 ID、最终回读并输出可评分 ledger。图表 JSON 参数见对应 reference |
| 创建 Base / Table | `dws aitable base create --name "<名>" --format json` → `dws aitable base get --base-id <baseId> --format json`;`dws aitable table create --base-id <id> --name "<名>" --fields '[...]' --format json` → `dws aitable +table-get --base-id <id> --table-ids <tableId> --format json` | 使用创建返回的真实 ID 立即回读;创建字段时回读 `fields[]` 的名称、类型与 config;系统改名/加后缀时不得继续猜原名 |
| 创建仪表盘 / 常用图表 | `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <baseId> "<仪表盘名>" [--chart-specs-file <workspace内JSON文件>]` | 唯一首选;完整参数与 ledger 契约只读 `references/aitable/aitable-script-recipes.md` |
| 复制视图 | `dws aitable view duplicate --base-id <baseId> --table-id <tableId> --view-id <源viewId> --new-name "<新名称>" --format json` | 源 viewId 来自当前表的真实返回;不要复制数据表或创建仪表盘替代 |
| 批量追加 CSV / JSON 到已有表 | `python3 scripts/import_records.py <baseId> <tableId> <file> [batch_size]` | CSV 表头必须是 fieldId;脚本返回不完整 ledger 时不得宣称全成功 |
| 文件导入为新数据表 | `python3 scripts/aitable_import_via_task.py <baseId> <file>` | 与“追加已有 table”不同;走 prepare → PUT → import task |
| 批量创建字段 | `python3 scripts/bulk_add_fields.py <baseId> <tableId> fields.json` | 单次最多 15;逐项检查成功/失败结果 |
| 导出 Base / Table / View | `python3 scripts/aitable_export_via_task.py <baseId> --scope all\|table\|view [...]` | 保存路径、覆盖与异步未完成状态必须显式处理 |
| 上传记录附件 | `python3 scripts/upload_attachment.py <baseId> <file>` | 返回 `fileToken` 后仍需按字段格式写入记录并回读 |
| 导入 / 导出 / 批量字段 / 附件 | 先读 `references/aitable/aitable-script-recipes.md`,再运行其中唯一的 `scripts/aitable_ops.py <operation> ...` | 不直接选择底层脚本;不读源码;保留统一入口返回的退出状态与 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 query --all` 仍受 `--page-limit` 约束;分页中断或局部富化失败时保留已有结果,输出 completeness 与逐项失败 ledger,不把部分结果描述为全量。
- `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`。
- **Complete evidence:** if an exact total/set relies on paginated output, use `--all --page-limit 0` or iterate until `nextCursor` is empty and `hasMore` is false. A complete write-result ID list is sufficient; a truncated page is not.
- **Capability proof:** with write authorization, create minimal prerequisites, execute, and read back. Help/Schema alone is unverified.
- 创建、更新、导入、批量建字段等写操作必须检查业务 `status`、逐项结果与返回 ID;普通写入按用户明确要求执行后回读,不能只凭退出码宣称成功。
- 长 JSON 使用 `--records-file` / 任务文件;不得为绕过字段错误而静默丢列、改类型或删除失败项。
@@ -81,19 +81,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` 操作不得自动重试或静默确认。
## 跨产品协作
@@ -6,6 +6,6 @@
|--------|-------------------|
| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId`<br>3. `aitable record query --base-id <baseId> --table-id <tableId>` → 取记录(分页)<br>  需要筛选时 `--filters` 格式见 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md),根节点必须是 `{"operator":"and\|or","operands":[...]}`<br>4. 总结数据 |
| generate-data-report | 1. 同 read-aitable 步骤 1-3<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 补充背景<br>3. `doc create --name "<报告名>" --content "<分析报告>"` |
| create-aitable-record | **批量导入优先**:`python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(自动分批创建)<br>单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId` 与类型<br>3. `aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":"值"}}]'` |
| create-aitable-record | **批量导入优先**:`python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> data.csv\|data.json [--batch-size N]`<br>单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId` 与类型<br>3. `aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":"值"}}]'` |
| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable record query --base-id <baseId> --table-id <tableId>` → 取 `recordId`,**先展示让用户确认**<br>3. `aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<recordId>","cells":{...}}]'` |
| search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`<br>2. 用户选定<br>3. `aitable base create --name "<表格名>" --template-id <templateId>` → 取 `baseId` |
@@ -102,11 +102,11 @@ dws aitable record delete --base-id <BASE_ID> --table-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 <BASE_ID> /path/to/report.pdf
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment <BASE_ID> /path/to/report.pdf
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
# 步骤 2: 在 record create/update 中使用 fileToken 写入附件字段
@@ -481,11 +481,7 @@ dws aitable record create --base-id <BASE_ID> --table-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` |
## 相关产品
@@ -23,7 +23,7 @@ Flags:
```bash
# 步骤 1: 使用脚本一键上传(内部自动完成 prepare + PUT)
python3 scripts/upload_attachment.py <BASE_ID> /path/to/report.pdf
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment <BASE_ID> /path/to/report.pdf
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
# 步骤 2: 在 record create/update 中使用 fileToken 写入
@@ -4,14 +4,14 @@
```bash
# 仅建仪表盘
python3 <本 Skill 绝对目录>/scripts/create_dashboard_chart.py <BASE_ID> "<仪表盘名>"
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <BASE_ID> "<仪表盘名>"
# 建仪表盘和常用图表
python3 <本 Skill 绝对目录>/scripts/create_dashboard_chart.py <BASE_ID> "<仪表盘名>" \
--chart-specs <workspace内/charts.json>
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <BASE_ID> "<仪表盘名>" \
--chart-specs-file <workspace内/charts.json>
```
脚本是 `dashboard create → chart create(可选)→ dashboard get` 的唯一首选
统一入口的 dashboard 操作是 `dashboard create → chart create(可选)→ dashboard get` 的唯一首选
recipe,并输出 `dws-skill-script-ledger/v1`。不要在脚本前调用 config-example 或
widgets-example,也不要在成功后重复创建或回读。
@@ -4,7 +4,7 @@
`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
优先使用 `python3 scripts/aitable_export_via_task.py <baseId> --scope all|table|view [...]`:它检查业务状态、持续轮询、要求 HTTPS 下载地址,并在本地文件已存在时停止而不是静默覆盖。只有需要控制底层轮询参数时才走下面的原子命令。
优先使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py export <baseId> --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 <BASE_ID> --task-id <TASK_ID> --timeout-ms 300
> **无需手动解析 CSV/Excel 再逐条 record create**,效率极低且容易出错。
新建数据表导入优先使用 `python3 scripts/aitable_import_via_task.py <baseId> <file>`,脚本封装 prepare → PUT → import 并检查每一步业务状态。追加到已有表且需要字段级类型控制时,使用 `python3 scripts/import_records.py <baseId> <tableId> <file> [batch_size]`;二者语义不同,不要自动互换。
新建数据表导入优先使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-new <baseId> <file>`;追加到已有表使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> <file> [--batch-size N]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;二者语义不同,不要自动互换。
```bash
# 第 1 步:申请上传凭证
@@ -87,6 +87,8 @@ dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
创建 `lookup`(关联引用)和 `filterUp`(查找引用)字段时,config 格式有严格要求:
当用户要求判断此能力且已授权创建资源时,不要只读 Help/Schema 后下结论:创建最小目标表及必需字段,执行一次真实字段创建并用 `field get` 回读。若没有写入授权,只能报告“接口声明支持但未实际验证”。
#### bidirectionalLink / unidirectionalLink(关联字段)
```bash
@@ -47,7 +47,7 @@ dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> \
创建成功以 `data.newRecordIds[]` 为 ID 来源;不要把整个 `data` 当作单个 recordId,也不要只以退出码作为写入成功证据。
批量追加本地 JSON/CSV 到已有表时可使用 `python3 scripts/import_records.py <baseId> <tableId> <file> [batch_size]`。脚本会逐批检查业务状态、收集 `newRecordIds` 并按 ID 回读;任何批次失败或回读不完整都会输出 ledger 并以非零状态结束,已有成功批次仍会保留在结果中。
批量追加本地 JSON/CSV 到已有表时使用 `python3 <本 Skill 绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> <file> [--batch-size N]`。完整参数只读 `references/aitable/aitable-script-recipes.md`;统一入口保留逐批业务状态、`newRecordIds` 回读和部分失败 ledger。
## cells 写入格式
@@ -33,6 +33,8 @@ Flags:
- **被截断时**(达到 page-limit 但仍有数据):输出中包含 `"hasMore": true` 和 `"cursor": "..."` 字段,可通过 `--cursor` 从断点继续拉取
- 适用于需要一次性获取全量数据的场景(如导出、统计、批量处理)
当精确计数或集合依赖分页查询时,优先使用 `--all --page-limit 0`;否则持续传回 `nextCursor`/`cursor`,直到游标为空且 `hasMore` 不为 true。写入响应返回的完整 ID 列表可直接作为该批证据;截断页、局部计数或最终链接数不能替代分页完成信号。
```bash
# 默认(最多 50 页 = 5000 条)
dws aitable record query --base-id X --table-id Y --all
@@ -0,0 +1,24 @@
# Bundled script recipes
Use this file only for dashboard/chart, import, export, bulk fields, or attachment workflows.
Use `scripts/aitable_ops.py` as the stable entry; do not read implementation files. Use operation-level `--help` only for unclear arguments.
For create-Base-then-script, create and verify the Base, then pass its returned `baseId`; choose a short name if omitted.
| Intent | Exact command |
|---|---|
| Create dashboard, optionally charts | `python3 <Skill绝对目录>/scripts/aitable_ops.py dashboard <baseId> "<dashboardName>" [--chart-specs-file <workspace JSON file>]` |
| Import CSV/XLS/XLSX as a new table | `python3 <Skill绝对目录>/scripts/aitable_ops.py import-new <baseId> <file>` |
| Append JSON/CSV to an existing table | `python3 <Skill绝对目录>/scripts/aitable_ops.py import-records <baseId> <tableId> <file> [--batch-size 100]` |
| Export Base/table/view | `python3 <Skill绝对目录>/scripts/aitable_ops.py export <baseId> --scope all\|table\|view [--table-id <id>] [--view-id <id>] [--output <path>]` |
| Add up to 15 fields | `python3 <Skill绝对目录>/scripts/aitable_ops.py add-fields <baseId> <tableId> <fields.json>` |
| Upload attachment | `python3 <Skill绝对目录>/scripts/aitable_ops.py upload-attachment <baseId> <file>` |
## Contracts
- `dashboard` creates the dashboard/charts, chains returned IDs, performs final readback, and emits `dws-skill-script-ledger/v1`. `--chart-specs-file` is a workspace-local file containing one JSON array; it is not inline JSON. Each item accepts `name`, `chart_type`, `table_id`, `measure_type`, `measure_field_id`, `dimension_field_id`, `aggregation`, and `view_id`. Do not add a guessed dashboard command after it.
- `import-new` runs prepare → secure PUT → import task and checks each business status. A returned HTTP upload URL is upgraded to HTTPS without changing host, path, or query. It is not interchangeable with `import-records`, and a deterministic failure is not a reason to expand the same flow manually.
- `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.
@@ -91,11 +91,11 @@ dws aitable table create --base-id <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 <BASE_ID> /path/to/photo.png
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py upload-attachment <BASE_ID> /path/to/photo.png
# 输出: { "fileToken": "ft_xxx", "fileName": "photo.png", "size": 1024 }
# 步骤 2: 在 record create/update 中使用 fileToken
@@ -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"}
@@ -22,7 +22,7 @@ import sys
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.parse import urlparse, urlunparse
from urllib.request import Request, urlopen
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
@@ -52,12 +52,24 @@ def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
return None
def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]:
def normalize_upload_url(upload_url: str) -> str:
parsed = urlparse(upload_url)
if parsed.scheme != "https" or not parsed.hostname:
return False, "uploadUrl must be a valid HTTPS URL"
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("uploadUrl must be a valid HTTP(S) URL")
if parsed.username or parsed.password:
raise ValueError("uploadUrl must not contain user info")
if parsed.scheme == "http":
parsed = parsed._replace(scheme="https")
return urlunparse(parsed)
def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]:
try:
secure_upload_url = normalize_upload_url(upload_url)
except ValueError as exc:
return False, str(exc)
payload = file_path.read_bytes()
req = Request(upload_url, data=payload, method="PUT")
req = Request(secure_upload_url, data=payload, method="PUT")
# 关键:清空 Content-Type,避免 SignatureDoesNotMatch。
req.add_header("Content-Type", "")
try:
@@ -0,0 +1,168 @@
#!/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-file", args.chart_specs_file] if args.chart_specs_file 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-file", "--chart-specs", dest="chart_specs_file",
help="workspace-local file containing a 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())
@@ -3,7 +3,7 @@
Examples:
python3 create_dashboard_chart.py BASE_ID "状态分析仪表盘"
python3 create_dashboard_chart.py BASE_ID "状态分析仪表盘" --chart-specs charts.json
python3 create_dashboard_chart.py BASE_ID "状态分析仪表盘" --chart-specs-file charts.json
charts.json is a JSON array. Each item accepts:
name, chart_type, table_id, measure_type, measure_field_id,
@@ -193,7 +193,10 @@ def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("base_id", help="Target AI Table baseId")
parser.add_argument("dashboard_name", help="Dashboard name")
parser.add_argument("--chart-specs", help="Workspace-local JSON chart spec file")
parser.add_argument(
"--chart-specs-file", "--chart-specs", dest="chart_specs_file",
help="Workspace-local file containing a JSON array of chart specs",
)
parser.add_argument("--dws", default="dws", help="dws executable")
args = parser.parse_args()
@@ -202,7 +205,7 @@ def main() -> int:
if not RESOURCE_ID_PATTERN.fullmatch(base_id) or not dashboard_name:
parser.error("base_id and dashboard_name are required and must be valid")
try:
specs = validate_specs(safe_json_file(args.chart_specs)) if args.chart_specs else []
specs = validate_specs(safe_json_file(args.chart_specs_file)) if args.chart_specs_file else []
except (OSError, ValueError, json.JSONDecodeError) as exc:
parser.error(str(exc))
+83 -3
View File
@@ -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-file", "charts.json"],
"create_dashboard_chart.py",
["base12345678", "概览", "--chart-specs-file", "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)
@@ -175,7 +251,7 @@ elif args[:3] == ['aitable', 'field', 'get']:
self.assertEqual(payload["verifiedCount"], 1)
self.assertEqual(payload["ledger"][1]["error"], "duplicate")
def test_upload_helpers_reject_plain_http_urls(self):
def test_upload_helpers_use_secure_urls(self):
with tempfile.TemporaryDirectory() as raw:
file_path = Path(raw) / "file.bin"
file_path.write_bytes(b"x")
@@ -184,9 +260,13 @@ elif args[:3] == ['aitable', 'field', 'get']:
"http://example.com/upload", file_path, "application/octet-stream"
)
)
ok, error = IMPORT_TASK.put_file("http://example.com/upload", file_path)
self.assertEqual(
IMPORT_TASK.normalize_upload_url("http://example.com/upload?signature=x"),
"https://example.com/upload?signature=x",
)
ok, error = IMPORT_TASK.put_file("ftp://example.com/upload", file_path)
self.assertFalse(ok)
self.assertIn("HTTPS", error)
self.assertIn("HTTP(S)", error)
def test_export_helpers_reject_http_and_existing_output(self):
with self.assertRaises(ValueError):