209 lines
8.3 KiB
Go
209 lines
8.3 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
package smart
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||
)
|
||
|
||
// ResolveTable: resolve a 数据表 (table) inside one Base by name keyword into a
|
||
// single tableId.
|
||
//
|
||
// This is the table-level analogue of "resolve a Base by name". Because there is
|
||
// no server tool that searches tables by name, it lists every table in the Base
|
||
// via get_tables (baseId ← --base, verbatim from list_tables / helpers
|
||
// tableGetCmd) and then matches --name locally:
|
||
// - project each table to {tableId, name} — field parsing is defensive
|
||
// (multiple candidate keys);
|
||
// - filter locally by a case-insensitive substring match on name;
|
||
// - exactly one match → return {resolved:true, tableId, name, base};
|
||
// multiple matches → return {resolved:false, count, candidates} and let the
|
||
// caller pick (never guesses);
|
||
// zero matches → report a validation error instead of an empty raw dump.
|
||
//
|
||
// Read-only: it only lists and reshapes, never mutates any table.
|
||
//
|
||
// dws aitable +resolve-table --base B --name 任务
|
||
var ResolveTable = shortcut.Shortcut{
|
||
Service: "aitable",
|
||
Command: "+resolve-table",
|
||
Product: "aitable",
|
||
Description: "在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读)",
|
||
Intent: "当你已经知道某个多维表 Base 的 baseId、又只记得里面某张数据表(table)的名称或名称关键词、" +
|
||
"想把它解析成可直接用于后续工具的 tableId 时使用;" +
|
||
"内部先用 get_tables(只传 baseId)列出该 Base 下的全部数据表,再在本地把每张表投影成 tableId、name," +
|
||
"并按 --name 关键词做大小写不敏感的包含匹配来筛选候选。" +
|
||
"如果只命中一张表就直接返回它的 tableId;如果命中多张则列出全部候选让你消歧,绝不替你瞎猜;如果一张都没命中则提示未找到。" +
|
||
"这是纯只读操作,只做列举、本地匹配与投影,不会创建、修改或删除任何数据表。",
|
||
Risk: shortcut.RiskRead,
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: corecmd.ContractDecl{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "aitable",
|
||
Name: "shortcut_resolve_table",
|
||
CanonicalPath: "aitable.shortcut_resolve_table",
|
||
CLIPath: "aitable +resolve-table",
|
||
PrimaryCLIPath: "aitable +resolve-table",
|
||
},
|
||
Description: "在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读)",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读)",
|
||
UseWhen: []string{"当你已经知道某个多维表 Base 的 baseId、又只记得里面某张数据表(table)的名称或名称关键词、想把它解析成可直接用于后续工具的 tableId 时使用;内部先用 get_tables(只传 baseId)列出该 Base 下的全部数据表,再在本地把每张表投影成 tableId、name,并按 --name 关键词做大小写不敏感的包含匹配来筛选候选。如果只命中一张表就直接返回它的 tableId;如果命中多张则列出全部候选让你消歧,绝不替你瞎猜;如果一张都没命中则提示未找到。这是纯只读操作,只做列举、本地匹配与投影,不会创建、修改或删除任何数据表。"},
|
||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||
Examples: []string{"dws aitable +resolve-table --base B --name 任务"},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "base", Type: shortcut.FlagString, Desc: "Base ID(要在其内解析数据表的多维表)", Required: true},
|
||
{Name: "name", Type: shortcut.FlagString, Desc: "要匹配的数据表名称关键词(必填)", Required: true},
|
||
},
|
||
Tips: []string{
|
||
`dws aitable +resolve-table --base B --name 任务`,
|
||
},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
// baseId mirrors list_tables / helpers tableGetCmd (get_tables). Omitting
|
||
// tableIds asks the server for every table in the base.
|
||
data, err := rt.CallMCPData("aitable", "get_tables", map[string]any{
|
||
"baseId": rt.Str("base"),
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Project every table to {tableId, name}, defensively unwrapping the list.
|
||
items := resolveTableItems(data)
|
||
all := make([]map[string]any, 0, len(items))
|
||
for _, t := range items {
|
||
all = append(all, map[string]any{
|
||
"tableId": resolveTableID(t),
|
||
"name": resolveTableName(t),
|
||
})
|
||
}
|
||
|
||
// Filter locally by case-insensitive substring match on name.
|
||
needle := strings.ToLower(rt.Str("name"))
|
||
candidates := make([]map[string]any, 0, len(all))
|
||
for _, c := range all {
|
||
name, _ := c["name"].(string)
|
||
if strings.Contains(strings.ToLower(name), needle) {
|
||
candidates = append(candidates, c)
|
||
}
|
||
}
|
||
|
||
switch len(candidates) {
|
||
case 0:
|
||
return apperrors.NewValidation("Base 内没有名称包含 " + rt.Str("name") + " 的数据表")
|
||
case 1:
|
||
return rt.Output(map[string]any{
|
||
"resolved": true,
|
||
"tableId": candidates[0]["tableId"],
|
||
"name": candidates[0]["name"],
|
||
"base": rt.Str("base"),
|
||
})
|
||
default:
|
||
return rt.Output(map[string]any{
|
||
"resolved": false,
|
||
"count": len(candidates),
|
||
"candidates": candidates,
|
||
})
|
||
}
|
||
},
|
||
}
|
||
|
||
// resolveTableItems defensively unwraps the list of tables from a get_tables
|
||
// response, tolerating the common container keys the gateway may use.
|
||
func resolveTableItems(data map[string]any) []map[string]any {
|
||
if data == nil {
|
||
return nil
|
||
}
|
||
for _, key := range []string{"result", "data", "list", "items", "tables", "records"} {
|
||
raw, ok := data[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if list, ok := raw.([]any); ok {
|
||
out := make([]map[string]any, 0, len(list))
|
||
for _, e := range list {
|
||
if m, ok := e.(map[string]any); ok {
|
||
out = append(out, m)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
// Nested container, e.g. {"data":{"list":[...]}}.
|
||
if nested, ok := raw.(map[string]any); ok {
|
||
if inner := resolveTableItems(nested); len(inner) > 0 {
|
||
return inner
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// resolveTableID reads a table's identifier, tolerating the common id keys.
|
||
func resolveTableID(t map[string]any) string {
|
||
for _, key := range []string{"tableId", "table_id", "id"} {
|
||
if s := resolveTableString(t[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// resolveTableName reads a table's display name, tolerating the common name keys.
|
||
func resolveTableName(t map[string]any) string {
|
||
for _, key := range []string{"name", "tableName", "table_name", "title"} {
|
||
if s := resolveTableString(t[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// resolveTableString coerces a scalar JSON value to a trimmed string, returning
|
||
// "" for nil / non-scalar / empty values.
|
||
func resolveTableString(v any) string {
|
||
switch typed := v.(type) {
|
||
case string:
|
||
return strings.TrimSpace(typed)
|
||
case float64:
|
||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||
case int:
|
||
return strconv.Itoa(typed)
|
||
case int64:
|
||
return strconv.FormatInt(typed, 10)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func init() {
|
||
shortcut.Register(ResolveTable)
|
||
}
|