refactor: how to add a node type in workflow (#558)

This commit is contained in:
shentongmartin
2025-08-05 14:02:33 +08:00
committed by GitHub
parent 5dafd81a3f
commit bb6ff0026b
96 changed files with 8305 additions and 8717 deletions

View File

@@ -20,8 +20,11 @@ import (
"context"
"fmt"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/canvas/convert"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/schema"
"github.com/coze-dev/coze-studio/backend/pkg/ctxcache"
"github.com/coze-dev/coze-studio/backend/pkg/errorx"
"github.com/coze-dev/coze-studio/backend/pkg/sonic"
@@ -34,32 +37,42 @@ const (
warningsKey = "deserialization_warnings"
)
type DeserializationConfig struct {
OutputFields map[string]*vo.TypeInfo `json:"outputFields,omitempty"`
type DeserializationConfig struct{}
func (d *DeserializationConfig) Adapt(_ context.Context, n *vo.Node, _ ...nodes.AdaptOption) (
*schema.NodeSchema, error) {
ns := &schema.NodeSchema{
Key: vo.NodeKey(n.ID),
Type: entity.NodeTypeJsonDeserialization,
Name: n.Data.Meta.Title,
Configs: d,
}
if err := convert.SetInputsForNodeSchema(n, ns); err != nil {
return nil, err
}
if err := convert.SetOutputTypesForNodeSchema(n, ns); err != nil {
return nil, err
}
return ns, nil
}
type Deserializer struct {
config *DeserializationConfig
typeInfo *vo.TypeInfo
}
func NewJsonDeserializer(_ context.Context, cfg *DeserializationConfig) (*Deserializer, error) {
if cfg == nil {
return nil, fmt.Errorf("config required")
}
if cfg.OutputFields == nil {
return nil, fmt.Errorf("OutputFields is required for deserialization")
}
typeInfo := cfg.OutputFields[OutputKeyDeserialization]
if typeInfo == nil {
func (d *DeserializationConfig) Build(_ context.Context, ns *schema.NodeSchema, _ ...schema.BuildOption) (any, error) {
typeInfo, ok := ns.OutputTypes[OutputKeyDeserialization]
if !ok {
return nil, fmt.Errorf("no output field specified in deserialization config")
}
return &Deserializer{
config: cfg,
typeInfo: typeInfo,
}, nil
}
type Deserializer struct {
typeInfo *vo.TypeInfo
}
func (jd *Deserializer) Invoke(ctx context.Context, input map[string]any) (map[string]any, error) {
jsonStrValue := input[InputKeyDeserialization]

View File

@@ -24,6 +24,7 @@ import (
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/schema"
"github.com/coze-dev/coze-studio/backend/pkg/ctxcache"
"github.com/coze-dev/coze-studio/backend/pkg/sonic"
)
@@ -31,19 +32,9 @@ import (
func TestNewJsonDeserializer(t *testing.T) {
ctx := context.Background()
// Test with nil config
_, err := NewJsonDeserializer(ctx, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "config required")
// Test with missing OutputFields config
_, err = NewJsonDeserializer(ctx, &DeserializationConfig{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "OutputFields is required")
// Test with missing output key in OutputFields
_, err = NewJsonDeserializer(ctx, &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
_, err := (&DeserializationConfig{}).Build(ctx, &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
"testKey": {Type: vo.DataTypeString},
},
})
@@ -51,12 +42,12 @@ func TestNewJsonDeserializer(t *testing.T) {
assert.Contains(t, err.Error(), "no output field specified in deserialization config")
// Test with valid config
validConfig := &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
validConfig := &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeString},
},
}
processor, err := NewJsonDeserializer(ctx, validConfig)
processor, err := (&DeserializationConfig{}).Build(ctx, validConfig)
assert.NoError(t, err)
assert.NotNil(t, processor)
}
@@ -65,16 +56,16 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
ctx := context.Background()
// Base type test config
baseTypeConfig := &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeString},
baseTypeConfig := &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeString},
},
}
// Object type test config
objectTypeConfig := &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {
objectTypeConfig := &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {
Type: vo.DataTypeObject,
Properties: map[string]*vo.TypeInfo{
"name": {Type: vo.DataTypeString, Required: true},
@@ -85,9 +76,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
}
// Array type test config
arrayTypeConfig := &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {
arrayTypeConfig := &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{Type: vo.DataTypeInteger},
},
@@ -95,9 +86,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
}
// Nested array object test config
nestedArrayConfig := &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {
nestedArrayConfig := &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{
Type: vo.DataTypeObject,
@@ -113,7 +104,7 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
// Test cases
tests := []struct {
name string
config *DeserializationConfig
config *schema.NodeSchema
inputJSON string
expectedOutput any
expectErr bool
@@ -127,9 +118,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 0,
}, {
name: "Test integer deserialization",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeInteger},
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeInteger},
},
},
inputJSON: `123`,
@@ -138,9 +129,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 0,
}, {
name: "Test boolean deserialization",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeBoolean},
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeBoolean},
},
},
inputJSON: `true`,
@@ -180,9 +171,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 0,
}, {
name: "Test type mismatch warning",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeInteger},
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeInteger},
},
},
inputJSON: `"not a number"`,
@@ -198,9 +189,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 0,
}, {
name: "Test string to integer conversion",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeInteger},
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeInteger},
},
},
inputJSON: `"123"`,
@@ -209,9 +200,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 0,
}, {
name: "Test float to integer conversion (integer part)",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeInteger},
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeInteger},
},
},
inputJSON: `123.0`,
@@ -220,9 +211,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 0,
}, {
name: "Test float to integer conversion (non-integer part)",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeInteger},
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeInteger},
},
},
inputJSON: `123.5`,
@@ -231,9 +222,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 0,
}, {
name: "Test boolean to integer conversion",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeInteger},
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeInteger},
},
},
inputJSON: `true`,
@@ -242,9 +233,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 1,
}, {
name: "Test string to boolean conversion",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeBoolean},
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeBoolean},
},
},
inputJSON: `"true"`,
@@ -252,10 +243,11 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectErr: false,
expectWarnings: 0,
}, {
name: "Test string to integer conversion in nested object",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {
name: "Test string to integer conversion in nested object",
inputJSON: `{"age":"456"}`,
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {
Type: vo.DataTypeObject,
Properties: map[string]*vo.TypeInfo{
"age": {Type: vo.DataTypeInteger},
@@ -263,15 +255,14 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
},
},
},
inputJSON: `{"age":"456"}`,
expectedOutput: map[string]any{"age": 456},
expectErr: false,
expectWarnings: 0,
}, {
name: "Test string to integer conversion for array elements",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{Type: vo.DataTypeInteger},
},
@@ -283,9 +274,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 0,
}, {
name: "Test string with non-numeric characters to integer conversion",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {Type: vo.DataTypeInteger},
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {Type: vo.DataTypeInteger},
},
},
inputJSON: `"123abc"`,
@@ -294,9 +285,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 1,
}, {
name: "Test type mismatch in nested object field",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {
Type: vo.DataTypeObject,
Properties: map[string]*vo.TypeInfo{
"score": {Type: vo.DataTypeInteger},
@@ -310,9 +301,9 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
expectWarnings: 1,
}, {
name: "Test partial conversion failure in array elements",
config: &DeserializationConfig{
OutputFields: map[string]*vo.TypeInfo{
"output": {
config: &schema.NodeSchema{
OutputTypes: map[string]*vo.TypeInfo{
OutputKeyDeserialization: {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{Type: vo.DataTypeInteger},
},
@@ -326,12 +317,12 @@ func TestJsonDeserializer_Invoke(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
processor, err := NewJsonDeserializer(ctx, tt.config)
processor, err := (&DeserializationConfig{}).Build(ctx, tt.config)
assert.NoError(t, err)
ctxWithCache := ctxcache.Init(ctx)
input := map[string]any{"input": tt.inputJSON}
result, err := processor.Invoke(ctxWithCache, input)
result, err := processor.(*Deserializer).Invoke(ctxWithCache, input)
if tt.expectErr {
assert.Error(t, err)

View File

@@ -20,7 +20,11 @@ import (
"context"
"fmt"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/canvas/convert"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/schema"
"github.com/coze-dev/coze-studio/backend/pkg/sonic"
)
@@ -29,28 +33,57 @@ const (
OutputKeySerialization = "output"
)
// SerializationConfig is the Config type for NodeTypeJsonSerialization.
// Each Node Type should have its own designated Config type,
// which should implement NodeAdaptor and NodeBuilder.
// NOTE: we didn't define any fields for this type,
// because this node is simple, we doesn't need to extract any SPECIFIC piece of info
// from frontend Node. In other cases we would need to do it, such as LLM's model configs.
type SerializationConfig struct {
InputTypes map[string]*vo.TypeInfo
// you can define ANY number of fields here,
// as long as these fields are SERIALIZABLE and EXPORTED.
// to store specific info extracted from frontend node.
// e.g.
// - LLM model configs
// - conditional expressions
// - fixed input fields such as MaxBatchSize
}
type JsonSerializer struct {
config *SerializationConfig
}
func NewJsonSerializer(_ context.Context, cfg *SerializationConfig) (*JsonSerializer, error) {
if cfg == nil {
return nil, fmt.Errorf("config required")
}
if cfg.InputTypes == nil {
return nil, fmt.Errorf("InputTypes is required for serialization")
// Adapt provides conversion from Node to NodeSchema.
// NOTE: in this specific case, we don't need AdaptOption.
func (s *SerializationConfig) Adapt(_ context.Context, n *vo.Node, _ ...nodes.AdaptOption) (*schema.NodeSchema, error) {
ns := &schema.NodeSchema{
Key: vo.NodeKey(n.ID),
Type: entity.NodeTypeJsonSerialization,
Name: n.Data.Meta.Title,
Configs: s, // remember to set the Node's Config Type to NodeSchema as well
}
return &JsonSerializer{
config: cfg,
}, nil
// this sets input fields' type and mapping info
if err := convert.SetInputsForNodeSchema(n, ns); err != nil {
return nil, err
}
// this set output fields' type info
if err := convert.SetOutputTypesForNodeSchema(n, ns); err != nil {
return nil, err
}
return ns, nil
}
func (js *JsonSerializer) Invoke(_ context.Context, input map[string]any) (map[string]any, error) {
func (s *SerializationConfig) Build(_ context.Context, _ *schema.NodeSchema, _ ...schema.BuildOption) (
any, error) {
return &Serializer{}, nil
}
// Serializer is the actual node implementation.
type Serializer struct {
// here can holds ANY data required for node execution
}
// Invoke implements the InvokableNode interface.
func (js *Serializer) Invoke(_ context.Context, input map[string]any) (map[string]any, error) {
// Directly use the input map for serialization
if input == nil {
return nil, fmt.Errorf("input data for serialization cannot be nil")

View File

@@ -23,44 +23,34 @@ import (
"github.com/stretchr/testify/assert"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/schema"
)
func TestNewJsonSerialize(t *testing.T) {
ctx := context.Background()
// Test with nil config
_, err := NewJsonSerializer(ctx, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "config required")
// Test with missing InputTypes config
_, err = NewJsonSerializer(ctx, &SerializationConfig{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "InputTypes is required")
// Test with valid config
validConfig := &SerializationConfig{
s, err := (&SerializationConfig{}).Build(ctx, &schema.NodeSchema{
InputTypes: map[string]*vo.TypeInfo{
"testKey": {Type: "string"},
},
}
processor, err := NewJsonSerializer(ctx, validConfig)
})
assert.NoError(t, err)
assert.NotNil(t, processor)
assert.NotNil(t, s)
}
func TestJsonSerialize_Invoke(t *testing.T) {
ctx := context.Background()
config := &SerializationConfig{
processor, err := (&SerializationConfig{}).Build(ctx, &schema.NodeSchema{
InputTypes: map[string]*vo.TypeInfo{
"stringKey": {Type: "string"},
"intKey": {Type: "integer"},
"boolKey": {Type: "boolean"},
"objKey": {Type: "object"},
},
}
processor, err := NewJsonSerializer(ctx, config)
})
assert.NoError(t, err)
// Test cases
@@ -115,7 +105,7 @@ func TestJsonSerialize_Invoke(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := processor.Invoke(ctx, tt.input)
result, err := processor.(*Serializer).Invoke(ctx, tt.input)
if tt.expectErr {
assert.Error(t, err)