feat: Enhance AI Workflow with Variable Contracts and Input Mapping

- Added ConfigSchema, InputSchema, OutputSchema, and DefaultInputs to AIWorkflowNodeSpecResponse.
- Implemented BuildAIWorkflowNodeSpecs to include variable contracts for start and send_reply nodes.
- Introduced applyAutoInputMappings to automatically map inputs based on node connections.
- Enhanced validation to check for required input mappings in workflows.
- Updated workflow editor to support variable selection for node inputs.
- Translated node names and labels to Chinese for better localization.
- Added tests for variable mapping and validation logic.
This commit is contained in:
mlogclub
2026-06-22 18:33:39 +08:00
parent 8a2c076691
commit d1f39ae57e
19 changed files with 1332 additions and 73 deletions
+15 -6
View File
@@ -10,11 +10,12 @@ type Definition struct {
}
type Node struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Position Position `json:"position"`
Config json.RawMessage `json:"config"`
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"`
}
type Position struct {
@@ -30,5 +31,13 @@ type Edge struct {
}
type Condition struct {
Expression string `json:"expression"`
Expression string `json:"expression,omitempty"`
Left *VariableSelector `json:"left,omitempty"`
Operator string `json:"operator,omitempty"`
Right any `json:"right,omitempty"`
}
type VariableSelector struct {
NodeID string `json:"nodeId"`
Field string `json:"field"`
}
+170 -12
View File
@@ -1,5 +1,7 @@
package registry
import "agent-desk/internal/ai/workflow/dsl"
const (
NodeTypeStart = "start"
NodeTypeKnowledgeRetrieve = "knowledge_retrieve"
@@ -17,17 +19,173 @@ const (
func DefaultRegistry() *Registry {
return NewRegistry(
NodeSpec{Type: NodeTypeStart, Title: "Start", Description: "Conversation workflow entry.", RiskLevel: NodeRiskLevelLow},
NodeSpec{Type: NodeTypeKnowledgeRetrieve, Title: "Knowledge Retrieve", Description: "Retrieve knowledge for the current user message.", RiskLevel: NodeRiskLevelLow},
NodeSpec{Type: NodeTypeAnswerabilityGate, Title: "Answerability Gate", Description: "Decide whether retrieved knowledge is enough to answer.", RiskLevel: NodeRiskLevelLow},
NodeSpec{Type: NodeTypeLLMReply, Title: "LLM Reply", Description: "Generate a reply or structured analysis with the configured model.", RiskLevel: NodeRiskLevelMedium},
NodeSpec{Type: NodeTypeCondition, Title: "Condition", Description: "Route by controlled workflow variables.", RiskLevel: NodeRiskLevelLow},
NodeSpec{Type: NodeTypeAnalyzeConversation, Title: "Analyze Conversation", Description: "Analyze intent, risk, and recommended next action.", RiskLevel: NodeRiskLevelLow},
NodeSpec{Type: NodeTypePrepareTicketDraft, Title: "Prepare Ticket Draft", Description: "Build a ticket draft from conversation context.", RiskLevel: NodeRiskLevelMedium},
NodeSpec{Type: NodeTypeHumanConfirm, Title: "Human Confirm", Description: "Interrupt and wait for explicit user confirmation.", RiskLevel: NodeRiskLevelMedium, Interruptible: true},
NodeSpec{Type: NodeTypeCreateTicket, Title: "Create Ticket", Description: "Create a ticket from a confirmed draft.", RiskLevel: NodeRiskLevelHigh, RequiresConfirmationPredecessor: true},
NodeSpec{Type: NodeTypeHandoffToHuman, Title: "Handoff To Human", Description: "Transfer the conversation to human support.", RiskLevel: NodeRiskLevelHigh, RequiresConfirmationPredecessor: true},
NodeSpec{Type: NodeTypeSendReply, Title: "Send Reply", Description: "Return or commit customer-visible reply text.", RiskLevel: NodeRiskLevelLow},
NodeSpec{Type: NodeTypeEnd, Title: "End", Description: "End workflow execution.", RiskLevel: NodeRiskLevelLow},
NodeSpec{
Type: NodeTypeStart,
Title: "Start",
Description: "Conversation workflow entry.",
RiskLevel: NodeRiskLevelLow,
OutputSchema: []VariableSpec{
output("conversationId", VariableTypeInteger, "Conversation ID."),
output("messageId", VariableTypeInteger, "Current user message ID."),
output("aiAgentId", VariableTypeInteger, "AI Agent ID."),
output("userMessage", VariableTypeString, "Current user message content."),
output("knowledgeBaseIds", VariableTypeIntegerArray, "Knowledge bases bound to the AI Agent."),
},
},
NodeSpec{
Type: NodeTypeKnowledgeRetrieve,
Title: "Knowledge Retrieve",
Description: "Retrieve knowledge for the current user message.",
RiskLevel: NodeRiskLevelLow,
InputSchema: []VariableSpec{
requiredInput("query", VariableTypeString, "Search query."),
},
OutputSchema: []VariableSpec{
output("items", VariableTypeObjectArray, "Retrieved knowledge items."),
output("summary", VariableTypeString, "Short retrieval summary."),
},
DefaultInputs: map[string]dsl.VariableSelector{
"query": {NodeID: "start_1", Field: "userMessage"},
},
},
NodeSpec{
Type: NodeTypeAnswerabilityGate,
Title: "Answerability Gate",
Description: "Decide whether retrieved knowledge is enough to answer.",
RiskLevel: NodeRiskLevelLow,
InputSchema: []VariableSpec{
requiredInput("userMessage", VariableTypeString, "Current user message content."),
requiredInput("knowledgeItems", VariableTypeObjectArray, "Retrieved knowledge items."),
},
OutputSchema: []VariableSpec{
output("answerability", VariableTypeString, "Answerability decision."),
output("reason", VariableTypeString, "Decision reason."),
},
},
NodeSpec{
Type: NodeTypeLLMReply,
Title: "LLM Reply",
Description: "Generate a reply or structured analysis with the configured model.",
RiskLevel: NodeRiskLevelMedium,
InputSchema: []VariableSpec{
requiredInput("userMessage", VariableTypeString, "Current user message content."),
optionalInput("knowledgeItems", VariableTypeObjectArray, "Retrieved knowledge items."),
},
OutputSchema: []VariableSpec{
output("replyText", VariableTypeString, "Generated reply text."),
},
},
NodeSpec{
Type: NodeTypeCondition,
Title: "Condition",
Description: "Route by controlled workflow variables.",
RiskLevel: NodeRiskLevelLow,
OutputSchema: []VariableSpec{
output("matched", VariableTypeBoolean, "Whether the condition matched."),
},
},
NodeSpec{
Type: NodeTypeAnalyzeConversation,
Title: "Analyze Conversation",
Description: "Analyze intent, risk, and recommended next action.",
RiskLevel: NodeRiskLevelLow,
InputSchema: []VariableSpec{
requiredInput("userMessage", VariableTypeString, "Current user message content."),
},
OutputSchema: []VariableSpec{
output("intent", VariableTypeString, "Detected user intent."),
output("riskLevel", VariableTypeString, "Detected risk level."),
output("needTicket", VariableTypeBoolean, "Whether a ticket is recommended."),
output("needHumanHandoff", VariableTypeBoolean, "Whether human handoff is recommended."),
},
},
NodeSpec{
Type: NodeTypePrepareTicketDraft,
Title: "Prepare Ticket Draft",
Description: "Build a ticket draft from conversation context.",
RiskLevel: NodeRiskLevelMedium,
InputSchema: []VariableSpec{
requiredInput("issue", VariableTypeString, "Issue summary."),
},
OutputSchema: []VariableSpec{
output("ticketDraft", VariableTypeObject, "Draft ticket payload."),
},
},
NodeSpec{
Type: NodeTypeHumanConfirm,
Title: "Human Confirm",
Description: "Interrupt and wait for explicit user confirmation.",
RiskLevel: NodeRiskLevelMedium,
Interruptible: true,
InputSchema: []VariableSpec{
requiredInput("prompt", VariableTypeString, "Confirmation prompt."),
},
OutputSchema: []VariableSpec{
output("confirmed", VariableTypeBoolean, "Whether the user confirmed."),
output("responseText", VariableTypeString, "Confirmation response text."),
},
},
NodeSpec{
Type: NodeTypeCreateTicket,
Title: "Create Ticket",
Description: "Create a ticket from a confirmed draft.",
RiskLevel: NodeRiskLevelHigh,
RequiresConfirmationPredecessor: true,
InputSchema: []VariableSpec{
requiredInput("ticketDraft", VariableTypeObject, "Confirmed draft ticket payload."),
requiredInput("confirmed", VariableTypeBoolean, "Confirmation result."),
},
OutputSchema: []VariableSpec{
output("ticketId", VariableTypeInteger, "Created ticket ID."),
},
},
NodeSpec{
Type: NodeTypeHandoffToHuman,
Title: "Handoff To Human",
Description: "Transfer the conversation to human support.",
RiskLevel: NodeRiskLevelHigh,
RequiresConfirmationPredecessor: true,
InputSchema: []VariableSpec{
requiredInput("reason", VariableTypeString, "Handoff reason."),
requiredInput("confirmed", VariableTypeBoolean, "Confirmation result."),
},
OutputSchema: []VariableSpec{
output("handoffId", VariableTypeInteger, "Handoff operation ID."),
},
},
NodeSpec{
Type: NodeTypeSendReply,
Title: "Send Reply",
Description: "Return or commit customer-visible reply text.",
RiskLevel: NodeRiskLevelLow,
InputSchema: []VariableSpec{
requiredInput("replyText", VariableTypeString, "Customer-visible reply text."),
},
OutputSchema: []VariableSpec{
output("sent", VariableTypeBoolean, "Whether the reply was sent."),
output("replyMessageId", VariableTypeInteger, "Reply message ID."),
},
},
NodeSpec{
Type: NodeTypeEnd,
Title: "End",
Description: "End workflow execution.",
RiskLevel: NodeRiskLevelLow,
OutputSchema: []VariableSpec{
output("status", VariableTypeString, "Workflow terminal status."),
},
},
)
}
func requiredInput(name string, variableType VariableType, description string) VariableSpec {
return VariableSpec{Name: name, Type: variableType, Required: true, Description: description}
}
func optionalInput(name string, variableType VariableType, description string) VariableSpec {
return VariableSpec{Name: name, Type: variableType, Description: description}
}
func output(name string, variableType VariableType, description string) VariableSpec {
return VariableSpec{Name: name, Type: variableType, Description: description}
}
@@ -0,0 +1,60 @@
package registry
import "testing"
func TestDefaultRegistryExposesStartOutputs(t *testing.T) {
spec, ok := DefaultRegistry().Get(NodeTypeStart)
if !ok {
t.Fatalf("start node spec not found")
}
if !hasVariable(spec.OutputSchema, "userMessage", VariableTypeString) {
t.Fatalf("expected start output userMessage:string, got %#v", spec.OutputSchema)
}
if !hasVariable(spec.OutputSchema, "knowledgeBaseIds", VariableTypeIntegerArray) {
t.Fatalf("expected start output knowledgeBaseIds:array<int>, got %#v", spec.OutputSchema)
}
}
func TestDefaultRegistryExposesKnowledgeRetrieveInputsAndOutputs(t *testing.T) {
spec, ok := DefaultRegistry().Get(NodeTypeKnowledgeRetrieve)
if !ok {
t.Fatalf("knowledge_retrieve node spec not found")
}
if !hasRequiredVariable(spec.InputSchema, "query", VariableTypeString) {
t.Fatalf("expected knowledge_retrieve required input query:string, got %#v", spec.InputSchema)
}
if !hasVariable(spec.OutputSchema, "items", VariableTypeObjectArray) {
t.Fatalf("expected knowledge_retrieve output items:array<object>, got %#v", spec.OutputSchema)
}
}
func TestDefaultRegistryExposesSendReplyRequiredInput(t *testing.T) {
spec, ok := DefaultRegistry().Get(NodeTypeSendReply)
if !ok {
t.Fatalf("send_reply node spec not found")
}
if !hasRequiredVariable(spec.InputSchema, "replyText", VariableTypeString) {
t.Fatalf("expected send_reply required input replyText:string, got %#v", spec.InputSchema)
}
if !hasVariable(spec.OutputSchema, "sent", VariableTypeBoolean) {
t.Fatalf("expected send_reply output sent:boolean, got %#v", spec.OutputSchema)
}
}
func hasRequiredVariable(items []VariableSpec, name string, variableType VariableType) bool {
for _, item := range items {
if item.Name == name && item.Type == variableType && item.Required {
return true
}
}
return false
}
func hasVariable(items []VariableSpec, name string, variableType VariableType) bool {
for _, item := range items {
if item.Name == name && item.Type == variableType {
return true
}
}
return false
}
+32 -6
View File
@@ -1,5 +1,7 @@
package registry
import "agent-desk/internal/ai/workflow/dsl"
type NodeRiskLevel string
const (
@@ -8,13 +10,37 @@ const (
NodeRiskLevelHigh NodeRiskLevel = "high"
)
type VariableType string
const (
VariableTypeString VariableType = "string"
VariableTypeInteger VariableType = "integer"
VariableTypeBoolean VariableType = "boolean"
VariableTypeObject VariableType = "object"
VariableTypeStringArray VariableType = "array<string>"
VariableTypeIntegerArray VariableType = "array<int>"
VariableTypeObjectArray VariableType = "array<object>"
VariableTypeAny VariableType = "any"
)
type VariableSpec struct {
Name string `json:"name"`
Type VariableType `json:"type"`
Required bool `json:"required,omitempty"`
Description string `json:"description"`
}
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"`
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 Registry struct {
+111
View File
@@ -55,6 +55,7 @@ func (v *definitionValidator) validate() {
v.validateEntry()
v.validateReachability()
v.validateConfirmationGuards()
v.validateVariableMappings()
}
func (v *definitionValidator) validateNodes() {
@@ -182,6 +183,116 @@ func (v *definitionValidator) validateConfirmationGuards() {
}
}
func (v *definitionValidator) validateVariableMappings() {
for id, node := range v.nodesByID {
spec, ok := v.registry.Get(node.Type)
if !ok {
continue
}
for _, input := range spec.InputSchema {
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)
continue
}
v.validateInputSelector(id, input, selector)
}
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
}
if _, ok := findInputSpec(spec.InputSchema, inputName); ok {
continue
}
sourceNode, sourceOK := v.nodesByID[strings.TrimSpace(selector.NodeID)]
if !sourceOK {
v.addError("nodes."+id+".inputs."+inputName, "input source node does not exist: "+selector.NodeID)
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)
}
}
}
}
func (v *definitionValidator) validateInputSelector(nodeID string, input registry.VariableSpec, selector dsl.VariableSelector) {
sourceNodeID := strings.TrimSpace(selector.NodeID)
sourceField := strings.TrimSpace(selector.Field)
sourceNode, ok := v.nodesByID[sourceNodeID]
if !ok {
v.addError("nodes."+nodeID+".inputs."+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)
return
}
sourceSpec, ok := v.registry.Get(sourceNode.Type)
if !ok {
return
}
output, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField)
if !ok {
v.addError("nodes."+nodeID+".inputs."+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))
}
}
func (v *definitionValidator) hasPath(sourceID string, targetID string, visiting map[string]struct{}) bool {
if sourceID == targetID {
return false
}
if _, seen := visiting[sourceID]; seen {
return false
}
visiting[sourceID] = struct{}{}
for _, next := range v.outgoing[sourceID] {
if next == targetID {
return true
}
if v.hasPath(next, targetID, visiting) {
return true
}
}
return false
}
func findInputSpec(items []registry.VariableSpec, name string) (registry.VariableSpec, bool) {
name = strings.TrimSpace(name)
for _, item := range items {
if item.Name == name {
return item, true
}
}
return registry.VariableSpec{}, false
}
func findOutputSpec(items []registry.VariableSpec, name string) (registry.VariableSpec, bool) {
name = strings.TrimSpace(name)
for _, item := range items {
if item.Name == name {
return item, true
}
}
return registry.VariableSpec{}, false
}
func variableTypesCompatible(input registry.VariableType, output registry.VariableType) bool {
return input == registry.VariableTypeAny || output == registry.VariableTypeAny || input == output
}
func (v *definitionValidator) hasConfirmationPredecessor(nodeID string, visiting map[string]struct{}) bool {
if _, seen := visiting[nodeID]; seen {
return false
@@ -83,9 +83,16 @@ func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "draft_1", Type: "prepare_ticket_draft"},
{ID: "confirm_1", Type: "human_confirm"},
{ID: "create_1", Type: "create_ticket"},
{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{
@@ -103,13 +110,99 @@ func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
}
}
func TestValidateDefinitionRejectsMissingRequiredInputMapping(t *testing.T) {
def := minimalDefinition()
def.Nodes[1].Inputs = nil
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected missing required input mapping to be invalid")
}
if !hasValidationMessage(result, "required input mapping is missing") {
t.Fatalf("expected required-input error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownInputSourceNode(t *testing.T) {
def := mappedReplyDefinition()
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "missing_1", Field: "replyText"}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unknown input source node to be invalid")
}
if !hasValidationMessage(result, "input source node does not exist") {
t.Fatalf("expected source-node error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownInputSourceField(t *testing.T) {
def := mappedReplyDefinition()
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "missing"}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unknown input source field to be invalid")
}
if !hasValidationMessage(result, "input source field does not exist") {
t.Fatalf("expected source-field error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsIncompatibleInputType(t *testing.T) {
def := mappedReplyDefinition()
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "conversationId"}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected incompatible input type to be invalid")
}
if !hasValidationMessage(result, "input type mismatch") {
t.Fatalf("expected type-mismatch error, got %#v", result.Errors)
}
}
func TestValidateDefinitionAcceptsMappedKnowledgeFlow(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
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"},
},
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"},
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected mapped knowledge flow to be valid, got %#v", result.Errors)
}
}
func minimalDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`)},
{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"},
},
Edges: []dsl.Edge{
@@ -119,6 +212,14 @@ func minimalDefinition() dsl.Definition {
}
}
func mappedReplyDefinition() dsl.Definition {
def := minimalDefinition()
def.Nodes[1].Inputs = map[string]dsl.VariableSelector{
"replyText": {NodeID: "start_1", Field: "userMessage"},
}
return def
}
func hasValidationMessage(result validator.Result, want string) bool {
for _, item := range result.Errors {
if strings.Contains(item.Message, want) {
+4
View File
@@ -78,6 +78,10 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor
RiskLevel: item.RiskLevel,
Interruptible: item.Interruptible,
RequiresConfirmationPredecessor: item.RequiresConfirmationPredecessor,
ConfigSchema: item.ConfigSchema,
InputSchema: item.InputSchema,
OutputSchema: item.OutputSchema,
DefaultInputs: item.DefaultInputs,
})
}
return ret
@@ -0,0 +1,40 @@
package builders
import (
"testing"
workflowregistry "agent-desk/internal/ai/workflow/registry"
)
func TestBuildAIWorkflowNodeSpecsIncludesVariableContracts(t *testing.T) {
specs := BuildAIWorkflowNodeSpecs(workflowregistry.DefaultRegistry().List())
var startFound bool
var sendReplyFound bool
for _, spec := range specs {
switch spec.Type {
case workflowregistry.NodeTypeStart:
startFound = true
if !hasResponseVariable(spec.OutputSchema, "userMessage") {
t.Fatalf("expected start output userMessage, got %#v", spec.OutputSchema)
}
case workflowregistry.NodeTypeSendReply:
sendReplyFound = true
if !hasResponseVariable(spec.InputSchema, "replyText") {
t.Fatalf("expected send_reply input replyText, got %#v", spec.InputSchema)
}
}
}
if !startFound || !sendReplyFound {
t.Fatalf("expected start and send_reply specs in response")
}
}
func hasResponseVariable(items []workflowregistry.VariableSpec, name string) bool {
for _, item := range items {
if item.Name == name {
return true
}
}
return false
}
@@ -42,10 +42,14 @@ type AIWorkflowValidationResponse struct {
}
type AIWorkflowNodeSpecResponse struct {
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
RiskLevel workflowregistry.NodeRiskLevel `json:"riskLevel"`
Interruptible bool `json:"interruptible"`
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
RiskLevel workflowregistry.NodeRiskLevel `json:"riskLevel"`
Interruptible bool `json:"interruptible"`
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
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"`
}
@@ -50,6 +50,12 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
if stored.EntryNodeID == "" {
t.Fatalf("expected default draft definition")
}
if len(stored.Nodes) < 5 {
t.Fatalf("expected product-ready default workflow, got nodes: %#v", stored.Nodes)
}
if !workflowHasNodeType(stored, "knowledge_retrieve") || !workflowHasNodeType(stored, "llm_reply") || !workflowHasNodeType(stored, "send_reply") {
t.Fatalf("expected default workflow to include retrieve, llm reply, and send reply nodes: %#v", stored.Nodes)
}
}
func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) {
@@ -169,3 +175,12 @@ func aiAgentWorkflowTestOperator() *dto.AuthPrincipal {
Nickname: "agent-workflow-tester",
}
}
func workflowHasNodeType(def dsl.Definition, nodeType string) bool {
for _, node := range def.Nodes {
if node.Type == nodeType {
return true
}
}
return false
}
+18 -3
View File
@@ -337,10 +337,25 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start", Position: dsl.Position{X: 0, Y: 80}},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End", Position: dsl.Position{X: 360, Y: 80}},
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始", Position: dsl.Position{X: 0, Y: 120}},
{ID: "retrieve_1", Type: workflowregistry.NodeTypeKnowledgeRetrieve, Name: "知识检索", Position: dsl.Position{X: 260, Y: 120}, Inputs: map[string]dsl.VariableSelector{
"query": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "AI 回复", Position: dsl.Position{X: 520, Y: 120}, 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: 780, Y: 120}, Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "reply_1", Field: "replyText"},
}},
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 1040, Y: 120}},
},
Edges: []dsl.Edge{
{ID: "edge_start_retrieve", Source: "start_1", Target: "retrieve_1"},
{ID: "edge_retrieve_reply", Source: "retrieve_1", Target: "reply_1"},
{ID: "edge_reply_send", Source: "reply_1", Target: "send_1"},
{ID: "edge_send_end", Source: "send_1", Target: "end_1"},
},
Edges: []dsl.Edge{{ID: "edge_start_end", Source: "start_1", Target: "end_1"}},
}
}
@@ -174,7 +174,9 @@ func validAIWorkflowDefinition() dsl.Definition {
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`)},
{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"},
},
Edges: []dsl.Edge{