feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
349
backend/domain/workflow/internal/compose/test/batch_test.go
Normal file
349
backend/domain/workflow/internal/compose/test/batch_test.go
Normal file
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* 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 test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
|
||||
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/batch"
|
||||
)
|
||||
|
||||
func TestBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
lambda1 := func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
|
||||
if in["index"].(int64) > 2 {
|
||||
return nil, fmt.Errorf("index= %d is too large", in["index"].(int64))
|
||||
}
|
||||
|
||||
out = make(map[string]any)
|
||||
out["output_1"] = fmt.Sprintf("%s_%v_%d", in["array_1"].(string), in["from_parent_wf"].(bool), in["index"].(int64))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
lambda2 := func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
|
||||
return map[string]any{"index": in["index"]}, nil
|
||||
}
|
||||
|
||||
lambda3 := func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
|
||||
t.Log(in["consumer_1"].(string), in["array_2"].(int64), in["static_source"].(string))
|
||||
return in, nil
|
||||
}
|
||||
|
||||
lambdaNode1 := &compose2.NodeSchema{
|
||||
Key: "lambda",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(lambda1),
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"index"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "batch_node_key",
|
||||
FromPath: compose.FieldPath{"index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"array_1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "batch_node_key",
|
||||
FromPath: compose.FieldPath{"array_1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"from_parent_wf"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "parent_predecessor_1",
|
||||
FromPath: compose.FieldPath{"success"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
lambdaNode2 := &compose2.NodeSchema{
|
||||
Key: "index",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(lambda2),
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"index"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "batch_node_key",
|
||||
FromPath: compose.FieldPath{"index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
lambdaNode3 := &compose2.NodeSchema{
|
||||
Key: "consumer",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(lambda3),
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"consumer_1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "lambda",
|
||||
FromPath: compose.FieldPath{"output_1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"array_2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "batch_node_key",
|
||||
FromPath: compose.FieldPath{"array_2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"static_source"},
|
||||
Source: vo.FieldSource{
|
||||
Val: "this is a const",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
ns := &compose2.NodeSchema{
|
||||
Key: "batch_node_key",
|
||||
Type: entity.NodeTypeBatch,
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"array_1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"array_1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"array_2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"array_2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{batch.ConcurrentSizeKey},
|
||||
Source: vo.FieldSource{
|
||||
Val: int64(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{batch.MaxBatchSizeKey},
|
||||
Source: vo.FieldSource{
|
||||
Val: int64(5),
|
||||
},
|
||||
},
|
||||
},
|
||||
InputTypes: map[string]*vo.TypeInfo{
|
||||
"array_1": {
|
||||
Type: vo.DataTypeArray,
|
||||
ElemTypeInfo: &vo.TypeInfo{
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
},
|
||||
"array_2": {
|
||||
Type: vo.DataTypeArray,
|
||||
ElemTypeInfo: &vo.TypeInfo{
|
||||
Type: vo.DataTypeInteger,
|
||||
},
|
||||
},
|
||||
},
|
||||
OutputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"assembled_output_1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "lambda",
|
||||
FromPath: compose.FieldPath{"output_1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"assembled_output_2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "index",
|
||||
FromPath: compose.FieldPath{"index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"assembled_output_1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "batch_node_key",
|
||||
FromPath: compose.FieldPath{"assembled_output_1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"assembled_output_2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "batch_node_key",
|
||||
FromPath: compose.FieldPath{"assembled_output_2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
parentLambda := func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
|
||||
return map[string]any{"success": true}, nil
|
||||
}
|
||||
|
||||
parentLambdaNode := &compose2.NodeSchema{
|
||||
Key: "parent_predecessor_1",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(parentLambda),
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
parentLambdaNode,
|
||||
ns,
|
||||
exit,
|
||||
lambdaNode1,
|
||||
lambdaNode2,
|
||||
lambdaNode3,
|
||||
},
|
||||
Hierarchy: map[vo.NodeKey]vo.NodeKey{
|
||||
"lambda": "batch_node_key",
|
||||
"index": "batch_node_key",
|
||||
"consumer": "batch_node_key",
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entity.EntryNodeKey,
|
||||
ToNode: "parent_predecessor_1",
|
||||
},
|
||||
{
|
||||
FromNode: "parent_predecessor_1",
|
||||
ToNode: "batch_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "batch_node_key",
|
||||
ToNode: "lambda",
|
||||
},
|
||||
{
|
||||
FromNode: "lambda",
|
||||
ToNode: "index",
|
||||
},
|
||||
{
|
||||
FromNode: "lambda",
|
||||
ToNode: "consumer",
|
||||
},
|
||||
{
|
||||
FromNode: "index",
|
||||
ToNode: "batch_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "consumer",
|
||||
ToNode: "batch_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "batch_node_key",
|
||||
ToNode: entity.ExitNodeKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(ctx, ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(ctx, map[string]any{
|
||||
"array_1": []any{"a", "b", "c"},
|
||||
"array_2": []any{int64(1), int64(2), int64(3), int64(4)},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"assembled_output_1": []any{"a_true_0", "b_true_1", "c_true_2"},
|
||||
"assembled_output_2": []any{int64(0), int64(1), int64(2)},
|
||||
}, out)
|
||||
|
||||
// input array is empty
|
||||
out, err = wf.Runner.Invoke(ctx, map[string]any{
|
||||
"array_1": []any{},
|
||||
"array_2": []any{int64(1)},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"assembled_output_1": []any{},
|
||||
"assembled_output_2": []any{},
|
||||
}, out)
|
||||
|
||||
// less than concurrency
|
||||
out, err = wf.Runner.Invoke(ctx, map[string]any{
|
||||
"array_1": []any{"a"},
|
||||
"array_2": []any{int64(1), int64(2)},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"assembled_output_1": []any{"a_true_0"},
|
||||
"assembled_output_2": []any{int64(0)},
|
||||
}, out)
|
||||
|
||||
// err by inner node
|
||||
_, err = wf.Runner.Invoke(ctx, map[string]any{
|
||||
"array_1": []any{"a", "b", "c", "d", "e", "f"},
|
||||
"array_2": []any{int64(1), int64(2), int64(3), int64(4), int64(5), int64(6), int64(7)},
|
||||
})
|
||||
assert.ErrorContains(t, err, "is too large")
|
||||
}
|
||||
679
backend/domain/workflow/internal/compose/test/llm_test.go
Normal file
679
backend/domain/workflow/internal/compose/test/llm_test.go
Normal file
@@ -0,0 +1,679 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* 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 test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bytedance/mockey"
|
||||
"github.com/cloudwego/eino-ext/components/model/deepseek"
|
||||
"github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/callbacks"
|
||||
model2 "github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/coze-dev/coze-studio/backend/api/model/crossdomain/modelmgr"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/crossdomain/model"
|
||||
mockmodel "github.com/coze-dev/coze-studio/backend/domain/workflow/crossdomain/model/modelmock"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
|
||||
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/llm"
|
||||
"github.com/coze-dev/coze-studio/backend/internal/testutil"
|
||||
"github.com/coze-dev/coze-studio/backend/pkg/ctxcache"
|
||||
)
|
||||
|
||||
func TestLLM(t *testing.T) {
|
||||
mockey.PatchConvey("test llm", t, func() {
|
||||
accessKey := os.Getenv("OPENAI_API_KEY")
|
||||
baseURL := os.Getenv("OPENAI_BASE_URL")
|
||||
modelName := os.Getenv("OPENAI_MODEL_NAME")
|
||||
var (
|
||||
openaiModel, deepSeekModel model2.BaseChatModel
|
||||
err error
|
||||
)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockModelManager := mockmodel.NewMockManager(ctrl)
|
||||
mockey.Mock(model.GetManager).Return(mockModelManager).Build()
|
||||
|
||||
if len(accessKey) > 0 && len(baseURL) > 0 && len(modelName) > 0 {
|
||||
openaiModel, err = openai.NewChatModel(context.Background(), &openai.ChatModelConfig{
|
||||
APIKey: accessKey,
|
||||
ByAzure: true,
|
||||
BaseURL: baseURL,
|
||||
Model: modelName,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
deepSeekModelName := os.Getenv("DEEPSEEK_MODEL_NAME")
|
||||
if len(accessKey) > 0 && len(baseURL) > 0 && len(deepSeekModelName) > 0 {
|
||||
deepSeekModel, err = deepseek.NewChatModel(context.Background(), &deepseek.ChatModelConfig{
|
||||
APIKey: accessKey,
|
||||
BaseURL: baseURL,
|
||||
Model: deepSeekModelName,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
mockModelManager.EXPECT().GetModel(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *model.LLMParams) (model2.BaseChatModel, *modelmgr.Model, error) {
|
||||
if params.ModelName == modelName {
|
||||
return openaiModel, nil, nil
|
||||
} else if params.ModelName == deepSeekModelName {
|
||||
return deepSeekModel, nil, nil
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("invalid model name: %s", params.ModelName)
|
||||
}
|
||||
}).AnyTimes()
|
||||
|
||||
ctx := ctxcache.Init(context.Background())
|
||||
|
||||
t.Run("plain text output, non-streaming mode", func(t *testing.T) {
|
||||
if openaiModel == nil {
|
||||
defer func() {
|
||||
openaiModel = nil
|
||||
}()
|
||||
openaiModel = &testutil.UTChatModel{
|
||||
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
|
||||
return &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "I don't know",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
llmNode := &compose2.NodeSchema{
|
||||
Key: "llm_node_key",
|
||||
Type: entity.NodeTypeLLM,
|
||||
Configs: map[string]any{
|
||||
"SystemPrompt": "{{sys_prompt}}",
|
||||
"UserPrompt": "{{query}}",
|
||||
"OutputFormat": llm.FormatText,
|
||||
"LLMParams": &model.LLMParams{
|
||||
ModelName: modelName,
|
||||
},
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"sys_prompt"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"sys_prompt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"query"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"query"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputTypes: map[string]*vo.TypeInfo{
|
||||
"sys_prompt": {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
"query": {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
},
|
||||
OutputTypes: map[string]*vo.TypeInfo{
|
||||
"output": {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: llmNode.Key,
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
llmNode,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: llmNode.Key,
|
||||
},
|
||||
{
|
||||
FromNode: llmNode.Key,
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(ctx, ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(ctx, map[string]any{
|
||||
"sys_prompt": "you are a helpful assistant",
|
||||
"query": "what's your name",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, len(out), 0)
|
||||
assert.Greater(t, len(out["output"].(string)), 0)
|
||||
})
|
||||
|
||||
t.Run("json output", func(t *testing.T) {
|
||||
if openaiModel == nil {
|
||||
defer func() {
|
||||
openaiModel = nil
|
||||
}()
|
||||
openaiModel = &testutil.UTChatModel{
|
||||
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
|
||||
return &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: `{"country_name": "Russia", "area_size": 17075400}`,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
llmNode := &compose2.NodeSchema{
|
||||
Key: "llm_node_key",
|
||||
Type: entity.NodeTypeLLM,
|
||||
Configs: map[string]any{
|
||||
"SystemPrompt": "you are a helpful assistant",
|
||||
"UserPrompt": "what's the largest country in the world and it's area size in square kilometers?",
|
||||
"OutputFormat": llm.FormatJSON,
|
||||
"IgnoreException": true,
|
||||
"DefaultOutput": map[string]any{
|
||||
"country_name": "unknown",
|
||||
"area_size": int64(0),
|
||||
},
|
||||
"LLMParams": &model.LLMParams{
|
||||
ModelName: modelName,
|
||||
},
|
||||
},
|
||||
OutputTypes: map[string]*vo.TypeInfo{
|
||||
"country_name": {
|
||||
Type: vo.DataTypeString,
|
||||
Required: true,
|
||||
},
|
||||
"area_size": {
|
||||
Type: vo.DataTypeInteger,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"country_name"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: llmNode.Key,
|
||||
FromPath: compose.FieldPath{"country_name"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"area_size"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: llmNode.Key,
|
||||
FromPath: compose.FieldPath{"area_size"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
llmNode,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: llmNode.Key,
|
||||
},
|
||||
{
|
||||
FromNode: llmNode.Key,
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(ctx, ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(ctx, map[string]any{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, out["country_name"], "Russia")
|
||||
assert.Greater(t, out["area_size"], int64(1000))
|
||||
})
|
||||
|
||||
t.Run("markdown output", func(t *testing.T) {
|
||||
if openaiModel == nil {
|
||||
defer func() {
|
||||
openaiModel = nil
|
||||
}()
|
||||
openaiModel = &testutil.UTChatModel{
|
||||
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
|
||||
return &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: `#Top 5 Largest Countries in the World ## 1. Russia 2. Canada 3. United States 4. Brazil 5. Japan`,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
llmNode := &compose2.NodeSchema{
|
||||
Key: "llm_node_key",
|
||||
Type: entity.NodeTypeLLM,
|
||||
Configs: map[string]any{
|
||||
"SystemPrompt": "you are a helpful assistant",
|
||||
"UserPrompt": "list the top 5 largest countries in the world",
|
||||
"OutputFormat": llm.FormatMarkdown,
|
||||
"LLMParams": &model.LLMParams{
|
||||
ModelName: modelName,
|
||||
},
|
||||
},
|
||||
OutputTypes: map[string]*vo.TypeInfo{
|
||||
"output": {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: llmNode.Key,
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
llmNode,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: llmNode.Key,
|
||||
},
|
||||
{
|
||||
FromNode: llmNode.Key,
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(ctx, ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(ctx, map[string]any{})
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, len(out["output"].(string)), 0)
|
||||
})
|
||||
|
||||
t.Run("plain text output, streaming mode", func(t *testing.T) {
|
||||
// start -> fan out to openai LLM and deepseek LLM -> fan in to output emitter -> end
|
||||
if openaiModel == nil || deepSeekModel == nil {
|
||||
if openaiModel == nil {
|
||||
defer func() {
|
||||
openaiModel = nil
|
||||
}()
|
||||
openaiModel = &testutil.UTChatModel{
|
||||
StreamResultProvider: func(_ int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
|
||||
sr := schema.StreamReaderFromArray([]*schema.Message{
|
||||
{
|
||||
Role: schema.Assistant,
|
||||
Content: "I ",
|
||||
},
|
||||
{
|
||||
Role: schema.Assistant,
|
||||
Content: "don't know.",
|
||||
},
|
||||
})
|
||||
return sr, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if deepSeekModel == nil {
|
||||
defer func() {
|
||||
deepSeekModel = nil
|
||||
}()
|
||||
deepSeekModel = &testutil.UTChatModel{
|
||||
StreamResultProvider: func(_ int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
|
||||
sr := schema.StreamReaderFromArray([]*schema.Message{
|
||||
{
|
||||
Role: schema.Assistant,
|
||||
Content: "I ",
|
||||
},
|
||||
{
|
||||
Role: schema.Assistant,
|
||||
Content: "don't know too.",
|
||||
},
|
||||
})
|
||||
return sr, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
openaiNode := &compose2.NodeSchema{
|
||||
Key: "openai_llm_node_key",
|
||||
Type: entity.NodeTypeLLM,
|
||||
Configs: map[string]any{
|
||||
"SystemPrompt": "you are a helpful assistant",
|
||||
"UserPrompt": "plan a 10 day family visit to China.",
|
||||
"OutputFormat": llm.FormatText,
|
||||
"LLMParams": &model.LLMParams{
|
||||
ModelName: modelName,
|
||||
},
|
||||
},
|
||||
OutputTypes: map[string]*vo.TypeInfo{
|
||||
"output": {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
deepseekNode := &compose2.NodeSchema{
|
||||
Key: "deepseek_llm_node_key",
|
||||
Type: entity.NodeTypeLLM,
|
||||
Configs: map[string]any{
|
||||
"SystemPrompt": "you are a helpful assistant",
|
||||
"UserPrompt": "thoroughly plan a 10 day family visit to China. Use your reasoning ability.",
|
||||
"OutputFormat": llm.FormatText,
|
||||
"LLMParams": &model.LLMParams{
|
||||
ModelName: modelName,
|
||||
},
|
||||
},
|
||||
OutputTypes: map[string]*vo.TypeInfo{
|
||||
"output": {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
"reasoning_content": {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
emitterNode := &compose2.NodeSchema{
|
||||
Key: "emitter_node_key",
|
||||
Type: entity.NodeTypeOutputEmitter,
|
||||
Configs: map[string]any{
|
||||
"Template": "prefix {{inputObj.field1}} {{input2}} {{deepseek_reasoning}} \n\n###\n\n {{openai_output}} \n\n###\n\n {{deepseek_output}} {{inputObj.field2}} suffix",
|
||||
"Mode": nodes.Streaming,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"openai_output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: openaiNode.Key,
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"deepseek_output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: deepseekNode.Key,
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"deepseek_reasoning"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: deepseekNode.Key,
|
||||
FromPath: compose.FieldPath{"reasoning_content"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"inputObj"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"inputObj"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"input2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"input2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.UseAnswerContent,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"openai_output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: openaiNode.Key,
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"deepseek_output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: deepseekNode.Key,
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"deepseek_reasoning"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: deepseekNode.Key,
|
||||
FromPath: compose.FieldPath{"reasoning_content"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
openaiNode,
|
||||
deepseekNode,
|
||||
emitterNode,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: openaiNode.Key,
|
||||
},
|
||||
{
|
||||
FromNode: openaiNode.Key,
|
||||
ToNode: emitterNode.Key,
|
||||
},
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: deepseekNode.Key,
|
||||
},
|
||||
{
|
||||
FromNode: deepseekNode.Key,
|
||||
ToNode: emitterNode.Key,
|
||||
},
|
||||
{
|
||||
FromNode: emitterNode.Key,
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(ctx, ws)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var fullOutput string
|
||||
|
||||
cbHandler := callbacks.NewHandlerBuilder().OnEndWithStreamOutputFn(
|
||||
func(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
|
||||
defer output.Close()
|
||||
|
||||
for {
|
||||
chunk, e := output.Recv()
|
||||
if e != nil {
|
||||
if e == io.EOF {
|
||||
break
|
||||
}
|
||||
assert.NoError(t, e)
|
||||
}
|
||||
|
||||
s, ok := chunk.(map[string]any)
|
||||
assert.True(t, ok)
|
||||
|
||||
out := s["output"].(string)
|
||||
if out != nodes.KeyIsFinished {
|
||||
fmt.Print(s["output"])
|
||||
fullOutput += s["output"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
return ctx
|
||||
}).Build()
|
||||
|
||||
outStream, err := wf.Runner.Stream(ctx, map[string]any{
|
||||
"inputObj": map[string]any{
|
||||
"field1": "field1",
|
||||
"field2": 1.1,
|
||||
},
|
||||
"input2": 23.5,
|
||||
}, compose.WithCallbacks(cbHandler).DesignateNode(string(emitterNode.Key)))
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(fullOutput, "prefix field1 23.5"))
|
||||
assert.True(t, strings.HasSuffix(fullOutput, "1.1 suffix"))
|
||||
outStream.Close()
|
||||
})
|
||||
})
|
||||
}
|
||||
495
backend/domain/workflow/internal/compose/test/loop_test.go
Normal file
495
backend/domain/workflow/internal/compose/test/loop_test.go
Normal file
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* 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 test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
|
||||
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/loop"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/variableassigner"
|
||||
"github.com/coze-dev/coze-studio/backend/pkg/lang/ptr"
|
||||
)
|
||||
|
||||
func TestLoop(t *testing.T) {
|
||||
t.Run("by iteration", func(t *testing.T) {
|
||||
// start-> loop_node_key[innerNode->continue] -> end
|
||||
innerNode := &compose2.NodeSchema{
|
||||
Key: "innerNode",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
|
||||
index := in["index"].(int64)
|
||||
return map[string]any{"output": index}, nil
|
||||
}),
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"index"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "loop_node_key",
|
||||
FromPath: compose.FieldPath{"index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
continueNode := &compose2.NodeSchema{
|
||||
Key: "continueNode",
|
||||
Type: entity.NodeTypeContinue,
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
loopNode := &compose2.NodeSchema{
|
||||
Key: "loop_node_key",
|
||||
Type: entity.NodeTypeLoop,
|
||||
Configs: map[string]any{
|
||||
"LoopType": loop.ByIteration,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{loop.Count},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"count"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OutputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "innerNode",
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "loop_node_key",
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
loopNode,
|
||||
exit,
|
||||
innerNode,
|
||||
continueNode,
|
||||
},
|
||||
Hierarchy: map[vo.NodeKey]vo.NodeKey{
|
||||
"innerNode": "loop_node_key",
|
||||
"continueNode": "loop_node_key",
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: "loop_node_key",
|
||||
ToNode: "innerNode",
|
||||
},
|
||||
{
|
||||
FromNode: "innerNode",
|
||||
ToNode: "continueNode",
|
||||
},
|
||||
{
|
||||
FromNode: "continueNode",
|
||||
ToNode: "loop_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "loop_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "loop_node_key",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(context.Background(), ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
|
||||
"count": int64(3),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"output": []any{int64(0), int64(1), int64(2)},
|
||||
}, out)
|
||||
})
|
||||
|
||||
t.Run("infinite", func(t *testing.T) {
|
||||
// start-> loop_node_key[innerNode->break] -> end
|
||||
innerNode := &compose2.NodeSchema{
|
||||
Key: "innerNode",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
|
||||
index := in["index"].(int64)
|
||||
return map[string]any{"output": index}, nil
|
||||
}),
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"index"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "loop_node_key",
|
||||
FromPath: compose.FieldPath{"index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
breakNode := &compose2.NodeSchema{
|
||||
Key: "breakNode",
|
||||
Type: entity.NodeTypeBreak,
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
loopNode := &compose2.NodeSchema{
|
||||
Key: "loop_node_key",
|
||||
Type: entity.NodeTypeLoop,
|
||||
Configs: map[string]any{
|
||||
"LoopType": loop.Infinite,
|
||||
},
|
||||
OutputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "innerNode",
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "loop_node_key",
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
loopNode,
|
||||
exit,
|
||||
innerNode,
|
||||
breakNode,
|
||||
},
|
||||
Hierarchy: map[vo.NodeKey]vo.NodeKey{
|
||||
"innerNode": "loop_node_key",
|
||||
"breakNode": "loop_node_key",
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: "loop_node_key",
|
||||
ToNode: "innerNode",
|
||||
},
|
||||
{
|
||||
FromNode: "innerNode",
|
||||
ToNode: "breakNode",
|
||||
},
|
||||
{
|
||||
FromNode: "breakNode",
|
||||
ToNode: "loop_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "loop_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "loop_node_key",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(context.Background(), ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(context.Background(), map[string]any{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"output": []any{int64(0)},
|
||||
}, out)
|
||||
})
|
||||
|
||||
t.Run("by array", func(t *testing.T) {
|
||||
// start-> loop_node_key[innerNode->variable_assign] -> end
|
||||
|
||||
innerNode := &compose2.NodeSchema{
|
||||
Key: "innerNode",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
|
||||
item1 := in["item1"].(string)
|
||||
item2 := in["item2"].(string)
|
||||
count := in["count"].(int)
|
||||
return map[string]any{"total": int(count) + len(item1) + len(item2)}, nil
|
||||
}),
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"item1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "loop_node_key",
|
||||
FromPath: compose.FieldPath{"items1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"item2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "loop_node_key",
|
||||
FromPath: compose.FieldPath{"items2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"count"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromPath: compose.FieldPath{"count"},
|
||||
VariableType: ptr.Of(vo.ParentIntermediate),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assigner := &compose2.NodeSchema{
|
||||
Key: "assigner",
|
||||
Type: entity.NodeTypeVariableAssignerWithinLoop,
|
||||
Configs: []*variableassigner.Pair{
|
||||
{
|
||||
Left: vo.Reference{
|
||||
FromPath: compose.FieldPath{"count"},
|
||||
VariableType: ptr.Of(vo.ParentIntermediate),
|
||||
},
|
||||
Right: compose.FieldPath{"total"},
|
||||
},
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"total"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "innerNode",
|
||||
FromPath: compose.FieldPath{"total"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "loop_node_key",
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
loopNode := &compose2.NodeSchema{
|
||||
Key: "loop_node_key",
|
||||
Type: entity.NodeTypeLoop,
|
||||
Configs: map[string]any{
|
||||
"LoopType": loop.ByArray,
|
||||
"IntermediateVars": map[string]*vo.TypeInfo{
|
||||
"count": {
|
||||
Type: vo.DataTypeInteger,
|
||||
},
|
||||
},
|
||||
},
|
||||
InputTypes: map[string]*vo.TypeInfo{
|
||||
"items1": {
|
||||
Type: vo.DataTypeArray,
|
||||
ElemTypeInfo: &vo.TypeInfo{Type: vo.DataTypeString},
|
||||
},
|
||||
"items2": {
|
||||
Type: vo.DataTypeArray,
|
||||
ElemTypeInfo: &vo.TypeInfo{Type: vo.DataTypeString},
|
||||
},
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"items1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"items1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"items2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"items2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"count"},
|
||||
Source: vo.FieldSource{
|
||||
Val: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
OutputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromPath: compose.FieldPath{"count"},
|
||||
VariableType: ptr.Of(vo.ParentIntermediate),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
loopNode,
|
||||
exit,
|
||||
innerNode,
|
||||
assigner,
|
||||
},
|
||||
Hierarchy: map[vo.NodeKey]vo.NodeKey{
|
||||
"innerNode": "loop_node_key",
|
||||
"assigner": "loop_node_key",
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: "loop_node_key",
|
||||
ToNode: "innerNode",
|
||||
},
|
||||
{
|
||||
FromNode: "innerNode",
|
||||
ToNode: "assigner",
|
||||
},
|
||||
{
|
||||
FromNode: "assigner",
|
||||
ToNode: "loop_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "loop_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "loop_node_key",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(context.Background(), ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
|
||||
"items1": []any{"a", "b"},
|
||||
"items2": []any{"a1", "b1", "c1"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"output": 6,
|
||||
}, out)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* 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 test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/bytedance/mockey"
|
||||
"github.com/cloudwego/eino-ext/components/model/openai"
|
||||
model2 "github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/crossdomain/model"
|
||||
mockmodel "github.com/coze-dev/coze-studio/backend/domain/workflow/crossdomain/model/modelmock"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
|
||||
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/qa"
|
||||
repo2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/repo"
|
||||
"github.com/coze-dev/coze-studio/backend/infra/impl/checkpoint"
|
||||
mock "github.com/coze-dev/coze-studio/backend/internal/mock/infra/contract/idgen"
|
||||
storageMock "github.com/coze-dev/coze-studio/backend/internal/mock/infra/contract/storage"
|
||||
|
||||
"github.com/coze-dev/coze-studio/backend/internal/testutil"
|
||||
"github.com/coze-dev/coze-studio/backend/pkg/lang/ptr"
|
||||
)
|
||||
|
||||
func TestQuestionAnswer(t *testing.T) {
|
||||
mockey.PatchConvey("test qa", t, func() {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockModelManager := mockmodel.NewMockManager(ctrl)
|
||||
mockey.Mock(model.GetManager).Return(mockModelManager).Build()
|
||||
|
||||
accessKey := os.Getenv("OPENAI_API_KEY")
|
||||
baseURL := os.Getenv("OPENAI_BASE_URL")
|
||||
modelName := os.Getenv("OPENAI_MODEL_NAME")
|
||||
var (
|
||||
chatModel model2.BaseChatModel
|
||||
err error
|
||||
)
|
||||
|
||||
if len(accessKey) > 0 && len(baseURL) > 0 && len(modelName) > 0 {
|
||||
chatModel, err = openai.NewChatModel(context.Background(), &openai.ChatModelConfig{
|
||||
APIKey: accessKey,
|
||||
ByAzure: true,
|
||||
BaseURL: baseURL,
|
||||
Model: modelName,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockModelManager.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel, nil, nil).AnyTimes()
|
||||
}
|
||||
|
||||
dsn := "root:root@tcp(127.0.0.1:3306)/opencoze?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
if os.Getenv("CI_JOB_NAME") != "" {
|
||||
dsn = strings.ReplaceAll(dsn, "127.0.0.1", "mysql")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn))
|
||||
assert.NoError(t, err)
|
||||
|
||||
s, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start miniredis: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: s.Addr(),
|
||||
})
|
||||
|
||||
mockIDGen := mock.NewMockIDGenerator(ctrl)
|
||||
mockIDGen.EXPECT().GenID(gomock.Any()).Return(time.Now().UnixNano(), nil).AnyTimes()
|
||||
mockTos := storageMock.NewMockStorage(ctrl)
|
||||
mockTos.EXPECT().GetObjectUrl(gomock.Any(), gomock.Any(), gomock.Any()).Return("", nil).AnyTimes()
|
||||
repo := repo2.NewRepository(mockIDGen, db, redisClient, mockTos,
|
||||
checkpoint.NewRedisStore(redisClient))
|
||||
mockey.Mock(workflow.GetRepository).Return(repo).Build()
|
||||
|
||||
t.Run("answer directly, no structured output", func(t *testing.T) {
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
}}
|
||||
|
||||
ns := &compose2.NodeSchema{
|
||||
Key: "qa_node_key",
|
||||
Type: entity.NodeTypeQuestionAnswer,
|
||||
Configs: map[string]any{
|
||||
"QuestionTpl": "{{input}}",
|
||||
"AnswerType": qa.AnswerDirectly,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"input"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"query"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"answer"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "qa_node_key",
|
||||
FromPath: compose.FieldPath{qa.UserResponseKey},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
ns,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "qa_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "qa_node_key",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(context.Background(), ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
checkPointID := fmt.Sprintf("%d", time.Now().Nanosecond())
|
||||
_, err = wf.Runner.Invoke(context.Background(), map[string]any{
|
||||
"query": "what's your name?",
|
||||
}, compose.WithCheckPointID(checkPointID))
|
||||
assert.Error(t, err)
|
||||
|
||||
info, existed := compose.ExtractInterruptInfo(err)
|
||||
assert.True(t, existed)
|
||||
assert.Equal(t, "what's your name?", info.State.(*compose2.State).Questions[ns.Key][0].Question)
|
||||
|
||||
answer := "my name is eino"
|
||||
stateModifier := func(ctx context.Context, path compose.NodePath, state any) error {
|
||||
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], answer)
|
||||
return nil
|
||||
}
|
||||
out, err := wf.Runner.Invoke(context.Background(), nil, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"answer": answer,
|
||||
}, out)
|
||||
})
|
||||
|
||||
t.Run("answer with fixed choices", func(t *testing.T) {
|
||||
if chatModel == nil {
|
||||
oneChatModel := &testutil.UTChatModel{
|
||||
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
|
||||
return &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "-1",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
mockModelManager.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(oneChatModel, nil, nil).Times(1)
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
ns := &compose2.NodeSchema{
|
||||
Key: "qa_node_key",
|
||||
Type: entity.NodeTypeQuestionAnswer,
|
||||
Configs: map[string]any{
|
||||
"QuestionTpl": "{{input}}",
|
||||
"AnswerType": qa.AnswerByChoices,
|
||||
"ChoiceType": qa.FixedChoices,
|
||||
"FixedChoices": []string{"{{choice1}}", "{{choice2}}"},
|
||||
"LLMParams": &model.LLMParams{},
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"input"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"query"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"choice1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"choice1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"choice2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"choice2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"option_id"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "qa_node_key",
|
||||
FromPath: compose.FieldPath{qa.OptionIDKey},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"option_content"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "qa_node_key",
|
||||
FromPath: compose.FieldPath{qa.OptionContentKey},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
lambda := &compose2.NodeSchema{
|
||||
Key: "lambda",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
|
||||
return out, nil
|
||||
}),
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
ns,
|
||||
exit,
|
||||
lambda,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "qa_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "qa_node_key",
|
||||
ToNode: exit.Key,
|
||||
FromPort: ptr.Of("branch_0"),
|
||||
},
|
||||
{
|
||||
FromNode: "qa_node_key",
|
||||
ToNode: exit.Key,
|
||||
FromPort: ptr.Of("branch_1"),
|
||||
},
|
||||
{
|
||||
FromNode: "qa_node_key",
|
||||
ToNode: "lambda",
|
||||
FromPort: ptr.Of("default"),
|
||||
},
|
||||
{
|
||||
FromNode: "lambda",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(context.Background(), ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
checkPointID := fmt.Sprintf("%d", time.Now().Nanosecond())
|
||||
_, err = wf.Runner.Invoke(context.Background(), map[string]any{
|
||||
"query": "what's would you make in Coze?",
|
||||
"choice1": "make agent",
|
||||
"choice2": "make workflow",
|
||||
}, compose.WithCheckPointID(checkPointID))
|
||||
assert.Error(t, err)
|
||||
|
||||
info, existed := compose.ExtractInterruptInfo(err)
|
||||
assert.True(t, existed)
|
||||
assert.Equal(t, "what's would you make in Coze?", info.State.(*compose2.State).Questions[ns.Key][0].Question)
|
||||
assert.Equal(t, "make agent", info.State.(*compose2.State).Questions[ns.Key][0].Choices[0])
|
||||
assert.Equal(t, "make workflow", info.State.(*compose2.State).Questions[ns.Key][0].Choices[1])
|
||||
|
||||
chosenContent := "I would make all kinds of stuff"
|
||||
stateModifier := func(ctx context.Context, path compose.NodePath, state any) error {
|
||||
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], chosenContent)
|
||||
return nil
|
||||
}
|
||||
out, err := wf.Runner.Invoke(context.Background(), nil, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"option_id": "other",
|
||||
"option_content": chosenContent,
|
||||
}, out)
|
||||
})
|
||||
|
||||
t.Run("answer with dynamic choices", func(t *testing.T) {
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
ns := &compose2.NodeSchema{
|
||||
Key: "qa_node_key",
|
||||
Type: entity.NodeTypeQuestionAnswer,
|
||||
Configs: map[string]any{
|
||||
"QuestionTpl": "{{input}}",
|
||||
"AnswerType": qa.AnswerByChoices,
|
||||
"ChoiceType": qa.DynamicChoices,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"input"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"query"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{qa.DynamicChoicesKey},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"choices"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"option_id"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "qa_node_key",
|
||||
FromPath: compose.FieldPath{qa.OptionIDKey},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"option_content"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "qa_node_key",
|
||||
FromPath: compose.FieldPath{qa.OptionContentKey},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
lambda := &compose2.NodeSchema{
|
||||
Key: "lambda",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
|
||||
return out, nil
|
||||
}),
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
ns,
|
||||
exit,
|
||||
lambda,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "qa_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "qa_node_key",
|
||||
ToNode: exit.Key,
|
||||
FromPort: ptr.Of("branch_0"),
|
||||
},
|
||||
{
|
||||
FromNode: "lambda",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
{
|
||||
FromNode: "qa_node_key",
|
||||
ToNode: "lambda",
|
||||
FromPort: ptr.Of("default"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(context.Background(), ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
checkPointID := fmt.Sprintf("%d", time.Now().Nanosecond())
|
||||
_, err = wf.Runner.Invoke(context.Background(), map[string]any{
|
||||
"query": "what's the capital city of China?",
|
||||
"choices": []any{"beijing", "shanghai"},
|
||||
}, compose.WithCheckPointID(checkPointID))
|
||||
assert.Error(t, err)
|
||||
|
||||
info, existed := compose.ExtractInterruptInfo(err)
|
||||
assert.True(t, existed)
|
||||
assert.Equal(t, "what's the capital city of China?", info.State.(*compose2.State).Questions[ns.Key][0].Question)
|
||||
assert.Equal(t, "beijing", info.State.(*compose2.State).Questions[ns.Key][0].Choices[0])
|
||||
assert.Equal(t, "shanghai", info.State.(*compose2.State).Questions[ns.Key][0].Choices[1])
|
||||
|
||||
chosenContent := "beijing"
|
||||
stateModifier := func(ctx context.Context, path compose.NodePath, state any) error {
|
||||
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], chosenContent)
|
||||
return nil
|
||||
}
|
||||
out, err := wf.Runner.Invoke(context.Background(), nil, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"option_id": "A",
|
||||
"option_content": chosenContent,
|
||||
}, out)
|
||||
})
|
||||
|
||||
t.Run("answer directly, extract structured output", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
qaCount := 0
|
||||
if chatModel == nil {
|
||||
defer func() {
|
||||
chatModel = nil
|
||||
}()
|
||||
chatModel = &testutil.UTChatModel{
|
||||
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
|
||||
if qaCount == 1 {
|
||||
return &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: `{"question": "what's your age?"}`,
|
||||
}, nil
|
||||
} else if qaCount == 2 {
|
||||
return &schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: `{"fields": {"name": "eino", "age": 1}}`,
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("not found")
|
||||
},
|
||||
}
|
||||
mockModelManager.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel, nil, nil).Times(1)
|
||||
}
|
||||
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
ns := &compose2.NodeSchema{
|
||||
Key: "qa_node_key",
|
||||
Type: entity.NodeTypeQuestionAnswer,
|
||||
Configs: map[string]any{
|
||||
"QuestionTpl": "{{input}}",
|
||||
"AnswerType": qa.AnswerDirectly,
|
||||
"ExtractFromAnswer": true,
|
||||
"AdditionalSystemPromptTpl": "{{prompt}}",
|
||||
"MaxAnswerCount": 2,
|
||||
"LLMParams": &model.LLMParams{},
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"input"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"query"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"prompt"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"prompt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OutputTypes: map[string]*vo.TypeInfo{
|
||||
"name": {
|
||||
Type: vo.DataTypeString,
|
||||
Required: true,
|
||||
},
|
||||
"age": {
|
||||
Type: vo.DataTypeInteger,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"name"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "qa_node_key",
|
||||
FromPath: compose.FieldPath{"name"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"age"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "qa_node_key",
|
||||
FromPath: compose.FieldPath{"age"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{qa.UserResponseKey},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "qa_node_key",
|
||||
FromPath: compose.FieldPath{qa.UserResponseKey},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
ns,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "qa_node_key",
|
||||
},
|
||||
{
|
||||
FromNode: "qa_node_key",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(context.Background(), ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
checkPointID := fmt.Sprintf("%d", time.Now().Nanosecond())
|
||||
_, err = wf.Runner.Invoke(ctx, map[string]any{
|
||||
"query": "what's your name?",
|
||||
"prompt": "You are a helpful assistant.",
|
||||
}, compose.WithCheckPointID(checkPointID))
|
||||
assert.Error(t, err)
|
||||
|
||||
info, existed := compose.ExtractInterruptInfo(err)
|
||||
assert.True(t, existed)
|
||||
assert.Equal(t, "what's your name?", info.State.(*compose2.State).Questions["qa_node_key"][0].Question)
|
||||
|
||||
qaCount++
|
||||
answer := "my name is eino"
|
||||
stateModifier := func(ctx context.Context, path compose.NodePath, state any) error {
|
||||
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], answer)
|
||||
return nil
|
||||
}
|
||||
_, err = wf.Runner.Invoke(ctx, map[string]any{}, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
|
||||
assert.Error(t, err)
|
||||
info, existed = compose.ExtractInterruptInfo(err)
|
||||
assert.True(t, existed)
|
||||
|
||||
qaCount++
|
||||
answer = "my age is 1 years old"
|
||||
stateModifier = func(ctx context.Context, path compose.NodePath, state any) error {
|
||||
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], answer)
|
||||
return nil
|
||||
}
|
||||
out, err := wf.Runner.Invoke(ctx, map[string]any{}, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
qa.UserResponseKey: answer,
|
||||
"name": "eino",
|
||||
"age": int64(1),
|
||||
}, out)
|
||||
})
|
||||
})
|
||||
}
|
||||
634
backend/domain/workflow/internal/compose/test/workflow_test.go
Normal file
634
backend/domain/workflow/internal/compose/test/workflow_test.go
Normal file
@@ -0,0 +1,634 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* 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 test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
|
||||
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/selector"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/textprocessor"
|
||||
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/variableaggregator"
|
||||
"github.com/coze-dev/coze-studio/backend/pkg/lang/ptr"
|
||||
)
|
||||
|
||||
func TestAddSelector(t *testing.T) {
|
||||
// start -> selector, selector.condition1 -> lambda1 -> end, selector.condition2 -> [lambda2, lambda3] -> end, selector default -> end
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
}}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "lambda1",
|
||||
FromPath: compose.FieldPath{"lambda1"},
|
||||
},
|
||||
},
|
||||
Path: compose.FieldPath{"lambda1"},
|
||||
},
|
||||
{
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "lambda2",
|
||||
FromPath: compose.FieldPath{"lambda2"},
|
||||
},
|
||||
},
|
||||
Path: compose.FieldPath{"lambda2"},
|
||||
},
|
||||
{
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "lambda3",
|
||||
FromPath: compose.FieldPath{"lambda3"},
|
||||
},
|
||||
},
|
||||
Path: compose.FieldPath{"lambda3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
lambda1 := func(ctx context.Context, in map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"lambda1": "v1",
|
||||
}, nil
|
||||
}
|
||||
|
||||
lambdaNode1 := &compose2.NodeSchema{
|
||||
Key: "lambda1",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(lambda1),
|
||||
}
|
||||
|
||||
lambda2 := func(ctx context.Context, in map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"lambda2": "v2",
|
||||
}, nil
|
||||
}
|
||||
|
||||
LambdaNode2 := &compose2.NodeSchema{
|
||||
Key: "lambda2",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(lambda2),
|
||||
}
|
||||
|
||||
lambda3 := func(ctx context.Context, in map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"lambda3": "v3",
|
||||
}, nil
|
||||
}
|
||||
|
||||
lambdaNode3 := &compose2.NodeSchema{
|
||||
Key: "lambda3",
|
||||
Type: entity.NodeTypeLambda,
|
||||
Lambda: compose.InvokableLambda(lambda3),
|
||||
}
|
||||
|
||||
ns := &compose2.NodeSchema{
|
||||
Key: "selector",
|
||||
Type: entity.NodeTypeSelector,
|
||||
Configs: map[string]any{"Clauses": []*selector.OneClauseSchema{
|
||||
{
|
||||
Single: ptr.Of(selector.OperatorEqual),
|
||||
},
|
||||
{
|
||||
Multi: &selector.MultiClauseSchema{
|
||||
Clauses: []*selector.Operator{
|
||||
ptr.Of(selector.OperatorGreater),
|
||||
ptr.Of(selector.OperatorIsTrue),
|
||||
},
|
||||
Relation: selector.ClauseRelationAND,
|
||||
},
|
||||
},
|
||||
}},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"0", selector.LeftKey},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"key1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"0", selector.RightKey},
|
||||
Source: vo.FieldSource{
|
||||
Val: "value1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"1", "0", selector.LeftKey},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"key2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"1", "0", selector.RightKey},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"key3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"1", "1", selector.LeftKey},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"key4"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputTypes: map[string]*vo.TypeInfo{
|
||||
"0": {
|
||||
Type: vo.DataTypeObject,
|
||||
Properties: map[string]*vo.TypeInfo{
|
||||
selector.LeftKey: {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
selector.RightKey: {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
},
|
||||
},
|
||||
"1": {
|
||||
Type: vo.DataTypeObject,
|
||||
Properties: map[string]*vo.TypeInfo{
|
||||
"0": {
|
||||
Type: vo.DataTypeObject,
|
||||
Properties: map[string]*vo.TypeInfo{
|
||||
selector.LeftKey: {
|
||||
Type: vo.DataTypeInteger,
|
||||
},
|
||||
selector.RightKey: {
|
||||
Type: vo.DataTypeInteger,
|
||||
},
|
||||
},
|
||||
},
|
||||
"1": {
|
||||
Type: vo.DataTypeObject,
|
||||
Properties: map[string]*vo.TypeInfo{
|
||||
selector.LeftKey: {
|
||||
Type: vo.DataTypeBoolean,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
ns,
|
||||
lambdaNode1,
|
||||
LambdaNode2,
|
||||
lambdaNode3,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "selector",
|
||||
},
|
||||
{
|
||||
FromNode: "selector",
|
||||
ToNode: "lambda1",
|
||||
FromPort: ptr.Of("branch_0"),
|
||||
},
|
||||
{
|
||||
FromNode: "selector",
|
||||
ToNode: "lambda2",
|
||||
FromPort: ptr.Of("branch_1"),
|
||||
},
|
||||
{
|
||||
FromNode: "selector",
|
||||
ToNode: "lambda3",
|
||||
FromPort: ptr.Of("branch_1"),
|
||||
},
|
||||
{
|
||||
FromNode: "selector",
|
||||
ToNode: exit.Key,
|
||||
FromPort: ptr.Of("default"),
|
||||
},
|
||||
{
|
||||
FromNode: "lambda1",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
{
|
||||
FromNode: "lambda2",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
{
|
||||
FromNode: "lambda3",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
ctx := context.Background()
|
||||
wf, err := compose2.NewWorkflow(ctx, ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(ctx, map[string]any{
|
||||
"key1": "value1",
|
||||
"key2": int64(2),
|
||||
"key3": int64(3),
|
||||
"key4": true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"lambda1": "v1",
|
||||
}, out)
|
||||
|
||||
out, err = wf.Runner.Invoke(ctx, map[string]any{
|
||||
"key1": "value2",
|
||||
"key2": int64(3),
|
||||
"key3": int64(2),
|
||||
"key4": true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"lambda2": "v2",
|
||||
"lambda3": "v3",
|
||||
}, out)
|
||||
|
||||
out, err = wf.Runner.Invoke(ctx, map[string]any{
|
||||
"key1": "value2",
|
||||
"key2": int64(2),
|
||||
"key3": int64(3),
|
||||
"key4": true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{}, out)
|
||||
}
|
||||
|
||||
func TestVariableAggregator(t *testing.T) {
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"Group1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "va",
|
||||
FromPath: compose.FieldPath{"Group1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"Group2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "va",
|
||||
FromPath: compose.FieldPath{"Group2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ns := &compose2.NodeSchema{
|
||||
Key: "va",
|
||||
Type: entity.NodeTypeVariableAggregator,
|
||||
Configs: map[string]any{
|
||||
"MergeStrategy": variableaggregator.FirstNotNullValue,
|
||||
"GroupToLen": map[string]int{
|
||||
"Group1": 1,
|
||||
"Group2": 1,
|
||||
},
|
||||
"GroupOrder": []string{
|
||||
"Group1",
|
||||
"Group2",
|
||||
},
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"Group1", "0"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"Str1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"Group2", "0"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"Int1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputTypes: map[string]*vo.TypeInfo{
|
||||
"Group1": {
|
||||
Type: vo.DataTypeObject,
|
||||
Properties: map[string]*vo.TypeInfo{
|
||||
"0": {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
},
|
||||
},
|
||||
"Group2": {
|
||||
Type: vo.DataTypeObject,
|
||||
Properties: map[string]*vo.TypeInfo{
|
||||
"0": {
|
||||
Type: vo.DataTypeInteger,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OutputTypes: map[string]*vo.TypeInfo{
|
||||
"Group1": {
|
||||
Type: vo.DataTypeString,
|
||||
},
|
||||
"Group2": {
|
||||
Type: vo.DataTypeInteger,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
entry,
|
||||
ns,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "va",
|
||||
},
|
||||
{
|
||||
FromNode: "va",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
ctx := context.Background()
|
||||
wf, err := compose2.NewWorkflow(ctx, ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
|
||||
"Str1": "str_v1",
|
||||
"Int1": int64(1),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"Group1": "str_v1",
|
||||
"Group2": int64(1),
|
||||
}, out)
|
||||
|
||||
out, err = wf.Runner.Invoke(context.Background(), map[string]any{
|
||||
"Str1": "str_v1",
|
||||
"Int1": nil,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"Group1": "str_v1",
|
||||
"Group2": nil,
|
||||
}, out)
|
||||
}
|
||||
|
||||
func TestTextProcessor(t *testing.T) {
|
||||
t.Run("split", func(t *testing.T) {
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "tp",
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ns := &compose2.NodeSchema{
|
||||
Key: "tp",
|
||||
Type: entity.NodeTypeTextProcessor,
|
||||
Configs: map[string]any{
|
||||
"Type": textprocessor.SplitText,
|
||||
"Separators": []string{"|"},
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"String"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"Str"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
ns,
|
||||
entry,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "tp",
|
||||
},
|
||||
{
|
||||
FromNode: "tp",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
wf, err := compose2.NewWorkflow(context.Background(), ws)
|
||||
|
||||
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
|
||||
"Str": "a|b|c",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"output": []any{"a", "b", "c"},
|
||||
}, out)
|
||||
})
|
||||
|
||||
t.Run("concat", func(t *testing.T) {
|
||||
entry := &compose2.NodeSchema{
|
||||
Key: entity.EntryNodeKey,
|
||||
Type: entity.NodeTypeEntry,
|
||||
Configs: map[string]any{
|
||||
"DefaultValues": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
exit := &compose2.NodeSchema{
|
||||
Key: entity.ExitNodeKey,
|
||||
Type: entity.NodeTypeExit,
|
||||
Configs: map[string]any{
|
||||
"TerminalPlan": vo.ReturnVariables,
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"output"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: "tp",
|
||||
FromPath: compose.FieldPath{"output"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ns := &compose2.NodeSchema{
|
||||
Key: "tp",
|
||||
Type: entity.NodeTypeTextProcessor,
|
||||
Configs: map[string]any{
|
||||
"Type": textprocessor.ConcatText,
|
||||
"Tpl": "{{String1}}_{{String2.f1}}_{{String3.f2[1]}}",
|
||||
"ConcatChar": "\t",
|
||||
},
|
||||
InputSources: []*vo.FieldInfo{
|
||||
{
|
||||
Path: compose.FieldPath{"String1"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"Str1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"String2"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"Str2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: compose.FieldPath{"String3"},
|
||||
Source: vo.FieldSource{
|
||||
Ref: &vo.Reference{
|
||||
FromNodeKey: entry.Key,
|
||||
FromPath: compose.FieldPath{"Str3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws := &compose2.WorkflowSchema{
|
||||
Nodes: []*compose2.NodeSchema{
|
||||
ns,
|
||||
entry,
|
||||
exit,
|
||||
},
|
||||
Connections: []*compose2.Connection{
|
||||
{
|
||||
FromNode: entry.Key,
|
||||
ToNode: "tp",
|
||||
},
|
||||
{
|
||||
FromNode: "tp",
|
||||
ToNode: exit.Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ws.Init()
|
||||
|
||||
ctx := context.Background()
|
||||
wf, err := compose2.NewWorkflow(ctx, ws)
|
||||
assert.NoError(t, err)
|
||||
|
||||
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
|
||||
"Str1": true,
|
||||
"Str2": map[string]any{
|
||||
"f1": 1.0,
|
||||
},
|
||||
"Str3": map[string]any{
|
||||
"f2": []any{1, "a"},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"output": "true_1_a",
|
||||
}, out)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user