Compare commits

...
Author SHA1 Message Date
瑞达 a30ff648f4 Align AI Table skill references and scripts 2026-08-13 20:17:30 +08:00
13 changed files with 299 additions and 37 deletions
+19 -19
View File
@@ -20,10 +20,11 @@ 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”只加载相关文件。所有下表的 `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`。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcut 发现(按需)
@@ -60,19 +61,17 @@ 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 <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 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` 操作不得自动重试或静默确认。
## 跨产品协作
@@ -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> "<仪表盘名>" \
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <BASE_ID> "<仪表盘名>" \
--chart-specs <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 步:申请上传凭证
@@ -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 写入格式
@@ -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 <Skill绝对目录>/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 <baseId> --format json`, then pass that exact `baseId` to this entry point.
| Intent | Exact command |
|---|---|
| Create dashboard, optionally charts | `python3 <Skill绝对目录>/scripts/aitable_ops.py dashboard <baseId> "<dashboardName>" [--chart-specs <workspace JSON>]` |
| 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 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.
@@ -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"}
@@ -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())
@@ -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)