Compare commits

..
Author SHA1 Message Date
南润 71b45b1eab fix(aitable): harden multi skill workflows 2026-08-13 16:04:44 +08:00
5 changed files with 59 additions and 13 deletions
+5 -2
View File
@@ -58,12 +58,13 @@ metadata:
| 按名称找 Table | `dws aitable +resolve-table --base <baseId> --name "<表名>" --format json` | `baseId` 必须来自上一步真实返回 |
| 取表、字段与视图目录 | `dws aitable +table-get --base-id <baseId> [--table-ids <tableId>] --format json` | `tables[].fields[]` 是字段目录;完整类型/config 再用 `+field-get` |
| 取字段完整配置 | `dws aitable +field-get --base-id <baseId> --table-id <tableId> [--field-ids <ids>] --format json` | 写入前核对类型、只读性和 select options;按需展开以控制返回体 |
| 查/搜/筛记录 | `dws aitable +record-query --base-id <baseId> --table-id <tableId> [--query <词>\|--filters '<JSON>'\|--record-ids <ids>] --format json` | ID 模式忽略 filter/sort;全量结论必须完整分页 |
| 查/搜/筛记录 | `dws aitable +record-query --base-id <baseId> --table-id <tableId> [--query <词>\|--filters '<JSON>'\|--record-ids <ids>] --format json` | ID 模式忽略 filter/sort;不要猜 `--page-limit`,分页使用返回的 cursor 与 leaf Help 中的真实分页 flag |
| 新增记录 | `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;系统改名/加后缀时不得继续猜原名 |
| 复制视图 | `dws aitable view duplicate --base-id <baseId> --table-id <tableId> --view-id <viewId> --new-name "<名>"` | `viewId` 必须属于当前表;不能用复制 Table 或新建 Dashboard 替代 |
| 创建图表 | 先读 `dws aitable chart config-example --format json`,再执行 `chart create ... --config '<JSON>' --layout '<JSON>'` | `--layout` 是必填项;不能只传 config 后依据退出码声称图表已创建 |
| 批量追加 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;逐项检查成功/失败结果 |
@@ -74,9 +75,10 @@ metadata:
- `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,不把部分结果描述为全量。
- 记录分页不能凭经验拼 `--page-limit`。先用当前 leaf Help 确认 page-size/cursor 的真实名称;每页读取返回 cursor,直到明确终止。分页中断或局部富化失败时保留已有结果,输出 completeness 与逐项失败 ledger,不把部分结果描述为全量。
- 创建、更新、导入、批量建字段等写操作必须检查业务 `status`、逐项结果与返回 ID;普通写入按用户明确要求执行后回读,不能只凭退出码宣称成功。
- 长 JSON 使用 `--records-file` / 任务文件;不得为绕过字段错误而静默丢列、改类型或删除失败项。
- `table create --fields` 的键固定为 `fieldName` / `type` / `config`,不能写成 `name`;单选/多选类型固定为 `singleSelect` / `multipleSelect`,不能写 `select`。number formatter 不确定时先读 leaf Help,禁止猜 `INTEGER` 等值。
## 写入计划与验证
@@ -112,6 +114,7 @@ metadata:
- 命令非零、输出非 JSON、业务 `status != success`、必需 ID 缺失、批处理部分失败均视为失败;保留成功项与 ledger,禁止吞错。
- 同名歧义、权限不足、资源不存在、字段类型漂移、分页无法推进或 Schema/Help 冲突时停止并报告。具体恢复动作按需读 [aitable-error-recovery.md](references/aitable/aitable-error-recovery.md)。
- 已确认远端未写入且契约声明可重试时,才从最新实际输出重新提取 ID 后重试;写入状态未知、删除和其他 `confirmation=user_required` 操作不得自动重试或静默确认。
- 参数校验错误必须先吸收错误中的精确 hint,再最多纠正一次;不要重复猜同一 flag、formatter、folderId 或图表配置。目标能力已有原生命令时,不要以手工重建相似对象掩盖原命令失败。
## 跨产品协作
@@ -38,6 +38,13 @@
## 字段创建时设置 config(重要)
字段 JSON 的命名和枚举是严格契约:
- 批量 `--fields` 每项使用 `fieldName`,不是 `name`。
- 单选是 `singleSelect`,多选是 `multipleSelect`;不存在通用的 `select` 类型。
- number/date/currency formatter 只使用当前 `table create --help` 或 leaf Schema 列出的枚举;不要从别的产品或旧样例猜 `INTEGER`。
- 第一次返回明确 hint 后只按 hint 修正一次;第二次仍失败就停止并保留部分成功 ledger。
创建 singleSelect/multipleSelect 字段时,**必须设置选项 (options)**:
```bash
@@ -17,6 +17,7 @@ from __future__ import annotations
import argparse
import json
import re
import ssl
import subprocess
import sys
import time
@@ -26,7 +27,7 @@ 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}$")
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{6,128}$")
ALLOWED_FORMATS = {"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"}
@@ -64,7 +65,12 @@ def normalize_download_url(url: str) -> str:
def download_file(url: str, output_path: Path) -> Tuple[bool, str]:
req = Request(url, method="GET")
try:
with urlopen(req, timeout=180) as resp:
try:
import certifi
context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
context = ssl.create_default_context()
with urlopen(req, timeout=180, context=context) as resp:
redirected = urlparse(resp.geturl())
if redirected.scheme != "https" or not redirected.hostname:
return False, "download redirect is not HTTPS"
@@ -17,6 +17,7 @@ from __future__ import annotations
import argparse
import json
import re
import ssl
import subprocess
import sys
from pathlib import Path
@@ -25,7 +26,7 @@ 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}$")
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{6,128}$")
ALLOWED_EXTENSIONS = {".csv", ".xlsx", ".xls"}
@@ -33,6 +34,24 @@ def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def normalize_oss_upload_url(upload_url: str) -> str:
"""Accept the signed OSS URL returned by Runtime and upgrade it to HTTPS."""
parsed = urlparse(upload_url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("uploadUrl must be a valid HTTP(S) URL")
if not parsed.hostname.endswith(".oss.aliyuncs.com"):
raise ValueError("uploadUrl host is not an Aliyun OSS endpoint")
return parsed._replace(scheme="https").geturl()
def tls_context() -> ssl.SSLContext:
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
return ssl.create_default_context()
def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]:
cmd = [dws_bin] + args
try:
@@ -53,15 +72,16 @@ def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]:
parsed = urlparse(upload_url)
if parsed.scheme != "https" or not parsed.hostname:
return False, "uploadUrl must be a valid HTTPS URL"
try:
upload_url = normalize_oss_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")
# 关键:清空 Content-Type,避免 SignatureDoesNotMatch。
req.add_header("Content-Type", "")
try:
with urlopen(req, timeout=180) as resp:
with urlopen(req, timeout=180, context=tls_context()) as resp:
if resp.status == 200:
return True, ""
return False, f"unexpected HTTP status: {resp.status}"
@@ -25,13 +25,14 @@ import subprocess
import os
import mimetypes
import re
import ssl
from pathlib import Path
from typing import Optional, Dict, Any
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{6,128}$')
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
@@ -77,15 +78,24 @@ def run_dws(args: list, dws_bin: str = 'dws') -> Optional[Dict[str, Any]]:
def upload_to_oss(upload_url: str, file_path: Path, mime_type: str) -> bool:
"""通过 HTTP PUT 上传文件到 OSS。"""
parsed = urlparse(upload_url)
if parsed.scheme != 'https' or not parsed.hostname:
print('错误:uploadUrl 必须是有效的 HTTPS URL', file=sys.stderr)
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
print('错误:uploadUrl 必须是有效的 HTTP(S) URL', file=sys.stderr)
return False
if not parsed.hostname.endswith('.oss.aliyuncs.com'):
print('错误:uploadUrl 不是阿里云 OSS 地址', file=sys.stderr)
return False
upload_url = parsed._replace(scheme='https').geturl()
file_data = file_path.read_bytes()
req = Request(upload_url, data=file_data, method='PUT')
req.add_header('Content-Type', mime_type)
try:
with urlopen(req, timeout=120) as resp:
try:
import certifi
context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
context = ssl.create_default_context()
with urlopen(req, timeout=120, context=context) as resp:
if resp.status == 200:
return True
print(f"错误:OSS 上传失败,HTTP {resp.status}", file=sys.stderr)