xyflow change to flowgraam

This commit is contained in:
mlogclub
2026-06-27 21:27:57 +08:00
parent 228ad1902f
commit 689acc997c
32 changed files with 3154 additions and 5004 deletions
@@ -49,13 +49,12 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
func TestPrepareWorkflowAgentDoesNotInjectWorkflowAppendix(t *testing.T) {
db := setupWorkflowResumeTestDB(t)
definitionJSON := mustMarshalDefinition(t, dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start",
SchemaVersion: 2,
Nodes: []dsl.Node{
{ID: "start", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "handoff", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff"},
runtimeTestNode("start", workflowregistry.NodeTypeStart, "Start", nil, nil),
runtimeTestNode("handoff", workflowregistry.NodeTypeHandoffToHuman, "Handoff", nil, nil),
},
Edges: []dsl.Edge{{ID: "edge_start_handoff", Source: "start", Target: "handoff"}},
Edges: []dsl.Edge{runtimeTestEdge("edge_start_handoff", "start", "handoff")},
})
version := models.AIWorkflowVersion{
WorkflowID: 1,
@@ -219,14 +218,13 @@ func TestServiceResumeReusesInterruptedWorkflowRun(t *testing.T) {
func TestServiceRunWritesFailedWorkflowRun(t *testing.T) {
db := setupWorkflowResumeTestDB(t)
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: 2,
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "bad_1", Type: "unsupported_node", Name: "Bad"},
runtimeTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
runtimeTestNode("bad_1", "unsupported_node", "Bad", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_bad", Source: "start_1", Target: "bad_1"},
runtimeTestEdge("edge_start_bad", "start_1", "bad_1"),
},
}
version := models.AIWorkflowVersion{
@@ -335,24 +333,39 @@ func setupWorkflowResumeTestDB(t *testing.T) *gorm.DB {
func runtimeHumanConfirmDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: 2,
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认"}`)},
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
"prompt": {NodeID: "prompt_1", Field: "replyText"},
}},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
runtimeTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
runtimeTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", []byte(`{"staticReply":"请确认"}`), nil),
runtimeTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", nil, map[string]dsl.Value{
"prompt": dsl.RefValue("prompt_1", "replyText"),
}),
runtimeTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_prompt", Source: "start_1", Target: "prompt_1"},
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
{ID: "edge_confirm_end", Source: "confirm_1", Target: "end_1"},
runtimeTestEdge("edge_start_prompt", "start_1", "prompt_1"),
runtimeTestEdge("edge_prompt_confirm", "prompt_1", "confirm_1"),
runtimeTestEdge("edge_confirm_end", "confirm_1", "end_1"),
},
}
}
func runtimeTestNode(id string, nodeType string, title string, config []byte, inputs map[string]dsl.Value) dsl.Node {
return dsl.Node{
ID: id,
Type: nodeType,
Data: dsl.NodeData{
Title: title,
Config: config,
InputsValues: inputs,
},
}
}
func runtimeTestEdge(id string, source string, target string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: id}
}
func mustMarshalDefinition(t *testing.T, def dsl.Definition) string {
t.Helper()
buf, err := json.Marshal(def)
+68 -41
View File
@@ -112,7 +112,7 @@ type conditionEvaluation struct {
func (e *Executor) Execute(ctx context.Context, input Input) (*Result, error) {
state := newRunState(input)
currentID := strings.TrimSpace(input.Definition.EntryNodeID)
currentID := state.startNodeID()
if currentID == "" {
return nil, fmt.Errorf("workflow entry node is required")
}
@@ -247,11 +247,20 @@ func newRunState(input Input) *runState {
}
}
for _, edge := range input.Definition.Edges {
state.outgoing[edge.Source] = append(state.outgoing[edge.Source], edge)
state.outgoing[strings.TrimSpace(edge.SourceNodeID)] = append(state.outgoing[strings.TrimSpace(edge.SourceNodeID)], edge)
}
return state
}
func (s *runState) startNodeID() string {
for _, node := range s.nodesByID {
if strings.TrimSpace(node.Type) == workflowregistry.NodeTypeStart {
return strings.TrimSpace(node.ID)
}
}
return ""
}
func (e *Executor) executeNode(ctx context.Context, state *runState, node dsl.Node) error {
switch node.Type {
case workflowregistry.NodeTypeStart:
@@ -587,19 +596,19 @@ func (e *Executor) executePrepareTicketDraft(ctx context.Context, state *runStat
input := graphs.PrepareTicketDraftInput{
Issue: issue,
}
if title := strings.TrimSpace(readStringConfig(node.Config, "title")); title != "" {
if title := strings.TrimSpace(readStringConfig(node.Data.Config, "title")); title != "" {
input.Title = title
}
if description := strings.TrimSpace(readStringConfig(node.Config, "description")); description != "" {
if description := strings.TrimSpace(readStringConfig(node.Data.Config, "description")); description != "" {
input.Description = description
}
if impact := strings.TrimSpace(readStringConfig(node.Config, "impact")); impact != "" {
if impact := strings.TrimSpace(readStringConfig(node.Data.Config, "impact")); impact != "" {
input.Impact = impact
}
if expectedOutcome := strings.TrimSpace(readStringConfig(node.Config, "expectedOutcome")); expectedOutcome != "" {
if expectedOutcome := strings.TrimSpace(readStringConfig(node.Data.Config, "expectedOutcome")); expectedOutcome != "" {
input.ExpectedOutcome = expectedOutcome
}
if currentAttempt := strings.TrimSpace(readStringConfig(node.Config, "currentAttempt")); currentAttempt != "" {
if currentAttempt := strings.TrimSpace(readStringConfig(node.Data.Config, "currentAttempt")); currentAttempt != "" {
input.CurrentAttempt = currentAttempt
}
args, err := json.Marshal(input)
@@ -632,20 +641,20 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
input := graphs.AnalyzeConversationInput{
ObservedIssue: userMessage,
}
if strings.TrimSpace(readStringConfig(node.Config, "goal")) != "" {
input.Goal = strings.TrimSpace(readStringConfig(node.Config, "goal"))
if strings.TrimSpace(readStringConfig(node.Data.Config, "goal")) != "" {
input.Goal = strings.TrimSpace(readStringConfig(node.Data.Config, "goal"))
}
if readBoolConfig(node.Config, "needTicket") {
if readBoolConfig(node.Data.Config, "needTicket") {
input.NeedTicket = true
}
if readBoolConfig(node.Config, "needHumanHandoff") {
if readBoolConfig(node.Data.Config, "needHumanHandoff") {
input.NeedHumanHandoff = true
}
if readBoolConfig(node.Config, "needQualityCheck") {
if readBoolConfig(node.Data.Config, "needQualityCheck") {
input.NeedQualityCheck = true
}
if strings.TrimSpace(readStringConfig(node.Config, "additionalContext")) != "" {
input.AdditionalContext = strings.TrimSpace(readStringConfig(node.Config, "additionalContext"))
if strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext")) != "" {
input.AdditionalContext = strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext"))
}
args, err := json.Marshal(input)
if err != nil {
@@ -670,7 +679,7 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
}
func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
if _, hasConfirmedInput := node.Inputs["confirmed"]; hasConfirmedInput && !truthy(state.resolveInput(node, "confirmed")) {
if _, hasConfirmedInput := node.Data.InputsValues["confirmed"]; hasConfirmedInput && !truthy(state.resolveInput(node, "confirmed")) {
state.setNodeVars(node.ID, map[string]any{
"handoffId": int64(0),
"reason": strings.TrimSpace(toString(state.resolveInput(node, "reason"))),
@@ -751,7 +760,7 @@ func (e *Executor) executeAnswerabilityGate(state *runState, node dsl.Node) erro
}
func (e *Executor) executeLLMReply(ctx context.Context, state *runState, node dsl.Node) error {
if staticReply := strings.TrimSpace(readStringConfig(node.Config, "staticReply")); staticReply != "" {
if staticReply := strings.TrimSpace(readStringConfig(node.Data.Config, "staticReply")); staticReply != "" {
state.setNodeVars(node.ID, map[string]any{"replyText": staticReply})
return nil
}
@@ -761,10 +770,10 @@ func (e *Executor) executeLLMReply(ctx context.Context, state *runState, node ds
}
knowledgeItems := toString(state.resolveInput(node, "knowledgeItems"))
systemPrompt := strings.TrimSpace(state.input.AIAgent.SystemPrompt)
if prompt := strings.TrimSpace(readStringConfig(node.Config, "prompt")); prompt != "" {
if prompt := strings.TrimSpace(readStringConfig(node.Data.Config, "prompt")); prompt != "" {
systemPrompt = strings.TrimSpace(systemPrompt + "\n\n" + prompt)
}
if _, declaresKnowledge := node.Inputs["knowledgeItems"]; declaresKnowledge && len(utils.SplitInt64s(state.input.AIAgent.KnowledgeIDs)) > 0 && !hasItems(state.resolveInput(node, "knowledgeItems")) {
if _, declaresKnowledge := node.Data.InputsValues["knowledgeItems"]; declaresKnowledge && len(utils.SplitInt64s(state.input.AIAgent.KnowledgeIDs)) > 0 && !hasItems(state.resolveInput(node, "knowledgeItems")) {
state.setNodeVars(node.ID, map[string]any{"replyText": workflowKnowledgeFallbackReply(state.input.AIAgent)})
return nil
}
@@ -798,11 +807,11 @@ func (s *runState) nextNodeID(sourceNodeID string) (string, bool, error) {
}
node := s.nodesByID[sourceNodeID]
if strings.TrimSpace(node.Type) != workflowregistry.NodeTypeCondition {
return strings.TrimSpace(edges[0].Target), true, nil
return strings.TrimSpace(edges[0].TargetNodeID), true, nil
}
config := dsl.ConditionConfig{}
if len(node.Config) > 0 {
if err := json.Unmarshal(node.Config, &config); err != nil {
if len(node.Data.Config) > 0 {
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
return "", false, fmt.Errorf("invalid condition node config: %w", err)
}
}
@@ -864,11 +873,13 @@ func (s *runState) evaluateConditionBranch(sourceNodeID string, branch dsl.Condi
evaluation.Matched = true
return true, evaluation, nil
}
left := s.resolveSelector(condition.Left)
operator := strings.TrimSpace(condition.Operator)
var left any
if condition.Left != nil {
evaluation.SourceNodeID = strings.TrimSpace(condition.Left.NodeID)
evaluation.SourceField = strings.TrimSpace(condition.Left.Field)
left = s.resolveValue(*condition.Left)
evaluation.SourceNodeID, evaluation.SourceField, _ = condition.Left.Ref()
evaluation.SourceNodeID = strings.TrimSpace(evaluation.SourceNodeID)
evaluation.SourceField = strings.TrimSpace(evaluation.SourceField)
}
evaluation.Operator = operator
evaluation.LeftValue = left
@@ -909,8 +920,11 @@ func (s *runState) evaluateConditionBranch(sourceNodeID string, branch dsl.Condi
func (s *runState) edgeIDForTarget(sourceNodeID string, targetNodeID string) string {
for _, edge := range s.outgoing[sourceNodeID] {
if strings.TrimSpace(edge.Target) == targetNodeID {
return strings.TrimSpace(edge.ID)
if strings.TrimSpace(edge.TargetNodeID) == targetNodeID {
if edge.SourcePortID != "" {
return strings.TrimSpace(edge.SourcePortID)
}
return strings.TrimSpace(edge.SourceNodeID + "->" + edge.TargetNodeID)
}
}
return ""
@@ -921,27 +935,27 @@ func (s *runState) setNodeVars(nodeID string, values map[string]any) {
}
func (s *runState) resolveInput(node dsl.Node, inputName string) any {
selector, ok := node.Inputs[inputName]
value, ok := node.Data.InputsValues[inputName]
if !ok {
return nil
}
return s.resolveSelector(&selector)
return s.resolveValue(value)
}
func (s *runState) nodeInputPreview(node dsl.Node) map[string]any {
inputs := make(map[string]any, len(node.Inputs))
for name, selector := range node.Inputs {
inputs[name] = s.resolveSelector(&selector)
inputs := make(map[string]any, len(node.Data.InputsValues))
for name, value := range node.Data.InputsValues {
inputs[name] = s.resolveValue(value)
}
ret := map[string]any{
"inputs": inputs,
}
if len(node.Config) > 0 {
if len(node.Data.Config) > 0 {
var cfg any
if err := json.Unmarshal(node.Config, &cfg); err == nil {
if err := json.Unmarshal(node.Data.Config, &cfg); err == nil {
ret["config"] = cfg
} else {
ret["config"] = string(node.Config)
ret["config"] = string(node.Data.Config)
}
}
return ret
@@ -969,15 +983,28 @@ func workflowPreviewJSON(value any) string {
return string(raw[:maxPreviewBytes])
}
func (s *runState) resolveSelector(selector *dsl.VariableSelector) any {
if selector == nil {
func (s *runState) resolveValue(value dsl.Value) any {
switch value.Type {
case dsl.ValueTypeRef:
nodeID, field, ok := value.Ref()
if !ok {
return nil
}
fields := s.vars[strings.TrimSpace(nodeID)]
if fields == nil {
return nil
}
return fields[strings.TrimSpace(field)]
case dsl.ValueTypeConstant:
return value.ConstantContent
case dsl.ValueTypeTemplate:
if len(value.Content) > 0 {
return value.Content[0]
}
return nil
default:
return nil
}
fields := s.vars[strings.TrimSpace(selector.NodeID)]
if fields == nil {
return nil
}
return fields[strings.TrimSpace(selector.Field)]
}
func readStringConfig(raw json.RawMessage, key string) string {
+201 -233
View File
@@ -435,286 +435,254 @@ func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace {
}
func emptyKnowledgeReplyDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Reply", Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
"knowledgeItems": {NodeID: "missing_retrieve", Field: "items"},
}},
{ID: "send_1", Type: workflowregistry.NodeTypeSendReply, Name: "Send", Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "reply_1", Field: "replyText"},
}},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
return wfTestDefinition(
[]dsl.Node{
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
wfTestNode("reply_1", workflowregistry.NodeTypeLLMReply, "Reply", map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"knowledgeItems": dsl.RefValue("missing_retrieve", "items"),
}, nil),
wfTestNode("send_1", workflowregistry.NodeTypeSendReply, "Send", wfTestInputs("replyText", "reply_1", "replyText"), nil),
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_reply", Source: "start_1", Target: "reply_1"},
{ID: "edge_reply_send", Source: "reply_1", Target: "send_1"},
{ID: "edge_send_end", Source: "send_1", Target: "end_1"},
[]dsl.Edge{
wfTestEdge("start_1", "reply_1", "edge_start_reply"),
wfTestEdge("reply_1", "send_1", "edge_reply_send"),
wfTestEdge("send_1", "end_1", "edge_send_end"),
},
}
)
}
func policyFirstWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "understanding_1", Type: workflowregistry.NodeTypeConversationUnderstanding, Name: "Understanding", Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "policy_1", Type: workflowregistry.NodeTypeReplyPolicy, Name: "Policy", Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
"messageIntent": {NodeID: "understanding_1", Field: "messageIntent"},
"answerScope": {NodeID: "understanding_1", Field: "answerScope"},
"riskSignals": {NodeID: "understanding_1", Field: "riskSignals"},
"knowledgeItems": {NodeID: "retrieve_1", Field: "items"},
}},
{ID: "policy_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Policy Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "direct", Name: "Direct", TargetNodeID: "send_direct_1", Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"},
Operator: "eq",
Right: "direct_reply",
}},
{ID: "knowledge", Name: "Knowledge", TargetNodeID: "retrieve_end", Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"},
Operator: "eq",
Right: "retrieve_knowledge",
}},
return wfTestDefinition(
[]dsl.Node{
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
wfTestNode("understanding_1", workflowregistry.NodeTypeConversationUnderstanding, "Understanding", wfTestInputs("userMessage", "start_1", "userMessage"), nil),
wfTestNode("policy_1", workflowregistry.NodeTypeReplyPolicy, "Policy", map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"messageIntent": dsl.RefValue("understanding_1", "messageIntent"),
"answerScope": dsl.RefValue("understanding_1", "answerScope"),
"riskSignals": dsl.RefValue("understanding_1", "riskSignals"),
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
}, nil),
wfTestNode("policy_route_1", workflowregistry.NodeTypeCondition, "Policy Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
wfTestConditionBranch("direct", "Direct", "send_direct_1", "policy_1", "action", "eq", "direct_reply"),
wfTestConditionBranch("knowledge", "Knowledge", "retrieve_end", "policy_1", "action", "eq", "retrieve_knowledge"),
{ID: "default", Name: "Default", TargetNodeID: "end_1", Default: true},
}})},
{ID: "send_direct_1", Type: workflowregistry.NodeTypeSendReply, Name: "Send Direct", Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "policy_1", Field: "replyText"},
}},
{ID: "retrieve_end", Type: workflowregistry.NodeTypeEnd, Name: "Retrieve"},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
}}),
wfTestNode("send_direct_1", workflowregistry.NodeTypeSendReply, "Send Direct", wfTestInputs("replyText", "policy_1", "replyText"), nil),
wfTestNode("retrieve_end", workflowregistry.NodeTypeEnd, "Retrieve", nil, nil),
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_understanding", Source: "start_1", Target: "understanding_1"},
{ID: "edge_understanding_policy", Source: "understanding_1", Target: "policy_1"},
{ID: "edge_policy_route", Source: "policy_1", Target: "policy_route_1"},
{ID: "edge_policy_direct", Source: "policy_route_1", Target: "send_direct_1"},
{ID: "edge_policy_knowledge", Source: "policy_route_1", Target: "retrieve_end"},
{ID: "edge_policy_default", Source: "policy_route_1", Target: "end_1"},
{ID: "edge_send_direct_end", Source: "send_direct_1", Target: "end_1"},
[]dsl.Edge{
wfTestEdge("start_1", "understanding_1", "edge_start_understanding"),
wfTestEdge("understanding_1", "policy_1", "edge_understanding_policy"),
wfTestEdge("policy_1", "policy_route_1", "edge_policy_route"),
wfTestEdge("policy_route_1", "send_direct_1", "edge_policy_direct"),
wfTestEdge("policy_route_1", "retrieve_end", "edge_policy_knowledge"),
wfTestEdge("policy_route_1", "end_1", "edge_policy_default"),
wfTestEdge("send_direct_1", "end_1", "edge_send_direct_end"),
},
}
)
}
func conditionalReplyDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "condition_1", Type: workflowregistry.NodeTypeCondition, Name: "Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "vip", Name: "VIP", TargetNodeID: "vip_reply", Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"},
Operator: "eq",
Right: "vip",
}},
return wfTestDefinition(
[]dsl.Node{
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
wfTestNode("condition_1", workflowregistry.NodeTypeCondition, "Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
wfTestConditionBranch("vip", "VIP", "vip_reply", "start_1", "userMessage", "eq", "vip"),
{ID: "default", Name: "Default", TargetNodeID: "normal_reply", Default: true},
}})},
{ID: "vip_reply", Type: workflowregistry.NodeTypeLLMReply, Name: "VIP", Config: []byte(`{"staticReply":"VIP reply"}`)},
{ID: "normal_reply", Type: workflowregistry.NodeTypeLLMReply, Name: "Normal", Config: []byte(`{"staticReply":"Normal reply"}`)},
{ID: "send_vip", Type: workflowregistry.NodeTypeSendReply, Name: "Send VIP", Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "vip_reply", Field: "replyText"},
}},
{ID: "send_normal", Type: workflowregistry.NodeTypeSendReply, Name: "Send Normal", Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "normal_reply", Field: "replyText"},
}},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
}}),
wfTestNode("vip_reply", workflowregistry.NodeTypeLLMReply, "VIP", nil, map[string]any{"staticReply": "VIP reply"}),
wfTestNode("normal_reply", workflowregistry.NodeTypeLLMReply, "Normal", nil, map[string]any{"staticReply": "Normal reply"}),
wfTestNode("send_vip", workflowregistry.NodeTypeSendReply, "Send VIP", wfTestInputs("replyText", "vip_reply", "replyText"), nil),
wfTestNode("send_normal", workflowregistry.NodeTypeSendReply, "Send Normal", wfTestInputs("replyText", "normal_reply", "replyText"), nil),
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_condition", Source: "start_1", Target: "condition_1"},
{ID: "edge_condition_vip", Source: "condition_1", Target: "vip_reply"},
{ID: "edge_condition_default", Source: "condition_1", Target: "normal_reply"},
{ID: "edge_vip_send", Source: "vip_reply", Target: "send_vip"},
{ID: "edge_normal_send", Source: "normal_reply", Target: "send_normal"},
{ID: "edge_send_vip_end", Source: "send_vip", Target: "end_1"},
{ID: "edge_send_normal_end", Source: "send_normal", Target: "end_1"},
[]dsl.Edge{
wfTestEdge("start_1", "condition_1", "edge_start_condition"),
wfTestEdge("condition_1", "vip_reply", "edge_condition_vip"),
wfTestEdge("condition_1", "normal_reply", "edge_condition_default"),
wfTestEdge("vip_reply", "send_vip", "edge_vip_send"),
wfTestEdge("normal_reply", "send_normal", "edge_normal_send"),
wfTestEdge("send_vip", "end_1", "edge_send_vip_end"),
wfTestEdge("send_normal", "end_1", "edge_send_normal_end"),
},
}
)
}
func createTicketWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "draft_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft", Inputs: map[string]dsl.VariableSelector{
"issue": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认创建工单"}`)},
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
"prompt": {NodeID: "prompt_1", Field: "replyText"},
}},
{ID: "confirm_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Confirm Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "yes", Name: "Yes", TargetNodeID: "create_ticket_1", Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "confirm_1", Field: "confirmed"},
Operator: "is_true",
}},
return wfTestDefinition(
[]dsl.Node{
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
wfTestNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "Draft", wfTestInputs("issue", "start_1", "userMessage"), nil),
wfTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", nil, map[string]any{"staticReply": "请确认创建工单"}),
wfTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", wfTestInputs("prompt", "prompt_1", "replyText"), nil),
wfTestNode("confirm_route_1", workflowregistry.NodeTypeCondition, "Confirm Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
wfTestConditionBranch("yes", "Yes", "create_ticket_1", "confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "Cancel", TargetNodeID: "cancel_end", Default: true},
}})},
{ID: "create_ticket_1", Type: workflowregistry.NodeTypeCreateTicket, Name: "Create Ticket", Inputs: map[string]dsl.VariableSelector{
"ticketDraft": {NodeID: "draft_1", Field: "ticketDraft"},
"confirmed": {NodeID: "confirm_1", Field: "confirmed"},
}},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
{ID: "cancel_end", Type: workflowregistry.NodeTypeEnd, Name: "Cancel"},
}}),
wfTestNode("create_ticket_1", workflowregistry.NodeTypeCreateTicket, "Create Ticket", map[string]dsl.Value{
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
"confirmed": dsl.RefValue("confirm_1", "confirmed"),
}, nil),
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
wfTestNode("cancel_end", workflowregistry.NodeTypeEnd, "Cancel", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_draft", Source: "start_1", Target: "draft_1"},
{ID: "edge_draft_prompt", Source: "draft_1", Target: "prompt_1"},
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
{ID: "edge_confirm_route", Source: "confirm_1", Target: "confirm_route_1"},
{ID: "edge_confirm_create", Source: "confirm_route_1", Target: "create_ticket_1"},
{ID: "edge_confirm_cancel", Source: "confirm_route_1", Target: "cancel_end"},
{ID: "edge_create_end", Source: "create_ticket_1", Target: "end_1"},
[]dsl.Edge{
wfTestEdge("start_1", "draft_1", "edge_start_draft"),
wfTestEdge("draft_1", "prompt_1", "edge_draft_prompt"),
wfTestEdge("prompt_1", "confirm_1", "edge_prompt_confirm"),
wfTestEdge("confirm_1", "confirm_route_1", "edge_confirm_route"),
wfTestEdge("confirm_route_1", "create_ticket_1", "edge_confirm_create"),
wfTestEdge("confirm_route_1", "cancel_end", "edge_confirm_cancel"),
wfTestEdge("create_ticket_1", "end_1", "edge_create_end"),
},
}
)
}
func humanConfirmWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认创建工单"}`)},
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
"prompt": {NodeID: "prompt_1", Field: "replyText"},
}},
{ID: "confirm_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Confirm Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "yes", Name: "Yes", TargetNodeID: "end_1", Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "confirm_1", Field: "confirmed"},
Operator: "is_true",
}},
return wfTestDefinition(
[]dsl.Node{
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
wfTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", nil, map[string]any{"staticReply": "请确认创建工单"}),
wfTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", wfTestInputs("prompt", "prompt_1", "replyText"), nil),
wfTestNode("confirm_route_1", workflowregistry.NodeTypeCondition, "Confirm Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
wfTestConditionBranch("yes", "Yes", "end_1", "confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "Cancel", TargetNodeID: "cancel_end", Default: true},
}})},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
{ID: "cancel_end", Type: workflowregistry.NodeTypeEnd, Name: "Cancel"},
}}),
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
wfTestNode("cancel_end", workflowregistry.NodeTypeEnd, "Cancel", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_prompt", Source: "start_1", Target: "prompt_1"},
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
{ID: "edge_confirm_route", Source: "confirm_1", Target: "confirm_route_1"},
{ID: "edge_confirm_yes", Source: "confirm_route_1", Target: "end_1"},
{ID: "edge_confirm_cancel", Source: "confirm_route_1", Target: "cancel_end"},
[]dsl.Edge{
wfTestEdge("start_1", "prompt_1", "edge_start_prompt"),
wfTestEdge("prompt_1", "confirm_1", "edge_prompt_confirm"),
wfTestEdge("confirm_1", "confirm_route_1", "edge_confirm_route"),
wfTestEdge("confirm_route_1", "end_1", "edge_confirm_yes"),
wfTestEdge("confirm_route_1", "cancel_end", "edge_confirm_cancel"),
},
}
)
}
func prepareTicketDraftWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "draft_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft", Inputs: map[string]dsl.VariableSelector{
"issue": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "draft_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Draft Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "ready", Name: "Ready", TargetNodeID: "ready_end", Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "draft_1", Field: "ticketDraft"},
Operator: "exists",
}},
return wfTestDefinition(
[]dsl.Node{
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
wfTestNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "Draft", wfTestInputs("issue", "start_1", "userMessage"), nil),
wfTestNode("draft_route_1", workflowregistry.NodeTypeCondition, "Draft Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
wfTestConditionBranch("ready", "Ready", "ready_end", "draft_1", "ticketDraft", "exists", nil),
{ID: "default", Name: "Default", TargetNodeID: "default_end", Default: true},
}})},
{ID: "ready_end", Type: workflowregistry.NodeTypeEnd, Name: "Ready"},
{ID: "default_end", Type: workflowregistry.NodeTypeEnd, Name: "Default"},
}}),
wfTestNode("ready_end", workflowregistry.NodeTypeEnd, "Ready", nil, nil),
wfTestNode("default_end", workflowregistry.NodeTypeEnd, "Default", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_draft", Source: "start_1", Target: "draft_1"},
{ID: "edge_draft_route", Source: "draft_1", Target: "draft_route_1"},
{ID: "edge_draft_ready", Source: "draft_route_1", Target: "ready_end"},
{ID: "edge_draft_default", Source: "draft_route_1", Target: "default_end"},
[]dsl.Edge{
wfTestEdge("start_1", "draft_1", "edge_start_draft"),
wfTestEdge("draft_1", "draft_route_1", "edge_draft_route"),
wfTestEdge("draft_route_1", "ready_end", "edge_draft_ready"),
wfTestEdge("draft_route_1", "default_end", "edge_draft_default"),
},
}
)
}
func analyzeConversationWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "analyze_1", Type: workflowregistry.NodeTypeAnalyzeConversation, Name: "Analyze", Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "analyze_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Analyze Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "handoff", Name: "Handoff", TargetNodeID: "handoff_end", Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "analyze_1", Field: "needHumanHandoff"},
Operator: "is_true",
}},
return wfTestDefinition(
[]dsl.Node{
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
wfTestNode("analyze_1", workflowregistry.NodeTypeAnalyzeConversation, "Analyze", wfTestInputs("userMessage", "start_1", "userMessage"), nil),
wfTestNode("analyze_route_1", workflowregistry.NodeTypeCondition, "Analyze Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
wfTestConditionBranch("handoff", "Handoff", "handoff_end", "analyze_1", "needHumanHandoff", "is_true", nil),
{ID: "default", Name: "Default", TargetNodeID: "default_end", Default: true},
}})},
{ID: "handoff_end", Type: workflowregistry.NodeTypeEnd, Name: "Handoff"},
{ID: "default_end", Type: workflowregistry.NodeTypeEnd, Name: "Default"},
}}),
wfTestNode("handoff_end", workflowregistry.NodeTypeEnd, "Handoff", nil, nil),
wfTestNode("default_end", workflowregistry.NodeTypeEnd, "Default", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_analyze", Source: "start_1", Target: "analyze_1"},
{ID: "edge_analyze_route", Source: "analyze_1", Target: "analyze_route_1"},
{ID: "edge_analyze_handoff", Source: "analyze_route_1", Target: "handoff_end"},
{ID: "edge_analyze_default", Source: "analyze_route_1", Target: "default_end"},
[]dsl.Edge{
wfTestEdge("start_1", "analyze_1", "edge_start_analyze"),
wfTestEdge("analyze_1", "analyze_route_1", "edge_analyze_route"),
wfTestEdge("analyze_route_1", "handoff_end", "edge_analyze_handoff"),
wfTestEdge("analyze_route_1", "default_end", "edge_analyze_default"),
},
}
)
}
func handoffWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "handoff_1", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff", Inputs: map[string]dsl.VariableSelector{
"reason": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "handoff_route_1", Type: workflowregistry.NodeTypeCondition, Name: "Handoff Route", Config: mustMarshalWorkflowTestConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "assigned", Name: "Assigned", TargetNodeID: "assigned_end", Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "handoff_1", Field: "decision"},
Operator: "eq",
Right: string(services.HandoffDecisionAssigned),
}},
return wfTestDefinition(
[]dsl.Node{
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
wfTestNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "Handoff", wfTestInputs("reason", "start_1", "userMessage"), nil),
wfTestNode("handoff_route_1", workflowregistry.NodeTypeCondition, "Handoff Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
wfTestConditionBranch("assigned", "Assigned", "assigned_end", "handoff_1", "decision", "eq", string(services.HandoffDecisionAssigned)),
{ID: "default", Name: "Default", TargetNodeID: "default_end", Default: true},
}})},
{ID: "assigned_end", Type: workflowregistry.NodeTypeEnd, Name: "Assigned"},
{ID: "default_end", Type: workflowregistry.NodeTypeEnd, Name: "Default"},
}}),
wfTestNode("assigned_end", workflowregistry.NodeTypeEnd, "Assigned", nil, nil),
wfTestNode("default_end", workflowregistry.NodeTypeEnd, "Default", nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_handoff", Source: "start_1", Target: "handoff_1"},
{ID: "edge_handoff_route", Source: "handoff_1", Target: "handoff_route_1"},
{ID: "edge_handoff_assigned", Source: "handoff_route_1", Target: "assigned_end"},
{ID: "edge_handoff_default", Source: "handoff_route_1", Target: "default_end"},
[]dsl.Edge{
wfTestEdge("start_1", "handoff_1", "edge_start_handoff"),
wfTestEdge("handoff_1", "handoff_route_1", "edge_handoff_route"),
wfTestEdge("handoff_route_1", "assigned_end", "edge_handoff_assigned"),
wfTestEdge("handoff_route_1", "default_end", "edge_handoff_default"),
},
)
}
func handoffAfterConfirmationWorkflowDefinition() dsl.Definition {
return wfTestDefinition(
[]dsl.Node{
wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
wfTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", nil, map[string]any{"staticReply": "请确认转人工"}),
wfTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", wfTestInputs("prompt", "prompt_1", "replyText"), nil),
wfTestNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "Handoff", map[string]dsl.Value{
"reason": dsl.RefValue("start_1", "userMessage"),
"confirmed": dsl.RefValue("confirm_1", "confirmed"),
}, nil),
wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
},
[]dsl.Edge{
wfTestEdge("start_1", "prompt_1", "edge_start_prompt"),
wfTestEdge("prompt_1", "confirm_1", "edge_prompt_confirm"),
wfTestEdge("confirm_1", "handoff_1", "edge_confirm_handoff"),
wfTestEdge("handoff_1", "end_1", "edge_handoff_end"),
},
)
}
func wfTestDefinition(nodes []dsl.Node, edges []dsl.Edge) dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion, Nodes: nodes, Edges: edges}
}
func wfTestNode(id string, nodeType string, title string, inputs map[string]dsl.Value, config any) dsl.Node {
return dsl.Node{
ID: id,
Type: nodeType,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 0, Y: 0}},
Data: dsl.NodeData{
Title: title,
InputsValues: inputs,
Config: mustMarshalWorkflowTestConfig(config),
},
}
}
func handoffAfterConfirmationWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认转人工"}`)},
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
"prompt": {NodeID: "prompt_1", Field: "replyText"},
}},
{ID: "handoff_1", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff", Inputs: map[string]dsl.VariableSelector{
"reason": {NodeID: "start_1", Field: "userMessage"},
"confirmed": {NodeID: "confirm_1", Field: "confirmed"},
}},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
},
Edges: []dsl.Edge{
{ID: "edge_start_prompt", Source: "start_1", Target: "prompt_1"},
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
{ID: "edge_confirm_handoff", Source: "confirm_1", Target: "handoff_1"},
{ID: "edge_handoff_end", Source: "handoff_1", Target: "end_1"},
func wfTestInputs(name string, nodeID string, field string) map[string]dsl.Value {
return map[string]dsl.Value{name: dsl.RefValue(nodeID, field)}
}
func wfTestEdge(source string, target string, id string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: id}
}
func wfTestConditionBranch(id string, name string, targetNodeID string, nodeID string, field string, operator string, right any) dsl.ConditionBranch {
return dsl.ConditionBranch{
ID: id,
Name: name,
TargetNodeID: targetNodeID,
Condition: &dsl.Condition{
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{nodeID, field}},
Operator: operator,
Right: right,
},
}
}
+152 -17
View File
@@ -2,20 +2,34 @@ package dsl
import "encoding/json"
const SchemaVersion = 2
type Definition struct {
SchemaVersion int `json:"schemaVersion"`
EntryNodeID string `json:"entryNodeId"`
Nodes []Node `json:"nodes"`
Edges []Edge `json:"edges"`
}
type Node struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Position Position `json:"position"`
Config json.RawMessage `json:"config"`
Inputs map[string]VariableSelector `json:"inputs,omitempty"`
ID string `json:"id"`
Type string `json:"type"`
Meta NodeMeta `json:"meta"`
Data NodeData `json:"data"`
Blocks []Node `json:"blocks,omitempty"`
Edges []Edge `json:"edges,omitempty"`
}
type NodeMeta struct {
Position Position `json:"position"`
}
type NodeData struct {
Title string `json:"title,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
Inputs json.RawMessage `json:"inputs,omitempty"`
Outputs json.RawMessage `json:"outputs,omitempty"`
InputsValues map[string]Value `json:"inputsValues,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}
type Position struct {
@@ -24,9 +38,25 @@ type Position struct {
}
type Edge struct {
ID string `json:"id"`
Source string `json:"source"`
Target string `json:"target"`
SourceNodeID string `json:"sourceNodeID"`
TargetNodeID string `json:"targetNodeID"`
SourcePortID string `json:"sourcePortID,omitempty"`
TargetPortID string `json:"targetPortID,omitempty"`
}
type ValueType string
const (
ValueTypeConstant ValueType = "constant"
ValueTypeRef ValueType = "ref"
ValueTypeTemplate ValueType = "template"
)
type Value struct {
Type ValueType `json:"type"`
Content []string `json:"content,omitempty"`
ConstantContent any `json:"-"`
RawContent json.RawMessage `json:"-"`
}
type ConditionConfig struct {
@@ -42,13 +72,118 @@ type ConditionBranch struct {
}
type Condition struct {
Expression string `json:"expression,omitempty"`
Left *VariableSelector `json:"left,omitempty"`
Operator string `json:"operator,omitempty"`
Right any `json:"right,omitempty"`
Expression string `json:"expression,omitempty"`
Left *Value `json:"left,omitempty"`
Operator string `json:"operator,omitempty"`
Right any `json:"right,omitempty"`
}
type VariableSelector struct {
NodeID string `json:"nodeId"`
Field string `json:"field"`
func RefValue(nodeID string, field string) Value {
return Value{Type: ValueTypeRef, Content: []string{nodeID, field}}
}
func ConstantValue(value any) Value {
raw, _ := json.Marshal(value)
return Value{Type: ValueTypeConstant, ConstantContent: value, RawContent: raw}
}
func TemplateValue(value string) Value {
return Value{Type: ValueTypeTemplate, Content: []string{value}}
}
func (v Value) Ref() (nodeID string, field string, ok bool) {
if v.Type != ValueTypeRef || len(v.Content) < 2 {
return "", "", false
}
return v.Content[0], v.Content[1], true
}
func (v *Value) UnmarshalJSON(data []byte) error {
type alias struct {
Type ValueType `json:"type"`
Content json.RawMessage `json:"content"`
}
var parsed alias
if err := json.Unmarshal(data, &parsed); err != nil {
return err
}
v.Type = parsed.Type
v.RawContent = append(v.RawContent[:0], parsed.Content...)
switch parsed.Type {
case ValueTypeRef:
var content []string
if len(parsed.Content) > 0 {
if err := json.Unmarshal(parsed.Content, &content); err != nil {
return err
}
}
v.Content = content
case ValueTypeTemplate:
var content string
if len(parsed.Content) > 0 {
if err := json.Unmarshal(parsed.Content, &content); err != nil {
return err
}
}
v.Content = []string{content}
case ValueTypeConstant:
if len(parsed.Content) > 0 {
if err := json.Unmarshal(parsed.Content, &v.ConstantContent); err != nil {
return err
}
}
default:
if len(parsed.Content) > 0 {
var content []string
if err := json.Unmarshal(parsed.Content, &content); err == nil {
v.Content = content
}
}
}
return nil
}
func (v Value) MarshalJSON() ([]byte, error) {
type alias struct {
Type ValueType `json:"type"`
Content any `json:"content,omitempty"`
}
var content any
switch v.Type {
case ValueTypeRef:
content = v.Content
case ValueTypeTemplate:
if len(v.Content) > 0 {
content = v.Content[0]
}
case ValueTypeConstant:
content = v.ConstantContent
default:
if len(v.Content) > 0 {
content = v.Content
}
}
return json.Marshal(alias{Type: v.Type, Content: content})
}
func (d *NodeData) UnmarshalJSON(data []byte) error {
type alias NodeData
var parsed alias
if err := json.Unmarshal(data, &parsed); err != nil {
return err
}
extra := make(map[string]json.RawMessage)
if err := json.Unmarshal(data, &extra); err != nil {
return err
}
delete(extra, "title")
delete(extra, "config")
delete(extra, "inputs")
delete(extra, "outputs")
delete(extra, "inputsValues")
*d = NodeData(parsed)
if len(extra) > 0 {
d.Extra = extra
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package dsl_test
import (
"encoding/json"
"testing"
"agent-desk/internal/ai/workflow/dsl"
)
func TestDefinitionUnmarshalsFlowGramStyleSchema(t *testing.T) {
raw := []byte(`{
"schemaVersion": 2,
"nodes": [{
"id": "send_1",
"type": "send_reply",
"meta": {
"position": { "x": 360, "y": 120 }
},
"data": {
"title": "发送回复",
"config": { "text": "hello" },
"inputs": {
"type": "object",
"properties": {
"replyText": { "type": "string" }
},
"required": ["replyText"]
},
"outputs": {
"type": "object",
"properties": {
"sent": { "type": "boolean" }
}
},
"inputsValues": {
"replyText": {
"type": "ref",
"content": ["start_1", "userMessage"]
}
}
}
}],
"edges": [{
"sourceNodeID": "start_1",
"targetNodeID": "send_1",
"sourcePortID": "default"
}]
}`)
var def dsl.Definition
if err := json.Unmarshal(raw, &def); err != nil {
t.Fatalf("unmarshal definition: %v", err)
}
if def.SchemaVersion != 2 {
t.Fatalf("unexpected schema version: %d", def.SchemaVersion)
}
node := def.Nodes[0]
if node.ID != "send_1" || node.Type != "send_reply" {
t.Fatalf("unexpected node identity: %#v", node)
}
if node.Meta.Position.X != 360 || node.Meta.Position.Y != 120 {
t.Fatalf("unexpected node position: %#v", node.Meta.Position)
}
if node.Data.Title != "发送回复" {
t.Fatalf("unexpected node title: %q", node.Data.Title)
}
var config map[string]string
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
if config["text"] != "hello" {
t.Fatalf("unexpected config: %s", node.Data.Config)
}
replyText := node.Data.InputsValues["replyText"]
if replyText.Type != dsl.ValueTypeRef || len(replyText.Content) != 2 || replyText.Content[0] != "start_1" || replyText.Content[1] != "userMessage" {
t.Fatalf("unexpected replyText value: %#v", replyText)
}
edge := def.Edges[0]
if edge.SourceNodeID != "start_1" || edge.TargetNodeID != "send_1" || edge.SourcePortID != "default" {
t.Fatalf("unexpected edge: %#v", edge)
}
}
+4 -4
View File
@@ -67,8 +67,8 @@ func DefaultRegistry() *Registry {
output("riskSignals", VariableTypeStringArray, "Detected risk signals."),
output("reason", VariableTypeString, "Decision reason."),
},
DefaultInputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
DefaultInputs: map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
},
},
NodeSpec{
@@ -123,8 +123,8 @@ func DefaultRegistry() *Registry {
output("items", VariableTypeObjectArray, "Retrieved knowledge items."),
output("summary", VariableTypeString, "Short retrieval summary."),
},
DefaultInputs: map[string]dsl.VariableSelector{
"query": {NodeID: "start_1", Field: "userMessage"},
DefaultInputs: map[string]dsl.Value{
"query": dsl.RefValue("start_1", "userMessage"),
},
},
NodeSpec{
+10 -10
View File
@@ -41,16 +41,16 @@ type VariableValueOption struct {
}
type NodeSpec struct {
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
RiskLevel NodeRiskLevel `json:"riskLevel"`
Interruptible bool `json:"interruptible"`
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
ConfigSchema any `json:"configSchema,omitempty"`
InputSchema []VariableSpec `json:"inputSchema,omitempty"`
OutputSchema []VariableSpec `json:"outputSchema,omitempty"`
DefaultInputs map[string]dsl.VariableSelector `json:"defaultInputs,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
RiskLevel NodeRiskLevel `json:"riskLevel"`
Interruptible bool `json:"interruptible"`
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
ConfigSchema any `json:"configSchema,omitempty"`
InputSchema []VariableSpec `json:"inputSchema,omitempty"`
OutputSchema []VariableSpec `json:"outputSchema,omitempty"`
DefaultInputs map[string]dsl.Value `json:"defaultInputs,omitempty"`
}
type Registry struct {
+61 -70
View File
@@ -53,7 +53,6 @@ type definitionValidator struct {
func (v *definitionValidator) validate() {
v.validateNodes()
v.validateEdges()
v.validateEntry()
v.validateReachability()
v.validateConfirmationGuards()
v.validateVariableMappings()
@@ -98,53 +97,29 @@ func (v *definitionValidator) validateNodes() {
}
func (v *definitionValidator) validateEdges() {
seen := make(map[string]struct{}, len(v.def.Edges))
for index, edge := range v.def.Edges {
edge.ID = strings.TrimSpace(edge.ID)
edge.Source = strings.TrimSpace(edge.Source)
edge.Target = strings.TrimSpace(edge.Target)
source := strings.TrimSpace(edge.SourceNodeID)
target := strings.TrimSpace(edge.TargetNodeID)
field := fmt.Sprintf("edges[%d]", index)
if edge.ID == "" {
v.addError(field+".id", "edge id is required")
} else if _, exists := seen[edge.ID]; exists {
v.addError(field+".id", "duplicate edge id: "+edge.ID)
if source == "" {
v.addError(field+".sourceNodeID", "edge source node is required")
} else if _, ok := v.nodesByID[source]; !ok {
v.addError(field+".sourceNodeID", "edge source node does not exist: "+source)
}
seen[edge.ID] = struct{}{}
if edge.Source == "" {
v.addError(field+".source", "edge source is required")
} else if _, ok := v.nodesByID[edge.Source]; !ok {
v.addError(field+".source", "edge source node does not exist: "+edge.Source)
if target == "" {
v.addError(field+".targetNodeID", "edge target node is required")
} else if _, ok := v.nodesByID[target]; !ok {
v.addError(field+".targetNodeID", "edge target node does not exist: "+target)
}
if edge.Target == "" {
v.addError(field+".target", "edge target is required")
} else if _, ok := v.nodesByID[edge.Target]; !ok {
v.addError(field+".target", "edge target node does not exist: "+edge.Target)
if source != "" && target != "" {
v.outgoing[source] = append(v.outgoing[source], target)
v.incoming[target] = append(v.incoming[target], source)
}
if edge.Source != "" && edge.Target != "" {
v.outgoing[edge.Source] = append(v.outgoing[edge.Source], edge.Target)
v.incoming[edge.Target] = append(v.incoming[edge.Target], edge.Source)
}
}
}
func (v *definitionValidator) validateEntry() {
entryNodeID := strings.TrimSpace(v.def.EntryNodeID)
if entryNodeID == "" {
v.addError("entryNodeId", "entry node id is required")
return
}
entry, ok := v.nodesByID[entryNodeID]
if !ok {
v.addError("entryNodeId", "entry node does not exist: "+entryNodeID)
return
}
if entry.Type != registry.NodeTypeStart {
v.addError("entryNodeId", "entry node must be the start node")
}
}
func (v *definitionValidator) validateReachability() {
entryNodeID := strings.TrimSpace(v.def.EntryNodeID)
entryNodeID := v.entryNodeID()
if entryNodeID == "" {
return
}
@@ -187,17 +162,17 @@ func (v *definitionValidator) validateConfirmationGuards() {
}
func (v *definitionValidator) validateConfirmedInput(nodeID string, node dsl.Node) {
selector, ok := node.Inputs["confirmed"]
if !ok || strings.TrimSpace(selector.NodeID) == "" || strings.TrimSpace(selector.Field) == "" {
value, ok := node.Data.InputsValues["confirmed"]
sourceNodeID, sourceField, refOK := value.Ref()
if !ok || !refOK || strings.TrimSpace(sourceNodeID) == "" || strings.TrimSpace(sourceField) == "" {
return
}
sourceNodeID := strings.TrimSpace(selector.NodeID)
sourceNode, ok := v.nodesByID[sourceNodeID]
if !ok {
return
}
if sourceNode.Type != registry.NodeTypeHumanConfirm || strings.TrimSpace(selector.Field) != "confirmed" {
v.addError("nodes."+nodeID+".inputs.confirmed", "confirmed input must come from human_confirm.confirmed")
if sourceNode.Type != registry.NodeTypeHumanConfirm || strings.TrimSpace(sourceField) != "confirmed" {
v.addError("nodes."+nodeID+".data.inputsValues.confirmed", "confirmed input must come from human_confirm.confirmed")
}
}
@@ -211,47 +186,55 @@ func (v *definitionValidator) validateVariableMappings() {
if !input.Required {
continue
}
selector, ok := node.Inputs[input.Name]
if !ok || strings.TrimSpace(selector.NodeID) == "" || strings.TrimSpace(selector.Field) == "" {
v.addError("nodes."+id+".inputs."+input.Name, "required input mapping is missing: "+input.Name)
value, ok := node.Data.InputsValues[input.Name]
if !ok {
v.addError("nodes."+id+".data.inputsValues."+input.Name, "required input mapping is missing: "+input.Name)
continue
}
v.validateInputSelector(id, input, selector)
v.validateInputValue(id, input, value)
}
for inputName, selector := range node.Inputs {
if strings.TrimSpace(selector.NodeID) == "" || strings.TrimSpace(selector.Field) == "" {
v.addError("nodes."+id+".inputs."+inputName, "input mapping source is required")
continue
}
for inputName, value := range node.Data.InputsValues {
if _, ok := findInputSpec(spec.InputSchema, inputName); ok {
continue
}
sourceNode, sourceOK := v.nodesByID[strings.TrimSpace(selector.NodeID)]
sourceNodeID, sourceField, refOK := value.Ref()
if !refOK {
continue
}
sourceNode, sourceOK := v.nodesByID[strings.TrimSpace(sourceNodeID)]
if !sourceOK {
v.addError("nodes."+id+".inputs."+inputName, "input source node does not exist: "+selector.NodeID)
v.addError("nodes."+id+".data.inputsValues."+inputName, "input source node does not exist: "+sourceNodeID)
continue
}
sourceSpec, sourceSpecOK := v.registry.Get(sourceNode.Type)
if !sourceSpecOK {
continue
}
if _, ok := findOutputSpec(sourceSpec.OutputSchema, selector.Field); !ok {
v.addError("nodes."+id+".inputs."+inputName, "input source field does not exist: "+selector.NodeID+"."+selector.Field)
if _, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField); !ok {
v.addError("nodes."+id+".data.inputsValues."+inputName, "input source field does not exist: "+sourceNodeID+"."+sourceField)
}
}
}
}
func (v *definitionValidator) validateInputSelector(nodeID string, input registry.VariableSpec, selector dsl.VariableSelector) {
sourceNodeID := strings.TrimSpace(selector.NodeID)
sourceField := strings.TrimSpace(selector.Field)
func (v *definitionValidator) validateInputValue(nodeID string, input registry.VariableSpec, value dsl.Value) {
sourceNodeID, sourceField, ok := value.Ref()
if !ok {
if value.Type == dsl.ValueTypeConstant || value.Type == dsl.ValueTypeTemplate {
return
}
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input mapping source is required")
return
}
sourceNodeID = strings.TrimSpace(sourceNodeID)
sourceField = strings.TrimSpace(sourceField)
sourceNode, ok := v.nodesByID[sourceNodeID]
if !ok {
v.addError("nodes."+nodeID+".inputs."+input.Name, "input source node does not exist: "+sourceNodeID)
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input source node does not exist: "+sourceNodeID)
return
}
if !v.hasPath(sourceNodeID, nodeID, make(map[string]struct{})) {
v.addError("nodes."+nodeID+".inputs."+input.Name, "input source node is not available before current node: "+sourceNodeID)
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input source node is not available before current node: "+sourceNodeID)
return
}
sourceSpec, ok := v.registry.Get(sourceNode.Type)
@@ -260,11 +243,11 @@ func (v *definitionValidator) validateInputSelector(nodeID string, input registr
}
output, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField)
if !ok {
v.addError("nodes."+nodeID+".inputs."+input.Name, "input source field does not exist: "+sourceNodeID+"."+sourceField)
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input source field does not exist: "+sourceNodeID+"."+sourceField)
return
}
if !variableTypesCompatible(input.Type, output.Type) {
v.addError("nodes."+nodeID+".inputs."+input.Name, fmt.Sprintf("input type mismatch: %s expects %s but %s.%s is %s", input.Name, input.Type, sourceNodeID, sourceField, output.Type))
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, fmt.Sprintf("input type mismatch: %s expects %s but %s.%s is %s", input.Name, input.Type, sourceNodeID, sourceField, output.Type))
}
}
@@ -275,8 +258,8 @@ func (v *definitionValidator) validateConditions() {
}
field := fmt.Sprintf("nodes[%d].config.branches", index)
config := dsl.ConditionConfig{}
if len(node.Config) > 0 {
if err := json.Unmarshal(node.Config, &config); err != nil {
if len(node.Data.Config) > 0 {
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
v.addError(field, "condition branches config must be valid JSON")
continue
}
@@ -341,9 +324,10 @@ func (v *definitionValidator) validateCondition(field string, sourceNodeID strin
v.addError(field+".left", "condition left variable is required")
return
}
sourceSelectorNodeID := strings.TrimSpace(condition.Left.NodeID)
sourceField := strings.TrimSpace(condition.Left.Field)
if sourceSelectorNodeID == "" || sourceField == "" {
sourceSelectorNodeID, sourceField, leftOK := condition.Left.Ref()
sourceSelectorNodeID = strings.TrimSpace(sourceSelectorNodeID)
sourceField = strings.TrimSpace(sourceField)
if !leftOK || sourceSelectorNodeID == "" || sourceField == "" {
v.addError(field+".left", "condition left variable is required")
return
}
@@ -462,13 +446,20 @@ func (v *definitionValidator) hasEdgeTo(sourceID string, targetID string) bool {
return true
}
for _, edge := range v.def.Edges {
if strings.TrimSpace(edge.Source) == sourceID && strings.TrimSpace(edge.Target) == targetID {
if strings.TrimSpace(edge.SourceNodeID) == sourceID && strings.TrimSpace(edge.TargetNodeID) == targetID {
return true
}
}
return false
}
func (v *definitionValidator) entryNodeID() string {
if len(v.startNodeIDs) != 1 {
return ""
}
return v.startNodeIDs[0]
}
func findInputSpec(items []registry.VariableSpec, name string) (registry.VariableSpec, bool) {
name = strings.TrimSpace(name)
for _, item := range items {
+124 -295
View File
@@ -10,7 +10,7 @@ import (
"agent-desk/internal/ai/workflow/validator"
)
func TestValidateDefinitionAcceptsMinimalConversationFlow(t *testing.T) {
func TestValidateDefinitionAcceptsMinimalFlowGramStyleFlow(t *testing.T) {
result := validator.ValidateDefinition(minimalDefinition(), registry.DefaultRegistry())
if !result.Valid {
@@ -21,8 +21,8 @@ func TestValidateDefinitionAcceptsMinimalConversationFlow(t *testing.T) {
func TestValidateDefinitionRejectsMissingStart(t *testing.T) {
def := minimalDefinition()
def.Nodes = []dsl.Node{
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`)},
{ID: "end_1", Type: "end"},
node("reply_1", "send_reply", inputs("replyText", dsl.RefValue("start_1", "userMessage")), nil),
node("end_1", "end", nil, nil),
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -35,148 +35,9 @@ func TestValidateDefinitionRejectsMissingStart(t *testing.T) {
}
}
func TestValidateDefinitionRejectsUnknownNodeType(t *testing.T) {
func TestValidateDefinitionRejectsMissingRequiredInputValue(t *testing.T) {
def := minimalDefinition()
def.Nodes = append(def.Nodes, dsl.Node{ID: "unknown_1", Type: "unknown_node"})
def.Edges = append(def.Edges, dsl.Edge{ID: "e3", Source: "reply_1", Target: "unknown_1"})
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unknown node type to be invalid")
}
if !hasValidationMessage(result, "unknown node type") {
t.Fatalf("expected unknown-node error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnguardedCreateTicket(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "draft_1", Type: "prepare_ticket_draft"},
{ID: "create_1", Type: "create_ticket"},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "draft_1"},
{ID: "e2", Source: "draft_1", Target: "create_1"},
{ID: "e3", Source: "create_1", Target: "end_1"},
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unguarded create_ticket to be invalid")
}
if !hasValidationMessage(result, "requires human_confirm") {
t.Fatalf("expected confirmation guard error, got %#v", result.Errors)
}
}
func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "draft_1", Type: "prepare_ticket_draft", Inputs: map[string]dsl.VariableSelector{
"issue": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "confirm_1", Type: "human_confirm", Inputs: map[string]dsl.VariableSelector{
"prompt": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "create_1", Type: "create_ticket", Inputs: map[string]dsl.VariableSelector{
"ticketDraft": {NodeID: "draft_1", Field: "ticketDraft"},
"confirmed": {NodeID: "confirm_1", Field: "confirmed"},
}},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "draft_1"},
{ID: "e2", Source: "draft_1", Target: "confirm_1"},
{ID: "e3", Source: "confirm_1", Target: "create_1"},
{ID: "e4", Source: "create_1", Target: "end_1"},
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected confirmed create_ticket to be valid, got %#v", result.Errors)
}
}
func TestValidateDefinitionAcceptsDirectHandoffToHuman(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "handoff_1", Type: "handoff_to_human", Inputs: map[string]dsl.VariableSelector{
"reason": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "handoff_1"},
{ID: "e2", Source: "handoff_1", Target: "end_1"},
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected direct handoff_to_human to be valid, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "analysis_1", Type: "analyze_conversation", Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "draft_1", Type: "prepare_ticket_draft", Inputs: map[string]dsl.VariableSelector{
"issue": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "confirm_1", Type: "human_confirm", Inputs: map[string]dsl.VariableSelector{
"prompt": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "create_1", Type: "create_ticket", Inputs: map[string]dsl.VariableSelector{
"ticketDraft": {NodeID: "draft_1", Field: "ticketDraft"},
"confirmed": {NodeID: "analysis_1", Field: "needTicket"},
}},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "analysis_1"},
{ID: "e2", Source: "analysis_1", Target: "draft_1"},
{ID: "e3", Source: "draft_1", Target: "confirm_1"},
{ID: "e4", Source: "confirm_1", Target: "create_1"},
{ID: "e5", Source: "create_1", Target: "end_1"},
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected confirmed input from non-confirm node to be invalid")
}
if !hasValidationMessage(result, "confirmed input must come from human_confirm.confirmed") {
t.Fatalf("expected confirmed-source error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsMissingRequiredInputMapping(t *testing.T) {
def := minimalDefinition()
def.Nodes[1].Inputs = nil
def.Nodes[1].Data.InputsValues = nil
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -189,8 +50,8 @@ func TestValidateDefinitionRejectsMissingRequiredInputMapping(t *testing.T) {
}
func TestValidateDefinitionRejectsUnknownInputSourceNode(t *testing.T) {
def := mappedReplyDefinition()
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "missing_1", Field: "replyText"}
def := minimalDefinition()
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("missing_1", "replyText")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -202,9 +63,25 @@ func TestValidateDefinitionRejectsUnknownInputSourceNode(t *testing.T) {
}
}
func TestValidateDefinitionRejectsUnavailableInputSourceNode(t *testing.T) {
def := minimalDefinition()
def.Nodes = append(def.Nodes, node("late_1", "llm_reply", inputs("userMessage", dsl.RefValue("reply_1", "sent")), nil))
def.Edges = append(def.Edges, edge("reply_1", "late_1"))
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("late_1", "replyText")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected downstream input source to be invalid")
}
if !hasValidationMessage(result, "input source node is not available before current node") {
t.Fatalf("expected source availability error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownInputSourceField(t *testing.T) {
def := mappedReplyDefinition()
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "missing"}
def := minimalDefinition()
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("start_1", "missing")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -217,8 +94,8 @@ func TestValidateDefinitionRejectsUnknownInputSourceField(t *testing.T) {
}
func TestValidateDefinitionRejectsIncompatibleInputType(t *testing.T) {
def := mappedReplyDefinition()
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "conversationId"}
def := minimalDefinition()
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("start_1", "conversationId")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -230,69 +107,83 @@ func TestValidateDefinitionRejectsIncompatibleInputType(t *testing.T) {
}
}
func TestValidateDefinitionAcceptsMappedKnowledgeFlow(t *testing.T) {
func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "retrieve_1", Type: "knowledge_retrieve", Inputs: map[string]dsl.VariableSelector{
"query": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "reply_1", Type: "send_reply", Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "end_1", Type: "end"},
node("start_1", "start", nil, nil),
node("draft_1", "prepare_ticket_draft", inputs("issue", dsl.RefValue("start_1", "userMessage")), nil),
node("confirm_1", "human_confirm", inputs("prompt", dsl.RefValue("start_1", "userMessage")), nil),
node("create_1", "create_ticket", map[string]dsl.Value{
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
"confirmed": dsl.RefValue("confirm_1", "confirmed"),
}, nil),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "retrieve_1"},
{ID: "e2", Source: "retrieve_1", Target: "reply_1"},
{ID: "e3", Source: "reply_1", Target: "end_1"},
edge("start_1", "draft_1"),
edge("draft_1", "confirm_1"),
edge("confirm_1", "create_1"),
edge("create_1", "end_1"),
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected mapped knowledge flow to be valid, got %#v", result.Errors)
t.Fatalf("expected confirmed create_ticket to be valid, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownConditionOperator(t *testing.T) {
def := conditionDefinition()
var config dsl.ConditionConfig
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
t.Fatalf("unmarshal condition config: %v", err)
func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T) {
def := minimalDefinition()
def.Nodes = []dsl.Node{
node("start_1", "start", nil, nil),
node("draft_1", "prepare_ticket_draft", inputs("issue", dsl.RefValue("start_1", "userMessage")), nil),
node("create_1", "create_ticket", map[string]dsl.Value{
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
"confirmed": dsl.RefValue("start_1", "userMessage"),
}, nil),
node("end_1", "end", nil, nil),
}
config.Branches[0].Condition.Operator = "regex"
raw, err := json.Marshal(config)
if err != nil {
t.Fatalf("marshal condition config: %v", err)
def.Edges = []dsl.Edge{
edge("start_1", "draft_1"),
edge("draft_1", "create_1"),
edge("create_1", "end_1"),
}
def.Nodes[1].Config = raw
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unknown condition operator to be invalid")
t.Fatalf("expected confirmed input from non-confirm node to be invalid")
}
if !hasValidationMessage(result, "unsupported condition operator") {
t.Fatalf("expected condition operator error, got %#v", result.Errors)
if !hasValidationMessage(result, "confirmed input must come from human_confirm.confirmed") {
t.Fatalf("expected confirmed-source error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConditionBranchTargetWithoutEdge(t *testing.T) {
def := conditionDefinition()
def.Edges = []dsl.Edge{edge("start_1", "condition_1")}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected condition branch target without edge to be invalid")
}
if !hasValidationMessage(result, "condition branch target must have an outgoing edge") {
t.Fatalf("expected branch edge error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownConditionVariable(t *testing.T) {
def := conditionDefinition()
var config dsl.ConditionConfig
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
if err := json.Unmarshal(def.Nodes[1].Data.Config, &config); err != nil {
t.Fatalf("unmarshal condition config: %v", err)
}
config.Branches[0].Condition.Left.Field = "missing"
raw, err := json.Marshal(config)
if err != nil {
t.Fatalf("marshal condition config: %v", err)
}
def.Nodes[1].Config = raw
config.Branches[0].Condition.Left = &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{"start_1", "missing"}}
def.Nodes[1].Data.Config = mustJSON(config)
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -304,123 +195,30 @@ func TestValidateDefinitionRejectsUnknownConditionVariable(t *testing.T) {
}
}
func TestValidateDefinitionRejectsInvalidConditionEnumValue(t *testing.T) {
def := policyConditionDefinition("unknown_action")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected invalid enum condition value to be rejected")
}
if !hasValidationMessage(result, "condition comparison value is not allowed") {
t.Fatalf("expected condition enum value error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConditionDefaultBranchBeforeLast(t *testing.T) {
def := conditionDefinition()
var config dsl.ConditionConfig
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
t.Fatalf("unmarshal condition config: %v", err)
}
config.Branches[0], config.Branches[1] = config.Branches[1], config.Branches[0]
raw, err := json.Marshal(config)
if err != nil {
t.Fatalf("marshal condition config: %v", err)
}
def.Nodes[1].Config = raw
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected default branch before last to be invalid")
}
if !hasValidationMessage(result, "default condition branch must be last") {
t.Fatalf("expected default branch order error, got %#v", result.Errors)
}
}
func minimalDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`), Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "end_1", Type: "end"},
node("start_1", "start", nil, nil),
node("reply_1", "send_reply", inputs("replyText", dsl.RefValue("start_1", "userMessage")), map[string]any{"text": "hello"}),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "reply_1"},
{ID: "e2", Source: "reply_1", Target: "end_1"},
edge("start_1", "reply_1"),
edge("reply_1", "end_1"),
},
}
}
func policyConditionDefinition(action any) dsl.Definition {
conditionConfig, _ := json.Marshal(dsl.ConditionConfig{
Branches: []dsl.ConditionBranch{
{
ID: "direct",
Name: "Direct",
TargetNodeID: "end_1",
Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"},
Operator: "eq",
Right: action,
},
},
{
ID: "default",
Name: "Default",
TargetNodeID: "end_1",
Default: true,
},
},
})
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "understanding_1", Type: "conversation_understanding", Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "policy_1", Type: "reply_policy", Inputs: map[string]dsl.VariableSelector{
"messageIntent": {NodeID: "understanding_1", Field: "messageIntent"},
"answerScope": {NodeID: "understanding_1", Field: "answerScope"},
}},
{ID: "condition_1", Type: "condition", Config: conditionConfig},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "understanding_1"},
{ID: "e2", Source: "understanding_1", Target: "policy_1"},
{ID: "e3", Source: "policy_1", Target: "condition_1"},
{ID: "e4", Source: "condition_1", Target: "end_1"},
{ID: "e5", Source: "condition_1", Target: "end_1"},
},
}
}
func mappedReplyDefinition() dsl.Definition {
def := minimalDefinition()
def.Nodes[1].Inputs = map[string]dsl.VariableSelector{
"replyText": {NodeID: "start_1", Field: "userMessage"},
}
return def
}
func conditionDefinition() dsl.Definition {
conditionConfig, _ := json.Marshal(dsl.ConditionConfig{
conditionConfig := dsl.ConditionConfig{
Branches: []dsl.ConditionBranch{
{
ID: "hello",
Name: "Hello",
TargetNodeID: "end_1",
Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"},
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{"start_1", "userMessage"}},
Operator: "eq",
Right: "hello",
},
@@ -432,23 +230,54 @@ func conditionDefinition() dsl.Definition {
Default: true,
},
},
})
}
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "condition_1", Type: "condition", Config: conditionConfig},
{ID: "end_1", Type: "end"},
node("start_1", "start", nil, nil),
node("condition_1", "condition", nil, conditionConfig),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "condition_1"},
{ID: "e2", Source: "condition_1", Target: "end_1"},
{ID: "e3", Source: "condition_1", Target: "end_1"},
edge("start_1", "condition_1"),
edge("condition_1", "end_1"),
edge("condition_1", "end_1"),
},
}
}
func node(id string, nodeType string, inputValues map[string]dsl.Value, config any) dsl.Node {
return dsl.Node{
ID: id,
Type: nodeType,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 0, Y: 0}},
Data: dsl.NodeData{
Title: nodeType,
Config: mustJSON(config),
InputsValues: inputValues,
},
}
}
func edge(source string, target string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
}
func inputs(name string, value dsl.Value) map[string]dsl.Value {
return map[string]dsl.Value{name: value}
}
func mustJSON(value any) json.RawMessage {
if value == nil {
return nil
}
raw, err := json.Marshal(value)
if err != nil {
panic(err)
}
return raw
}
func hasValidationMessage(result validator.Result, want string) bool {
for _, item := range result.Errors {
if strings.Contains(item.Message, want) {
@@ -99,13 +99,12 @@ func TestBuildAIWorkflowRunIncludesAuditDisplayFields(t *testing.T) {
func TestBuildAIWorkflowRunDetailIncludesPublishedDefinitionSnapshot(t *testing.T) {
definition := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始"},
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "运行时回复"},
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Data: dsl.NodeData{Title: "开始"}},
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Data: dsl.NodeData{Title: "运行时回复"}},
},
Edges: []dsl.Edge{{ID: "edge_start_reply", Source: "start_1", Target: "reply_1"}},
Edges: []dsl.Edge{{SourceNodeID: "start_1", TargetNodeID: "reply_1", SourcePortID: "edge_start_reply"}},
}
buf, err := json.Marshal(definition)
if err != nil {
@@ -120,13 +119,13 @@ func TestBuildAIWorkflowRunDetailIncludesPublishedDefinitionSnapshot(t *testing.
&models.AIAgent{Name: "售后 Agent"},
)
if resp.Definition.EntryNodeID != "start_1" {
if resp.Definition.SchemaVersion != dsl.SchemaVersion {
t.Fatalf("expected run detail definition from published version, got %#v", resp.Definition)
}
if len(resp.Definition.Nodes) != 2 || resp.Definition.Nodes[1].Name != "运行时回复" {
if len(resp.Definition.Nodes) != 2 || resp.Definition.Nodes[1].Data.Title != "运行时回复" {
t.Fatalf("expected published definition nodes, got %#v", resp.Definition.Nodes)
}
if len(resp.Definition.Edges) != 1 || resp.Definition.Edges[0].ID != "edge_start_reply" {
if len(resp.Definition.Edges) != 1 || resp.Definition.Edges[0].SourcePortID != "edge_start_reply" {
t.Fatalf("expected published definition edges, got %#v", resp.Definition.Edges)
}
}
@@ -51,7 +51,7 @@ type AIWorkflowNodeSpecResponse struct {
ConfigSchema any `json:"configSchema,omitempty"`
InputSchema []workflowregistry.VariableSpec `json:"inputSchema,omitempty"`
OutputSchema []workflowregistry.VariableSpec `json:"outputSchema,omitempty"`
DefaultInputs map[string]dsl.VariableSelector `json:"defaultInputs,omitempty"`
DefaultInputs map[string]dsl.Value `json:"defaultInputs,omitempty"`
}
type AIWorkflowRunResponse struct {
@@ -49,7 +49,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
if err := json.Unmarshal([]byte(workflow.DraftDefinition), &stored); err != nil {
t.Fatalf("unmarshal draft definition: %v", err)
}
if stored.EntryNodeID == "" {
if stored.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(stored, "start_1") != workflowregistry.NodeTypeStart {
t.Fatalf("expected default draft definition")
}
validation := workflowvalidator.ValidateDefinition(stored, workflowregistry.DefaultRegistry())
@@ -93,7 +93,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionIsValid(t *testing.T) {
definition := AIWorkflowService.DefaultAgentWorkflowDefinition()
if definition.EntryNodeID == "" {
if definition.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(definition, "start_1") != workflowregistry.NodeTypeStart {
t.Fatalf("expected default workflow definition")
}
validation := workflowvalidator.ValidateDefinition(definition, workflowregistry.DefaultRegistry())
@@ -296,7 +296,7 @@ func conditionBranches(t *testing.T, def dsl.Definition, nodeID string) []dsl.Co
continue
}
var config dsl.ConditionConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
t.Fatalf("unmarshal condition config for %s: %v", nodeID, err)
}
return config.Branches
@@ -307,7 +307,7 @@ func conditionBranches(t *testing.T, def dsl.Definition, nodeID string) []dsl.Co
func workflowEdgeExists(def dsl.Definition, sourceID string, targetID string) bool {
for _, edge := range def.Edges {
if edge.Source == sourceID && edge.Target == targetID {
if edge.SourceNodeID == sourceID && edge.TargetNodeID == targetID {
return true
}
}
@@ -329,10 +329,10 @@ func assertWorkflowLayoutDoesNotOverlap(t *testing.T, def dsl.Definition) {
width, height := defaultWorkflowNodeRenderSize(node.Type)
boxes = append(boxes, workflowLayoutBox{
NodeID: node.ID,
Left: node.Position.X,
Top: node.Position.Y,
Right: node.Position.X + width,
Bottom: node.Position.Y + height,
Left: node.Meta.Position.X,
Top: node.Meta.Position.Y,
Right: node.Meta.Position.X + width,
Bottom: node.Meta.Position.Y + height,
})
}
const minGap = 32.0
+115 -103
View File
@@ -423,120 +423,132 @@ func (s *aiWorkflowService) createDefaultAgentWorkflow(db *gorm.DB, agent *model
func defaultAgentWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始", Position: dsl.Position{X: 0, Y: 520}},
{ID: "understanding_1", Type: workflowregistry.NodeTypeConversationUnderstanding, Name: "会话理解", Position: dsl.Position{X: 320, Y: 520}, Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "policy_1", Type: workflowregistry.NodeTypeReplyPolicy, Name: "回复策略", Position: dsl.Position{X: 640, Y: 520}, Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
"messageIntent": {NodeID: "understanding_1", Field: "messageIntent"},
"answerScope": {NodeID: "understanding_1", Field: "answerScope"},
"riskSignals": {NodeID: "understanding_1", Field: "riskSignals"},
}},
{ID: "policy_route_1", Type: workflowregistry.NodeTypeCondition, Name: "策略分流", Position: dsl.Position{X: 960, Y: 520}, Config: mustMarshalWorkflowConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "direct", Name: "直接回复", TargetNodeID: "policy_reply_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "direct_reply"}},
{ID: "clarify", Name: "追问澄清", TargetNodeID: "policy_reply_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "clarify"}},
{ID: "end_conversation", Name: "结束语", TargetNodeID: "policy_reply_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "end_conversation"}},
{ID: "handoff", Name: "转人工", TargetNodeID: "handoff_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "handoff_to_human"}},
{ID: "ticket", Name: "创建工单", TargetNodeID: "draft_ticket_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "prepare_ticket"}},
{ID: "knowledge", Name: "知识库回复", TargetNodeID: "retrieve_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"}, Operator: "eq", Right: "retrieve_knowledge"}},
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 0, 520, nil, nil),
workflowNode("understanding_1", workflowregistry.NodeTypeConversationUnderstanding, "会话理解", 320, 520, workflowInputs("userMessage", "start_1", "userMessage"), nil),
workflowNode("policy_1", workflowregistry.NodeTypeReplyPolicy, "回复策略", 640, 520, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"messageIntent": dsl.RefValue("understanding_1", "messageIntent"),
"answerScope": dsl.RefValue("understanding_1", "answerScope"),
"riskSignals": dsl.RefValue("understanding_1", "riskSignals"),
}, nil),
workflowNode("policy_route_1", workflowregistry.NodeTypeCondition, "策略分流", 960, 520, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("direct", "直接回复", "policy_reply_1", "policy_1", "action", "eq", "direct_reply"),
workflowConditionBranch("clarify", "追问澄清", "policy_reply_1", "policy_1", "action", "eq", "clarify"),
workflowConditionBranch("end_conversation", "结束语", "policy_reply_1", "policy_1", "action", "eq", "end_conversation"),
workflowConditionBranch("handoff", "转人工", "handoff_1", "policy_1", "action", "eq", "handoff_to_human"),
workflowConditionBranch("ticket", "创建工单", "draft_ticket_1", "policy_1", "action", "eq", "prepare_ticket"),
workflowConditionBranch("knowledge", "知识库回复", "retrieve_1", "policy_1", "action", "eq", "retrieve_knowledge"),
{ID: "default", Name: "默认澄清", TargetNodeID: "policy_reply_1", Default: true},
}})},
{ID: "policy_reply_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送策略回复", Position: dsl.Position{X: 1280, Y: 0}, Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "policy_1", Field: "replyText"},
}},
{ID: "handoff_1", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "转人工", Position: dsl.Position{X: 1280, Y: 220}, Inputs: map[string]dsl.VariableSelector{
"reason": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "handoff_end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 1600, Y: 220}},
{ID: "draft_ticket_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "整理工单草稿", Position: dsl.Position{X: 1280, Y: 440}, Inputs: map[string]dsl.VariableSelector{
"issue": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "ticket_confirm_prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "建单确认文案", Position: dsl.Position{X: 1600, Y: 440}, Config: json.RawMessage(`{"staticReply":"我已整理工单草稿。请回复“确认”创建工单,或回复“取消”放弃。"}`), Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "ticket_confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "确认建单", Position: dsl.Position{X: 1920, Y: 440}, Inputs: map[string]dsl.VariableSelector{
"prompt": {NodeID: "ticket_confirm_prompt_1", Field: "replyText"},
}},
{ID: "ticket_confirm_route_1", Type: workflowregistry.NodeTypeCondition, Name: "建单确认分流", Position: dsl.Position{X: 2240, Y: 440}, Config: mustMarshalWorkflowConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "confirmed", Name: "已确认", TargetNodeID: "create_ticket_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "ticket_confirm_1", Field: "confirmed"}, Operator: "is_true"}},
}}),
workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 1280, 0, workflowInputs("replyText", "policy_1", "replyText"), nil),
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 1280, 220, workflowInputs("reason", "start_1", "userMessage"), nil),
workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 1600, 220, nil, nil),
workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 1280, 440, workflowInputs("issue", "start_1", "userMessage"), nil),
workflowNode("ticket_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认文案", 1600, 440, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "我已整理工单草稿。请回复“确认”创建工单,或回复“取消”放弃。"}),
workflowNode("ticket_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 1920, 440, workflowInputs("prompt", "ticket_confirm_prompt_1", "replyText"), nil),
workflowNode("ticket_confirm_route_1", workflowregistry.NodeTypeCondition, "建单确认分流", 2240, 440, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "create_ticket_1", "ticket_confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消或未确认", TargetNodeID: "ticket_cancel_reply_1", Default: true},
}})},
{ID: "create_ticket_1", Type: workflowregistry.NodeTypeCreateTicket, Name: "创建工单", Position: dsl.Position{X: 2560, Y: 320}, Inputs: map[string]dsl.VariableSelector{
"ticketDraft": {NodeID: "draft_ticket_1", Field: "ticketDraft"},
"confirmed": {NodeID: "ticket_confirm_1", Field: "confirmed"},
}},
{ID: "ticket_result_reply_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送建单结果", Position: dsl.Position{X: 2880, Y: 320}, Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "create_ticket_1", Field: "message"},
}},
{ID: "ticket_cancel_reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "取消建单提示", Position: dsl.Position{X: 2560, Y: 560}, Config: json.RawMessage(`{"staticReply":"已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}`), Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "send_ticket_cancel_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送取消提示", Position: dsl.Position{X: 2880, Y: 560}, Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "ticket_cancel_reply_1", Field: "replyText"},
}},
{ID: "retrieve_1", Type: workflowregistry.NodeTypeKnowledgeRetrieve, Name: "知识检索", Position: dsl.Position{X: 1280, Y: 860}, Inputs: map[string]dsl.VariableSelector{
"query": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "answerability_1", Type: workflowregistry.NodeTypeAnswerabilityGate, Name: "可回答判断", Position: dsl.Position{X: 1600, Y: 860}, Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
"knowledgeItems": {NodeID: "retrieve_1", Field: "items"},
}},
{ID: "answerability_route_1", Type: workflowregistry.NodeTypeCondition, Name: "可回答分流", Position: dsl.Position{X: 1920, Y: 860}, Config: mustMarshalWorkflowConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
{ID: "answerable", Name: "可以回答", TargetNodeID: "reply_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "answerability_1", Field: "answerability"}, Operator: "eq", Right: "answerable"}},
}}),
workflowNode("create_ticket_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 2560, 320, map[string]dsl.Value{
"ticketDraft": dsl.RefValue("draft_ticket_1", "ticketDraft"),
"confirmed": dsl.RefValue("ticket_confirm_1", "confirmed"),
}, nil),
workflowNode("ticket_result_reply_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 2880, 320, workflowInputs("replyText", "create_ticket_1", "message"), nil),
workflowNode("ticket_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消建单提示", 2560, 560, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}),
workflowNode("send_ticket_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2880, 560, workflowInputs("replyText", "ticket_cancel_reply_1", "replyText"), nil),
workflowNode("retrieve_1", workflowregistry.NodeTypeKnowledgeRetrieve, "知识检索", 1280, 860, workflowInputs("query", "start_1", "userMessage"), nil),
workflowNode("answerability_1", workflowregistry.NodeTypeAnswerabilityGate, "可回答判断", 1600, 860, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
}, nil),
workflowNode("answerability_route_1", workflowregistry.NodeTypeCondition, "可回答分流", 1920, 860, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("answerable", "可以回答", "reply_1", "answerability_1", "answerability", "eq", "answerable"),
{ID: "default", Name: "兜底追问", TargetNodeID: "fallback_reply_1", Default: true},
}})},
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "AI 回复", Position: dsl.Position{X: 2240, Y: 780}, Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
"knowledgeItems": {NodeID: "retrieve_1", Field: "items"},
}},
{ID: "send_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送回复", Position: dsl.Position{X: 2560, Y: 780}, Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "reply_1", Field: "replyText"},
}},
{ID: "fallback_reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "兜底追问", Position: dsl.Position{X: 2240, Y: 1040}, Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
"knowledgeItems": {NodeID: "retrieve_1", Field: "items"},
}},
{ID: "send_fallback_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送兜底", Position: dsl.Position{X: 2560, Y: 1040}, Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "fallback_reply_1", Field: "replyText"},
}},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 3200, Y: 780}},
}}),
workflowNode("reply_1", workflowregistry.NodeTypeLLMReply, "AI 回复", 2240, 780, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
}, nil),
workflowNode("send_1", workflowregistry.NodeTypeSendReply, "发送回复", 2560, 780, workflowInputs("replyText", "reply_1", "replyText"), nil),
workflowNode("fallback_reply_1", workflowregistry.NodeTypeLLMReply, "兜底追问", 2240, 1040, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
}, nil),
workflowNode("send_fallback_1", workflowregistry.NodeTypeSendReply, "发送兜底", 2560, 1040, workflowInputs("replyText", "fallback_reply_1", "replyText"), nil),
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 3200, 780, nil, nil),
},
Edges: []dsl.Edge{
{ID: "edge_start_understanding", Source: "start_1", Target: "understanding_1"},
{ID: "edge_understanding_policy", Source: "understanding_1", Target: "policy_1"},
{ID: "edge_policy_route", Source: "policy_1", Target: "policy_route_1"},
{ID: "edge_policy_reply", Source: "policy_route_1", Target: "policy_reply_1"},
{ID: "edge_policy_handoff", Source: "policy_route_1", Target: "handoff_1"},
{ID: "edge_policy_ticket", Source: "policy_route_1", Target: "draft_ticket_1"},
{ID: "edge_policy_knowledge", Source: "policy_route_1", Target: "retrieve_1"},
{ID: "edge_policy_reply_end", Source: "policy_reply_1", Target: "end_1"},
{ID: "edge_handoff_end", Source: "handoff_1", Target: "handoff_end_1"},
{ID: "edge_draft_ticket_confirm_prompt", Source: "draft_ticket_1", Target: "ticket_confirm_prompt_1"},
{ID: "edge_ticket_prompt_confirm", Source: "ticket_confirm_prompt_1", Target: "ticket_confirm_1"},
{ID: "edge_ticket_confirm_route", Source: "ticket_confirm_1", Target: "ticket_confirm_route_1"},
{ID: "edge_ticket_confirm_create", Source: "ticket_confirm_route_1", Target: "create_ticket_1"},
{ID: "edge_ticket_confirm_cancel", Source: "ticket_confirm_route_1", Target: "ticket_cancel_reply_1"},
{ID: "edge_create_ticket_result", Source: "create_ticket_1", Target: "ticket_result_reply_1"},
{ID: "edge_ticket_result_end", Source: "ticket_result_reply_1", Target: "end_1"},
{ID: "edge_ticket_cancel_send", Source: "ticket_cancel_reply_1", Target: "send_ticket_cancel_1"},
{ID: "edge_ticket_cancel_end", Source: "send_ticket_cancel_1", Target: "end_1"},
{ID: "edge_retrieve_answerability", Source: "retrieve_1", Target: "answerability_1"},
{ID: "edge_answerability_route", Source: "answerability_1", Target: "answerability_route_1"},
{ID: "edge_answerability_reply", Source: "answerability_route_1", Target: "reply_1"},
{ID: "edge_answerability_fallback", Source: "answerability_route_1", Target: "fallback_reply_1"},
{ID: "edge_reply_send", Source: "reply_1", Target: "send_1"},
{ID: "edge_fallback_send", Source: "fallback_reply_1", Target: "send_fallback_1"},
{ID: "edge_send_end", Source: "send_1", Target: "end_1"},
{ID: "edge_send_fallback_end", Source: "send_fallback_1", Target: "end_1"},
workflowEdge("start_1", "understanding_1"),
workflowEdge("understanding_1", "policy_1"),
workflowEdge("policy_1", "policy_route_1"),
workflowEdge("policy_route_1", "policy_reply_1"),
workflowEdge("policy_route_1", "handoff_1"),
workflowEdge("policy_route_1", "draft_ticket_1"),
workflowEdge("policy_route_1", "retrieve_1"),
workflowEdge("policy_reply_1", "end_1"),
workflowEdge("handoff_1", "handoff_end_1"),
workflowEdge("draft_ticket_1", "ticket_confirm_prompt_1"),
workflowEdge("ticket_confirm_prompt_1", "ticket_confirm_1"),
workflowEdge("ticket_confirm_1", "ticket_confirm_route_1"),
workflowEdge("ticket_confirm_route_1", "create_ticket_1"),
workflowEdge("ticket_confirm_route_1", "ticket_cancel_reply_1"),
workflowEdge("create_ticket_1", "ticket_result_reply_1"),
workflowEdge("ticket_result_reply_1", "end_1"),
workflowEdge("ticket_cancel_reply_1", "send_ticket_cancel_1"),
workflowEdge("send_ticket_cancel_1", "end_1"),
workflowEdge("retrieve_1", "answerability_1"),
workflowEdge("answerability_1", "answerability_route_1"),
workflowEdge("answerability_route_1", "reply_1"),
workflowEdge("answerability_route_1", "fallback_reply_1"),
workflowEdge("reply_1", "send_1"),
workflowEdge("fallback_reply_1", "send_fallback_1"),
workflowEdge("send_1", "end_1"),
workflowEdge("send_fallback_1", "end_1"),
},
}
}
func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node {
return dsl.Node{
ID: id,
Type: nodeType,
Meta: dsl.NodeMeta{Position: dsl.Position{X: x, Y: y}},
Data: dsl.NodeData{
Title: title,
Config: mustMarshalWorkflowConfig(config),
InputsValues: inputs,
},
}
}
func workflowInputs(name string, nodeID string, field string) map[string]dsl.Value {
return map[string]dsl.Value{name: dsl.RefValue(nodeID, field)}
}
func workflowConditionBranch(id string, name string, targetNodeID string, nodeID string, field string, operator string, right any) dsl.ConditionBranch {
return dsl.ConditionBranch{
ID: id,
Name: name,
TargetNodeID: targetNodeID,
Condition: &dsl.Condition{
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{nodeID, field}},
Operator: operator,
Right: right,
},
}
}
func workflowEdge(source string, target string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
}
func mustMarshalWorkflowConfig(value any) json.RawMessage {
if value == nil {
return nil
}
raw, err := json.Marshal(value)
if err != nil {
panic(err)
+50 -25
View File
@@ -20,16 +20,15 @@ import (
func TestAIWorkflowServiceValidateDefinitionReportsErrors(t *testing.T) {
setupAIWorkflowTestDB(t)
result := AIWorkflowService.ValidateDefinition(dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "create_1", Type: "create_ticket"},
{ID: "end_1", Type: "end"},
workflowServiceTestNode("start_1", "start", nil, nil),
workflowServiceTestNode("create_1", "create_ticket", nil, nil),
workflowServiceTestNode("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "create_1"},
{ID: "e2", Source: "create_1", Target: "end_1"},
workflowServiceTestEdge("start_1", "create_1"),
workflowServiceTestEdge("create_1", "end_1"),
},
})
@@ -79,7 +78,7 @@ func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) {
if err := json.Unmarshal([]byte(version.Definition), &stored); err != nil {
t.Fatalf("unmarshal stored definition: %v", err)
}
if stored.EntryNodeID != "start_1" {
if stored.SchemaVersion != dsl.SchemaVersion || len(stored.Nodes) == 0 {
t.Fatalf("unexpected stored definition: %+v", stored)
}
}
@@ -131,16 +130,15 @@ func TestAIWorkflowServicePublishRejectsInvalidDSL(t *testing.T) {
_, err = AIWorkflowService.PublishWorkflow(request.PublishAIWorkflowRequest{
WorkflowID: workflow.ID,
Definition: dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "create_1", Type: "create_ticket"},
{ID: "end_1", Type: "end"},
workflowServiceTestNode("start_1", "start", nil, nil),
workflowServiceTestNode("create_1", "create_ticket", nil, nil),
workflowServiceTestNode("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "create_1"},
{ID: "e2", Source: "create_1", Target: "end_1"},
workflowServiceTestEdge("start_1", "create_1"),
workflowServiceTestEdge("create_1", "end_1"),
},
},
}, operator)
@@ -164,7 +162,7 @@ func TestAIWorkflowServiceRunListAndDetail(t *testing.T) {
t.Fatalf("create workflow: %v", err)
}
versionDefinition := validAIWorkflowDefinition()
versionDefinition.Nodes[1].Name = "运行时回复"
versionDefinition.Nodes[1].Data.Title = "运行时回复"
versionDefinitionJSON, err := json.Marshal(versionDefinition)
if err != nil {
t.Fatalf("marshal version definition: %v", err)
@@ -278,22 +276,49 @@ func setupAIWorkflowTestDB(t *testing.T) {
func validAIWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`), Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "end_1", Type: "end"},
workflowServiceTestNode("start_1", "start", nil, nil),
workflowServiceTestNode("reply_1", "send_reply", map[string]dsl.Value{
"replyText": dsl.RefValue("start_1", "userMessage"),
}, map[string]any{"text": "hello"}),
workflowServiceTestNode("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "reply_1"},
{ID: "e2", Source: "reply_1", Target: "end_1"},
workflowServiceTestEdge("start_1", "reply_1"),
workflowServiceTestEdge("reply_1", "end_1"),
},
}
}
func workflowServiceTestNode(id string, nodeType string, inputs map[string]dsl.Value, config any) dsl.Node {
return dsl.Node{
ID: id,
Type: nodeType,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 0, Y: 0}},
Data: dsl.NodeData{
Title: nodeType,
InputsValues: inputs,
Config: mustMarshalWorkflowServiceTestConfig(config),
},
}
}
func workflowServiceTestEdge(source string, target string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
}
func mustMarshalWorkflowServiceTestConfig(value any) json.RawMessage {
if value == nil {
return nil
}
raw, err := json.Marshal(value)
if err != nil {
panic(err)
}
return raw
}
func aiWorkflowTestOperator() *dto.AuthPrincipal {
return &dto.AuthPrincipal{
UserID: 1,