diff --git a/shortcuts/base/workflow_ai_analysis_validate.go b/shortcuts/base/workflow_ai_analysis_validate.go new file mode 100644 index 0000000000..1dd79a6e5a --- /dev/null +++ b/shortcuts/base/workflow_ai_analysis_validate.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import "fmt" + +const aiAnalysisActionType = "AIAnalysisAction" + +var aiAnalysisIdentityTypes = map[string]bool{ + "maker": true, + "triggerPersonal": true, +} + +func validateWorkflowAIAnalysisAction(stepIndex int, step map[string]interface{}) error { + data, ok := step["data"].(map[string]interface{}) + if !ok { + return nil + } + if value, exists := data["analysis_table_names"]; exists { + items, ok := value.([]interface{}) + if !ok { + return baseFlagErrorf("%s must be a string array", workflowJSONPath(stepIndex, "analysis_table_names")) + } + for itemIndex, item := range items { + if _, ok := item.(string); !ok { + return baseFlagErrorf("%s[%d] must be a string", workflowJSONPath(stepIndex, "analysis_table_names"), itemIndex) + } + } + } + if value, exists := data["identity_type"]; exists { + identityType, ok := value.(string) + if !ok { + return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type")) + } + if !aiAnalysisIdentityTypes[identityType] { + return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type")) + } + } + return nil +} + +func workflowJSONPath(stepIndex int, suffix string) string { + return fmt.Sprintf("--json.steps[%d].data.%s", stepIndex, suffix) +} diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go new file mode 100644 index 0000000000..2737983614 --- /dev/null +++ b/shortcuts/base/workflow_ai_classification_validate.go @@ -0,0 +1,226 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "fmt" + "strings" +) + +const ( + workflowAIClassificationStepType = "AIClassificationBranch" + workflowAIClassificationDefaultNoMatchAction = "classifyToOther" +) + +func indexWorkflowStepIDs(steps []interface{}) map[string]int { + stepIDs := make(map[string]int, len(steps)) + for i, raw := range steps { + step, _ := raw.(map[string]interface{}) + id, _ := step["id"].(string) + if strings.TrimSpace(id) != "" { + stepIDs[id] = i + } + } + return stepIDs +} + +func validateWorkflowAIClassificationStep(index int, step map[string]interface{}, stepIDs map[string]int) error { + path := fmt.Sprintf("--json steps[%d]", index) + data, ok := step["data"].(map[string]interface{}) + if !ok || data == nil { + return baseValidationErrorf("%s.data must be an object for AIClassificationBranch", path) + } + classes, err := validateAIClassificationAgentData(path, data, index, stepIDs) + if err != nil { + return err + } + return validateAIClassificationLinks(path, step, stepIDs, classes, aiClassificationNoMatchAction(data)) +} + +func validateAIClassificationAgentData(path string, data map[string]interface{}, stepIndex int, stepIDs map[string]int) ([]string, error) { + if _, ok := data["mode"]; ok { + return nil, baseValidationErrorf("%s.data.mode is not supported; omit it because AI classification only supports Exclusive mode", path) + } + + rawClasses, ok := data["classes"].([]interface{}) + if !ok { + return nil, baseValidationErrorf("%s.data.classes must be an array", path) + } + if len(rawClasses) < 2 { + return nil, baseValidationErrorf("%s.data.classes must contain at least 2 items", path) + } + classes := make([]string, 0, len(rawClasses)) + seen := map[string]int{} + for i, raw := range rawClasses { + classPath := fmt.Sprintf("%s.data.classes[%d]", path, i) + item, ok := raw.(map[string]interface{}) + if !ok { + return nil, baseValidationErrorf("%s must be an object", classPath) + } + name, _ := item["name"].(string) + name = strings.TrimSpace(name) + if name == "" { + return nil, baseValidationErrorf("%s.name must be a non-empty string", classPath) + } + if strings.ContainsAny(name, "\r\n") { + return nil, baseValidationErrorf("%s.name must not contain newlines", classPath) + } + if prev, exists := seen[name]; exists { + return nil, baseValidationErrorf("%s.name duplicates %s.data.classes[%d].name", classPath, path, prev) + } + if _, ok := item["desc"].(string); !ok { + return nil, baseValidationErrorf("%s.desc must be a string", classPath) + } + seen[name] = i + classes = append(classes, name) + } + + if err := validateAIClassificationContent(path, data["content"], stepIndex, stepIDs); err != nil { + return nil, err + } + if rule, ok := data["classification_rule"]; ok { + if _, ok := rule.(string); !ok { + return nil, baseValidationErrorf("%s.data.classification_rule must be a string when set", path) + } + } + if actionRaw, ok := data["no_match_action"]; ok { + action, ok := actionRaw.(string) + if !ok || strings.TrimSpace(action) == "" { + return nil, baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail when set", path) + } + if action != "classifyToOther" && action != "fail" { + return nil, baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail when set", path) + } + } + return classes, nil +} + +func validateAIClassificationContent(path string, raw interface{}, stepIndex int, stepIDs map[string]int) error { + items, ok := raw.([]interface{}) + if !ok { + return baseValidationErrorf("%s.data.content must be an array", path) + } + hasContent := false + for i, rawItem := range items { + itemPath := fmt.Sprintf("%s.data.content[%d]", path, i) + item, ok := rawItem.(map[string]interface{}) + if !ok { + return baseValidationErrorf("%s must be an object", itemPath) + } + valueType, _ := item["value_type"].(string) + value, _ := item["value"].(string) + switch valueType { + case "text": + if strings.TrimSpace(value) != "" { + hasContent = true + } + case "ref": + refStep, ok := workflowRefStepID(value) + if !ok { + return baseValidationErrorf("%s.value must be a workflow ref path starting with $.", itemPath) + } + refIndex, exists := stepIDs[refStep] + if !exists { + return baseValidationErrorf("%s.value references unknown step id %q", itemPath, refStep) + } + if refIndex >= stepIndex { + return baseValidationErrorf("%s.value must reference a previous step, got %q", itemPath, refStep) + } + hasContent = true + default: + return baseValidationErrorf("%s.value_type must be text or ref", itemPath) + } + } + if !hasContent { + return baseValidationErrorf("%s.data.content must contain non-empty text or ref", path) + } + return nil +} + +func validateAIClassificationLinks(path string, step map[string]interface{}, stepIDs map[string]int, classes []string, noMatchAction string) error { + children, _ := step["children"].(map[string]interface{}) + links, _ := children["links"].([]interface{}) + if len(links) == 0 { + return baseValidationErrorf("%s.children.links must contain one non-empty case link for each class", path) + } + + ordinaryLinks := 0 + defaultLinks := 0 + seenTargets := map[string]int{} + for i, raw := range links { + linkPath := fmt.Sprintf("%s.children.links[%d]", path, i) + link, ok := raw.(map[string]interface{}) + if !ok { + return baseValidationErrorf("%s must be an object", linkPath) + } + kind, _ := link["kind"].(string) + if kind != "case" { + return baseValidationErrorf("%s.kind must be case for AIClassificationBranch", linkPath) + } + label, _ := link["label"].(string) + if label == "other" { + return baseValidationErrorf("%s.label must be default for AIClassificationBranch default branch, not other", linkPath) + } + to, _ := link["to"].(string) + to = strings.TrimSpace(to) + if to == "" { + return baseValidationErrorf("%s.to must not be blank", linkPath) + } + if _, ok := stepIDs[to]; !ok { + return baseValidationErrorf("%s.to references unknown step id %q", linkPath, to) + } + if prev, exists := seenTargets[to]; exists { + return baseValidationErrorf("%s.to duplicates %s.children.links[%d].to", linkPath, path, prev) + } + seenTargets[to] = i + + if label == "default" { + defaultLinks++ + continue + } + wantLabel := fmt.Sprintf("branch_%d", ordinaryLinks+1) + if label != wantLabel { + return baseValidationErrorf("%s.label must be %s for AIClassificationBranch class link", linkPath, wantLabel) + } + desc, _ := link["desc"].(string) + if ordinaryLinks < len(classes) && desc != classes[ordinaryLinks] { + return baseValidationErrorf("%s.desc must equal --json steps data.classes[%d].name", linkPath, ordinaryLinks) + } + ordinaryLinks++ + } + if ordinaryLinks != len(classes) { + return baseValidationErrorf("%s.children.links must contain one non-empty case link for each class", path) + } + if noMatchAction == "classifyToOther" && defaultLinks != 1 { + return baseValidationErrorf("%s.children.links must contain exactly one default link when no_match_action is classifyToOther", path) + } + if noMatchAction == "fail" && defaultLinks != 0 { + return baseValidationErrorf("%s.children.links must not contain a default link when no_match_action is fail", path) + } + return nil +} + +func aiClassificationNoMatchAction(data map[string]interface{}) string { + action, _ := data["no_match_action"].(string) + action = strings.TrimSpace(action) + if action == "" { + return workflowAIClassificationDefaultNoMatchAction + } + return action +} + +func workflowRefStepID(value string) (string, bool) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "$.") { + return "", false + } + value = strings.TrimPrefix(value, "$.") + if value == "" { + return "", false + } + if idx := strings.Index(value, "."); idx >= 0 { + value = value[:idx] + } + return value, value != "" +} diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index 9b6084a901..cd9de3bcb8 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -33,21 +33,15 @@ var BaseWorkflowCreate = common.Shortcut{ if strings.TrimSpace(runtime.Str("base-token")) == "" { return baseFlagErrorf("--base-token must not be blank") } - pc := newParseCtx(runtime) - raw, err := loadJSONInput(pc, runtime.Str("json"), "json") - if err != nil { - return err - } - if _, err := parseJSONObject(pc, raw, "json"); err != nil { + if _, err := parseWorkflowBodyJSON(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - pc := newParseCtx(runtime) var body map[string]interface{} - if raw, err := loadJSONInput(pc, runtime.Str("json"), "json"); err == nil { - body, _ = parseJSONObject(pc, raw, "json") + if parsed, err := parseWorkflowBodyJSON(runtime); err == nil { + body = parsed } return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/workflows"). @@ -55,12 +49,7 @@ var BaseWorkflowCreate = common.Shortcut{ Set("base_token", runtime.Str("base-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - pc := newParseCtx(runtime) - raw, err := loadJSONInput(pc, runtime.Str("json"), "json") - if err != nil { - return err - } - body, err := parseJSONObject(pc, raw, "json") + body, err := parseWorkflowBodyJSON(runtime) if err != nil { return err } diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index ad7b0866c4..48fd28f856 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -107,6 +107,57 @@ func TestBaseWorkflowExecuteCreate(t *testing.T) { } } +func TestBaseWorkflowExecuteCreatePreservesAIClassificationAgentData(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_ai", "title": "Feedback classify"}, + }, + } + reg.Register(stub) + + body := `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {"table_name": "Feedback"}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links": [ + {"kind": "case", "label": "branch_1", "desc": "Bug", "to": "step_bug"}, + {"kind": "case", "label": "branch_2", "desc": "Feature", "to": "step_feature"}, + {"kind": "case", "label": "default", "desc": "默认分支", "to": "step_other"} + ]}, + "data": { + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [ + {"value_type": "text", "value": "Classify feedback: "}, + {"value_type": "ref", "value": "$.step_trigger.fldFeedback"} + ], + "classification_rule": "Use Other when unsure.", + "no_match_action": "classifyToOther", + "future_server_field": {"keep": true} + } + }, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} + ] + }` + if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", body}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := string(stub.CapturedBody); !strings.Contains(got, `"type":"AIClassificationBranch"`) || !strings.Contains(got, `"future_server_field":{"keep":true}`) { + t.Fatalf("AI classification payload was not forwarded verbatim enough: %s", got) + } +} + func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { t.Run("missing base-token", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) @@ -124,6 +175,473 @@ func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { }) } +func TestBaseWorkflowExecuteValidateAIClassificationAgentData(t *testing.T) { + base := func(data string, children string) string { + return `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + {"id": "step_classify", "type": "AIClassificationBranch", "children": ` + children + `, "data": ` + data + `}, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} + ] + }` + } + validChildren := `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"}]}` + validData := `{ + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [{"value_type": "text", "value": "Classify"}], + "classification_rule": "Use the closest category.", + "no_match_action": "fail" + }` + + tests := []struct { + name string + body string + want string + }{ + { + name: "draft data is not public protocol", + body: base(`{"prompt":[{"value_type":"text","value":"Classify"}],"childBranchList":[{"name":"Bug"},{"name":"Feature"}],"no_match_action":"fail"}`, validChildren), + want: "data.classes must be an array", + }, + { + name: "exclusive mode is not public input", + body: base(strings.Replace(validData, `"classes": [`, `"mode": "Exclusive", "classes": [`, 1), validChildren), + want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode", + }, + { + name: "parallel mode is not public input", + body: base(strings.Replace(validData, `"classes": [`, `"mode": "Parallel", "classes": [`, 1), validChildren), + want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode", + }, + { + name: "empty mode is not public input", + body: base(strings.Replace(validData, `"classes": [`, `"mode": "", "classes": [`, 1), validChildren), + want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode", + }, + { + name: "non string mode is not public input", + body: base(strings.Replace(validData, `"classes": [`, `"mode": true, "classes": [`, 1), validChildren), + want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode", + }, + { + name: "empty links", + body: base(validData, `{"links":[]}`), + want: "children.links must contain one non-empty case link for each class", + }, + { + name: "other default label", + body: base(strings.Replace(validData, `"no_match_action": "fail"`, `"no_match_action": "classifyToOther"`, 1), `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"},{"kind":"case","label":"other","desc":"其他","to":"step_other"}]}`), + want: "label must be default", + }, + { + name: "missing no match action still requires default link", + body: base(strings.Replace(validData, `, + "no_match_action": "fail"`, "", 1), validChildren), + want: "children.links must contain exactly one default link when no_match_action is classifyToOther", + }, + { + name: "class link count mismatch", + body: base(validData, `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"}]}`), + want: "children.links must contain one non-empty case link for each class", + }, + { + name: "class link desc mismatch", + body: base(validData, `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Mismatch","to":"step_feature"}]}`), + want: "desc must equal --json steps data.classes[1].name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", tt.body}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want substring %q", err, tt.want) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("err type=%T want *errs.ValidationError", err) + } + }) + } +} + +func TestBaseWorkflowExecuteValidateAIClassificationOptionalModeAndNoMatchAction(t *testing.T) { + base := func(data string, children string) string { + return `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + {"id": "step_classify", "type": "AIClassificationBranch", "children": ` + children + `, "data": ` + data + `}, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} + ] + }` + } + data := `{ + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [{"value_type": "text", "value": "Classify"}], + "classification_rule": "Use the closest category." + }` + children := `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"},{"kind":"case","label":"default","desc":"默认分支","to":"step_other"}]}` + + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_ai", "title": "Feedback classify"}, + }, + } + reg.Register(stub) + if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", base(data, children)}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := string(stub.CapturedBody) + if strings.Contains(got, `"mode"`) || strings.Contains(got, `"no_match_action"`) { + t.Fatalf("AI classification optional fields should not be injected by CLI: %s", got) + } +} + +func TestBaseWorkflowExecuteUpdateRejectsAIClassificationMode(t *testing.T) { + base := func(mode string) string { + return `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links":[ + {"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"}, + {"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"} + ]}, + "data": { + "mode": "` + mode + `", + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [{"value_type": "text", "value": "Classify"}], + "classification_rule": "Use the closest category.", + "no_match_action": "fail" + } + }, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}} + ] + }` + } + + for _, mode := range []string{"Exclusive", "Parallel"} { + t.Run(mode, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", base(mode)}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "data.mode is not supported; omit it because AI classification only supports Exclusive mode") { + t.Fatalf("err=%v", err) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("err type=%T want *errs.ValidationError", err) + } + }) + } +} + +func TestBaseWorkflowExecuteUpdatePreservesAIClassificationWithoutMode(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Feedback classify"}, + }, + } + reg.Register(stub) + + body := `{ + "title": "Feedback classify", + "status": "disabled", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links":[ + {"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"}, + {"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"}, + {"kind":"case","label":"default","desc":"默认分支","to":"step_other"} + ]}, + "data": { + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [{"value_type": "text", "value": "Classify"}], + "classification_rule": "Use the closest category." + } + }, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} + ] + }` + if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := string(stub.CapturedBody); strings.Contains(got, `"mode"`) || strings.Contains(got, `"no_match_action"`) || !strings.Contains(got, `"classes":[`) { + t.Fatalf("AI classification payload should be forwarded without injected optional fields: %s", got) + } +} + +func TestBaseWorkflowExecuteUpdateValidatesAIClassificationNoMatchActionTopology(t *testing.T) { + body := func(noMatchAction string, links string) string { + return `{ + "title": "Language classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links":` + links + `}, + "data": { + "classes": [ + {"name": "English", "desc": "English text"}, + {"name": "Chinese", "desc": "Chinese text"} + ], + "content": [{"value_type": "text", "value": "Classify"}]` + noMatchAction + ` + } + }, + {"id": "step_english", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_chinese", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other_2", "type": "SetRecordAction", "next": null, "data": {}} + ] + }` + } + caseLinks := `[ + {"kind":"case","label":"branch_1","desc":"English","to":"step_english"}, + {"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"} + ]` + defaultLinks := `[ + {"kind":"case","label":"branch_1","desc":"English","to":"step_english"}, + {"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"}, + {"kind":"case","label":"default","desc":"Other","to":"step_other"} + ]` + multipleDefaultLinks := `[ + {"kind":"case","label":"branch_1","desc":"English","to":"step_english"}, + {"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"}, + {"kind":"case","label":"default","desc":"Other","to":"step_other"}, + {"kind":"case","label":"default","desc":"Other 2","to":"step_other_2"} + ]` + + tests := []struct { + name string + noMatchAction string + links string + want string + wantForwarded string + }{ + { + name: "rejects omitted action without default link", + links: caseLinks, + want: "children.links must contain exactly one default link when no_match_action is classifyToOther", + }, + { + name: "accepts omitted action with one default link without injection", + links: defaultLinks, + wantForwarded: `"label":"default"`, + }, + { + name: "accepts explicit fail without default link", + noMatchAction: `,"no_match_action":"fail"`, + links: caseLinks, + wantForwarded: `"no_match_action":"fail"`, + }, + { + name: "rejects explicit fail with default link", + noMatchAction: `,"no_match_action":"fail"`, + links: defaultLinks, + want: "children.links must not contain a default link when no_match_action is fail", + }, + { + name: "accepts explicit classifyToOther with one default link", + noMatchAction: `,"no_match_action":"classifyToOther"`, + links: defaultLinks, + wantForwarded: `"no_match_action":"classifyToOther"`, + }, + { + name: "rejects explicit classifyToOther without default link", + noMatchAction: `,"no_match_action":"classifyToOther"`, + links: caseLinks, + want: "children.links must contain exactly one default link when no_match_action is classifyToOther", + }, + { + name: "rejects omitted action with multiple default links", + links: multipleDefaultLinks, + want: "children.links must contain exactly one default link when no_match_action is classifyToOther", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowUpdate, []string{ + "+workflow-update", + "--base-token", "app_x", + "--workflow-id", "wkf_1", + "--json", body(tt.noMatchAction, tt.links), + "--dry-run", + "--format", "pretty", + }, factory, stdout) + if tt.want != "" { + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want substring %q", err, tt.want) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("err type=%T want *errs.ValidationError", err) + } + return + } + if err != nil { + t.Fatalf("dry-run err=%v", err) + } + dryRun := stdout.String() + if !strings.Contains(dryRun, "PUT /open-apis/base/v3/bases/app_x/workflows/wkf_1") || !strings.Contains(dryRun, tt.wantForwarded) { + t.Fatalf("dry-run did not preserve the validated request: %s", dryRun) + } + if tt.noMatchAction == "" && strings.Contains(dryRun, `"no_match_action"`) { + t.Fatalf("dry-run must not inject omitted no_match_action: %s", dryRun) + } + }) + } +} + +func TestBaseWorkflowExecuteCreateValidateAIAnalysisData(t *testing.T) { + t.Run("rejects table names string", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":"订单表","identity_type":"maker"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "steps[0].data.analysis_table_names") + }) + t.Run("rejects table names item type", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表",1],"identity_type":"maker"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "steps[0].data.analysis_table_names[1]") + }) + t.Run("rejects identity type enum", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"unknownIdentity"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maker, triggerPersonal") + if !strings.Contains(err.Error(), "steps[0].data.identity_type") { + t.Fatalf("err=%v, want field path", err) + } + }) + t.Run("accepts maker", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_new"}, + }, + }) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"maker"}}]}`}, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + }) + t.Run("accepts triggerPersonal", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_new"}, + }, + }) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"triggerPersonal"}}]}`}, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseWorkflowExecuteUpdateValidateAIAnalysisData(t *testing.T) { + t.Run("rejects table names string", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":"订单表","identity_type":"maker"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "steps[0].data.analysis_table_names") + }) + t.Run("rejects identity type enum", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"unknownIdentity"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maker, triggerPersonal") + if !strings.Contains(err.Error(), "steps[0].data.identity_type") { + t.Fatalf("err=%v, want field path", err) + } + }) +} + +func TestBaseWorkflowExecuteUpdatePreservesOmittedSteps(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Only Title"}, + }, + } + reg.Register(stub) + if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"title":"Only Title"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body invalid JSON: %v", err) + } + if steps, ok := body["steps"]; ok { + t.Fatalf("request steps=%#v, want field omitted", steps) + } +} + +func TestBaseWorkflowDryRunUpdatePreservesOmittedSteps(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{ + "+workflow-update", + "--base-token", "app_x", + "--workflow-id", "wkf_1", + "--json", `{"title":"Only Title"}`, + "--dry-run", + "--format", "pretty", + } + if err := runShortcut(t, BaseWorkflowUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "PUT /open-apis/base/v3/bases/app_x/workflows/wkf_1") || !strings.Contains(got, `"title":"Only Title"`) { + t.Fatalf("stdout=%s", got) + } + if strings.Contains(got, `"steps":`) { + t.Fatalf("dry-run injected omitted steps: %s", got) + } +} + func TestBaseWorkflowExecuteDisable(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/workflow_json_validation.go b/shortcuts/base/workflow_json_validation.go new file mode 100644 index 0000000000..90e77b7aea --- /dev/null +++ b/shortcuts/base/workflow_json_validation.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import "github.com/larksuite/cli/shortcuts/common" + +func parseWorkflowBodyJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { + pc := newParseCtx(runtime) + body, err := parseJSONObject(pc, runtime.Str("json"), "json") + if err != nil { + return nil, err + } + if err := validateWorkflowBodyForCLI(body); err != nil { + return nil, err + } + return body, nil +} + +func validateWorkflowBodyForCLI(body map[string]interface{}) error { + steps, ok := body["steps"].([]interface{}) + if !ok { + return nil + } + + var stepIDs map[string]int + for stepIndex, rawStep := range steps { + step, ok := rawStep.(map[string]interface{}) + if !ok { + continue + } + + stepType, _ := step["type"].(string) + switch stepType { + case aiAnalysisActionType: + if err := validateWorkflowAIAnalysisAction(stepIndex, step); err != nil { + return err + } + case workflowAIClassificationStepType: + if stepIDs == nil { + stepIDs = indexWorkflowStepIDs(steps) + } + if err := validateWorkflowAIClassificationStep(stepIndex, step, stepIDs); err != nil { + return err + } + } + } + + return nil +} diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index d0b2c1caeb..f367f0720f 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -38,16 +38,14 @@ var BaseWorkflowUpdate = common.Shortcut{ if strings.TrimSpace(runtime.Str("workflow-id")) == "" { return baseFlagErrorf("--workflow-id must not be blank") } - pc := newParseCtx(runtime) - if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil { + if _, err := parseWorkflowBodyJSON(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - pc := newParseCtx(runtime) var body map[string]interface{} - body, _ = parseJSONObject(pc, runtime.Str("json"), "json") + body, _ = parseWorkflowBodyJSON(runtime) return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). Body(body). @@ -55,8 +53,7 @@ var BaseWorkflowUpdate = common.Shortcut{ Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - pc := newParseCtx(runtime) - body, err := parseJSONObject(pc, runtime.Str("json"), "json") + body, err := parseWorkflowBodyJSON(runtime) if err != nil { return err } diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index b79acbc41d..9769f66deb 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -125,6 +125,7 @@ | `Delay` | 延迟 | | `LarkMessageAction` | 发送飞书消息 | | `GenerateAiTextAction` | AI 生成文本 | +| `AIAnalysisAction` | AI 分析 | > 所有 Action 节点**请勿设置** `children` ,通过 `next` 串联后继。 @@ -134,6 +135,7 @@ |------|------| | `IfElseBranch` | 条件分支,`children.links` 含 `if_true` 和 `if_false` | | `SwitchBranch` | 多路分支,`children.links` 含多个 `case` | +| `AIClassificationBranch` | AI 分类分支,`children.links` 含多个 `case` | ### System 类型 @@ -473,6 +475,26 @@ |------|------|------| | `prompt` | 是 | TextRefItem[] 提示词,支持 `text` / `ref` | +### AIAnalysisAction + +```json +{ + "analysis_task": [ + { "value_type": "text", "value": "分析昨日订单趋势、异常原因,并给出行动建议" } + ], + "analysis_table_names": ["订单表", "退款表"], + "identity_type": "maker", + "output_instruction": "先给结论,再列证据与行动建议" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `analysis_task` | 是 | TextRefItem[] 分析任务,支持 `text` / `ref` 混排;至少包含一项有效内容 | +| `analysis_table_names` | 否 | string[] 分析数据范围;为空数组 `[]` 或省略时表示当前 Base 的全部数据表 | +| `identity_type` | 是 | 数据访问身份:`maker`(固定流程身份) / `triggerPersonal`(流程触发者) | +| `output_instruction` | 否 | 仅支持纯文本 | + ## Branch data 详细结构 @@ -552,6 +574,45 @@ | `name` | string | 分支名称 | | `condition` | OrGroup | 分支条件 | +### AIClassificationBranch + +`AIClassificationBranch` 用 AI 对 `content` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`steps[].data` 使用公开 Agent Data 协议。 + +```json +{ + "classes": [ + { + "name": "Bug", + "desc": "功能报错、异常、不可用或结果错误" + }, + { + "name": "功能建议", + "desc": "希望新增能力或优化现有功能" + } + ], + "content": [ + { "value_type": "text", "value": "请根据反馈内容判断类型:" }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedback" } + ], + "classification_rule": "信息不足时判定为无法匹配。" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|----------------------------------------------------------------------| +| `classes` | 是 | 分类列表,至少 2 项。每项包含 `name` 和 `desc` | +| `classes[].name` | 是 | 分类名称,需与对应普通 `children.links[].desc` 保持一致 | +| `classes[].desc` | 是 | 分类描述,可为空字符串,但字段必须存在 | +| `content` | 是 | TextRefItem[],用于分类的内容,支持 `text` / `ref` | +| `classification_rule` | 否 | 全局分类规则纯文本 | +| `no_match_action` | 否 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败。省略时使用 `classifyToOther` | + +`children.links` 规则: +- 每个分类命中后要跳到哪个后续步骤,必须写在 children.links 中。 +- 普通分类边使用 `kind: "case"` 和 `label: "branch_1"`、`branch_2` 等稳定标签;`desc` 与 `classes[i].name` 保持一致;`to` 指向该分类的入口 step。 +- `no_match_action: "classifyToOther"` 时必须额外提供一条默认分支边:`{ "kind": "case", "label": "default", "desc": "默认分支", "to": "step_other_action" }`。 +- `no_match_action: "fail"` 时不要提供默认分支边。 + ## System data 详细结构 @@ -788,6 +849,12 @@ HTTPClientAction 的输出取决于 `response_type`: |--------|------|----------| | (整体出参) | AI 生成的文本内容(不支持下钻,只能引用 `$.{stepId}`) | `$.{stepId}` | +##### AIAnalysisAction(AI 分析) + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `analysisResult` | AI 分析结果字符串 | `$.{stepId}.analysisResult` | + ##### 无输出的操作节点 以下节点不产生任何可引用的输出数据: @@ -887,6 +954,7 @@ $.{stepId}.{fieldId}.fileToken → 文件 Token 列表(array,仅 | SetRecordAction | 动作 | ✅ | 动态(用户配置的字段) | | HTTPClientAction | 动作 | ✅ | 动态(取决于用户配置的 HTTP 响应输出) | | GenerateAiTextAction | 动作 | ✅ | 静态(单 string) | +| AIAnalysisAction | 动作 | ✅ | 静态(`analysisResult`) | | Delay | 动作 | ❌ | 无输出 | | LarkMessageAction | 动作 | ❌ | 无输出 | | IfElseBranch | 分支 | ❌ | 无输出 | diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 24d40f4707..dfb7edb0a6 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -57,9 +57,10 @@ | 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例1-新增记录触发--发送消息) | | 按钮点击+调用外部接口+写入日志 | ButtonTrigger → HTTPClientAction → AddRecordAction | [下方](#示例-6-按钮触发--调用外部接口--写入同步日志) | | 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例2-定时触发--查找记录--循环遍历--发送消息) | -| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支-ifelsebranch) | -| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例4-多路分支-switchbranch) | -| 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例5-组合场景-定时查找循环分支消息) | +| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支ifelsebranch) | +| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例4-多路分支switchbranch) | +| 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例5-组合场景定时查找循环分支消息) | +| AI 分类 | ... → AIClassificationBranch → 分类后处理 | [下方](#示例7-ai-分类用户反馈自动分流) | --- @@ -741,6 +742,101 @@ --- +### 示例 7: AI 分类(用户反馈自动分流) + +**场景**: 当用户反馈表新增记录时,AI 根据反馈内容分类为 Bug 或功能建议;无法判断时标记为待人工复核。 + +```json +{ + "client_token": "1704067206", + "title": "用户反馈自动分流", + "steps": [ + { + "id": "step_trigger", + "type": "AddRecordTrigger", + "title": "新增反馈时触发", + "next": "step_ai_classify", + "data": { + "table_name": "用户反馈表", + "watched_field_name": "反馈详情" + } + }, + { + "id": "step_ai_classify", + "type": "AIClassificationBranch", + "title": "AI 判断反馈类型", + "children": { + "links": [ + { "kind": "case", "to": "step_bug_action", "label": "branch_1", "desc": "Bug" }, + { "kind": "case", "to": "step_feature_action", "label": "branch_2", "desc": "功能建议" }, + { "kind": "case", "to": "step_other_action", "label": "default", "desc": "默认分支" } + ] + }, + "next": null, + "data": { + "classes": [ + { + "name": "Bug", + "desc": "功能报错、异常、崩溃、无法使用或结果错误" + }, + { + "name": "功能建议", + "desc": "希望新增能力或改变产品行为" + } + ], + "content": [ + { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } + ], + "classification_rule": "有明确故障现象时优先归入 Bug;同时包含多个诉求时,以最影响用户完成任务的问题为准;信息不足时进入默认分支。" + } + }, + { + "id": "step_bug_action", + "type": "SetRecordAction", + "title": "标记为 Bug", + "next": null, + "data": { + "table_name": "用户反馈表", + "ref_info": { "step_id": "step_trigger" }, + "field_values": [ + { "field_name": "分类", "value": [{ "value_type": "text", "value": "Bug" }] } + ] + } + }, + { + "id": "step_feature_action", + "type": "SetRecordAction", + "title": "标记为功能建议", + "next": null, + "data": { + "table_name": "用户反馈表", + "ref_info": { "step_id": "step_trigger" }, + "field_values": [ + { "field_name": "分类", "value": [{ "value_type": "text", "value": "功能建议" }] } + ] + } + }, + { + "id": "step_other_action", + "type": "SetRecordAction", + "title": "标记为待人工复核", + "next": null, + "data": { + "table_name": "用户反馈表", + "ref_info": { "step_id": "step_trigger" }, + "field_values": [ + { "field_name": "分类", "value": [{ "value_type": "text", "value": "待人工复核" }] } + ] + } + } + ] +} +``` +**关键点**: +- `classes` 按顺序对应 `branch_1`、`branch_2`;`desc` 与分类名一致,`to` 指向已定义的下游 step; + +--- + ## 构造技巧 ### Loop 构造要点