Compare commits

..
Author SHA1 Message Date
瑞达 90f3aa7f8f feat(aitable): align deterministic run5 workflows 2026-08-17 09:52:47 +08:00
9 changed files with 53 additions and 26 deletions
+6 -5
View File
@@ -20,16 +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/...` 都是相对本 Skill 根目录的完整精确路径;不得省略中间的 `aitable/`。dashboard/chart、导入、导出、批量字段和附件统一只读 `references/aitable/aitable-script-recipes.md`,再运行 `scripts/aitable_ops.py`;不要预读整个 reference 目录或任何脚本源码。
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 和判断失败,完成所有请求后再统一回读并答复。bundled script 参数明确时直接运行;只有参数不明确时执行统一入口的操作级 `--help`,只有契约失败、环境异常或用户要求修改脚本时才读取源码。
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 -->
@@ -62,7 +62,7 @@ metadata:
| 更新记录 | `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 "<名>" --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` |
| 创建仪表盘 / 常用图表 | `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 来自当前表的真实返回;不要复制数据表或创建仪表盘替代 |
| 导入 / 导出 / 批量字段 / 附件 | 先读 `references/aitable/aitable-script-recipes.md`,再运行其中唯一的 `scripts/aitable_ops.py <operation> ...` | 不直接选择底层脚本;不读源码;保留统一入口返回的退出状态与 ledger |
@@ -72,7 +72,8 @@ metadata:
- `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,不把部分结果描述为全量。
- **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` / 任务文件;不得为绕过字段错误而静默丢列、改类型或删除失败项。
@@ -8,7 +8,7 @@ python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <BASE_ID> "<
# 建仪表盘和常用图表
python3 <本 Skill 绝对目录>/scripts/aitable_ops.py dashboard <BASE_ID> "<仪表盘名>" \
--chart-specs <workspace内/charts.json>
--chart-specs-file <workspace内/charts.json>
```
统一入口的 dashboard 操作是 `dashboard create → chart create(可选)→ dashboard get` 的唯一首选
@@ -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
@@ -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
@@ -1,13 +1,13 @@
# 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.
Use `scripts/aitable_ops.py` as the stable entry; do not read implementation files. Use operation-level `--help` only for unclear arguments.
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.
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 <workspace JSON>]` |
| 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>]` |
@@ -16,8 +16,8 @@ For a create-Base-then-script workflow, create the Base first and take `baseId`
## 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`.
- `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.
@@ -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:
@@ -17,7 +17,7 @@ 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 []),
*(["--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]
@@ -56,7 +56,10 @@ def parser() -> argparse.ArgumentParser:
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")
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")
@@ -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))
+9 -5
View File
@@ -63,9 +63,9 @@ class AITableSkillScriptsTest(unittest.TestCase):
def test_unified_ops_dispatches_without_cross_operation_arguments(self):
cases = [
(
["dashboard", "base12345678", "概览", "--chart-specs", "charts.json"],
["dashboard", "base12345678", "概览", "--chart-specs-file", "charts.json"],
"create_dashboard_chart.py",
["base12345678", "概览", "--chart-specs", "charts.json"],
["base12345678", "概览", "--chart-specs-file", "charts.json"],
),
(
["import-new", "base12345678", "data.csv"],
@@ -251,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")
@@ -260,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):