feat: implement business node specifications and enhance workflow editor with node specs support
This commit is contained in:
@@ -395,9 +395,13 @@ func (e *Executor) executeCreateTicket(state *runState, node dsl.Node) error {
|
||||
draft := asMap(state.resolveInput(node, "ticketDraft"))
|
||||
title := strings.TrimSpace(toString(draft["title"]))
|
||||
description := strings.TrimSpace(toString(draft["description"]))
|
||||
tagIDs := toInt64Slice(state.resolveInput(node, "tagIds"))
|
||||
assigneeID := toInt64(state.resolveInput(node, "assigneeId"))
|
||||
result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{
|
||||
Conversation: state.input.Conversation, AIAgent: state.input.AIAgent,
|
||||
ToolCode: toolx.GraphCreateTicketConfirm.Code, Arguments: map[string]any{"title": title, "description": description},
|
||||
ToolCode: toolx.GraphCreateTicketConfirm.Code, Arguments: map[string]any{
|
||||
"title": title, "description": description, "tagIds": tagIDs, "assigneeId": assigneeID,
|
||||
},
|
||||
IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1336,6 +1340,24 @@ func toFloat(value any) float64 {
|
||||
}
|
||||
}
|
||||
|
||||
func toInt64(value any) int64 {
|
||||
return int64(toFloat(value))
|
||||
}
|
||||
|
||||
func toInt64Slice(value any) []int64 {
|
||||
rv := reflect.ValueOf(value)
|
||||
if !rv.IsValid() || (rv.Kind() != reflect.Array && rv.Kind() != reflect.Slice) {
|
||||
return nil
|
||||
}
|
||||
ret := make([]int64, 0, rv.Len())
|
||||
for index := 0; index < rv.Len(); index++ {
|
||||
if id := toInt64(rv.Index(index).Interface()); id > 0 {
|
||||
ret = append(ret, id)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func asMap(value any) map[string]any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
|
||||
@@ -251,6 +251,8 @@ func DefaultRegistry() *Registry {
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("ticketDraft", "工单草稿", VariableTypeObject, "已经由客户确认的工单草稿内容。"),
|
||||
requiredInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认创建工单。"),
|
||||
optionalInput("tagIds", "工单标签", VariableTypeIntegerArray, "创建工单时附加的标签 ID 列表。"),
|
||||
optionalInput("assigneeId", "处理人", VariableTypeInteger, "创建工单后默认指派的客服用户 ID。"),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("ticketId", "工单 ID", VariableTypeInteger, "创建成功后的工单内部编号。"),
|
||||
|
||||
@@ -2,6 +2,22 @@ package registry
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultRegistryMarksOnlyRuntimeSupportedNodesExecutable(t *testing.T) {
|
||||
registry := DefaultRegistry()
|
||||
for _, nodeType := range []string{NodeTypeCreateTicket, NodeTypeHumanConfirm, NodeTypeSendReply, NodeTypeLLM} {
|
||||
spec, ok := registry.Get(nodeType)
|
||||
if !ok || !spec.Executable {
|
||||
t.Fatalf("expected %s to be executable, got %#v", nodeType, spec)
|
||||
}
|
||||
}
|
||||
for _, nodeType := range []string{NodeTypeHTTP, NodeTypeCode, NodeTypeLoop} {
|
||||
spec, ok := registry.Get(nodeType)
|
||||
if !ok || spec.Executable {
|
||||
t.Fatalf("expected %s to be unavailable in server runtime, got %#v", nodeType, spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRegistryVariablesHaveBusinessLabels(t *testing.T) {
|
||||
for _, spec := range DefaultRegistry().List() {
|
||||
for _, variable := range append(spec.InputSchema, spec.OutputSchema...) {
|
||||
|
||||
@@ -45,6 +45,8 @@ type NodeSpec struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Category string `json:"category"`
|
||||
Executable bool `json:"executable"`
|
||||
RiskLevel NodeRiskLevel `json:"riskLevel"`
|
||||
Interruptible bool `json:"interruptible"`
|
||||
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
||||
@@ -68,12 +70,54 @@ func NewRegistry(specs ...NodeSpec) *Registry {
|
||||
if spec.Type == "" {
|
||||
continue
|
||||
}
|
||||
spec.Executable = IsExecutableNodeType(spec.Type)
|
||||
if spec.Category == "" {
|
||||
spec.Category = NodeCategory(spec.Type)
|
||||
}
|
||||
ret.specsByType[spec.Type] = spec
|
||||
ret.specs = append(ret.specs, spec)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func IsExecutableNodeType(nodeType string) bool {
|
||||
switch nodeType {
|
||||
case NodeTypeStart,
|
||||
NodeTypeConversationUnderstanding,
|
||||
NodeTypeReplyPolicy,
|
||||
NodeTypeKnowledgeRetrieve,
|
||||
NodeTypeAnswerabilityGate,
|
||||
NodeTypeCondition,
|
||||
NodeTypeAnalyzeConversation,
|
||||
NodeTypePrepareTicketDraft,
|
||||
NodeTypeHumanConfirm,
|
||||
NodeTypeCreateTicket,
|
||||
NodeTypeLLMReply,
|
||||
NodeTypeLLM,
|
||||
NodeTypeSendReply,
|
||||
NodeTypeHandoffToHuman,
|
||||
NodeTypeEnd:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NodeCategory(nodeType string) string {
|
||||
switch nodeType {
|
||||
case NodeTypeStart, NodeTypeEnd:
|
||||
return "trigger"
|
||||
case NodeTypeCondition, NodeTypeMultiCondition, NodeTypeLoop, NodeTypeBlockStart, NodeTypeBlockEnd, NodeTypeContinue, NodeTypeBreak:
|
||||
return "control"
|
||||
case NodeTypeConversationUnderstanding, NodeTypeReplyPolicy, NodeTypeAnswerabilityGate, NodeTypeAnalyzeConversation, NodeTypeLLMReply, NodeTypeLLM, NodeTypeKnowledgeRetrieve:
|
||||
return "ai"
|
||||
case NodeTypePrepareTicketDraft, NodeTypeHumanConfirm, NodeTypeCreateTicket, NodeTypeHandoffToHuman, NodeTypeSendReply:
|
||||
return "business"
|
||||
default:
|
||||
return "utility"
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Get(nodeType string) (NodeSpec, bool) {
|
||||
if r == nil {
|
||||
return NodeSpec{}, false
|
||||
|
||||
@@ -82,6 +82,10 @@ func (v *definitionValidator) validateNodes() {
|
||||
v.addError(field+".type", "unknown node type: "+node.Type)
|
||||
continue
|
||||
}
|
||||
if !registry.IsExecutableNodeType(node.Type) {
|
||||
v.addError(field+".type", "node type is not supported by the server runtime: "+node.Type)
|
||||
continue
|
||||
}
|
||||
switch node.Type {
|
||||
case registry.NodeTypeStart:
|
||||
v.startNodeIDs = append(v.startNodeIDs, node.ID)
|
||||
|
||||
@@ -18,6 +18,23 @@ func TestValidateDefinitionAcceptsMinimalFlowGramStyleFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsNodeMissingFromServerRuntime(t *testing.T) {
|
||||
def := dsl.Definition{
|
||||
Nodes: []dsl.Node{
|
||||
node("start_1", "start", nil, nil),
|
||||
node("http_1", "http", nil, nil),
|
||||
node("end_1", "end", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{edge("start_1", "http_1"), edge("http_1", "end_1")},
|
||||
}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid || !hasValidationMessage(result, "not supported by the server runtime") {
|
||||
t.Fatalf("expected unsupported-runtime error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionAcceptsOfficialFlowGramCondition(t *testing.T) {
|
||||
def := dsl.Definition{
|
||||
Nodes: []dsl.Node{
|
||||
|
||||
@@ -77,6 +77,8 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor
|
||||
Title: item.Title,
|
||||
Description: item.Description,
|
||||
Icon: item.Icon,
|
||||
Category: item.Category,
|
||||
Executable: item.Executable,
|
||||
RiskLevel: item.RiskLevel,
|
||||
Interruptible: item.Interruptible,
|
||||
RequiresConfirmationPredecessor: item.RequiresConfirmationPredecessor,
|
||||
|
||||
@@ -60,6 +60,8 @@ type AIWorkflowNodeSpecResponse struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Category string `json:"category"`
|
||||
Executable bool `json:"executable"`
|
||||
RiskLevel workflowregistry.NodeRiskLevel `json:"riskLevel"`
|
||||
Interruptible bool `json:"interruptible"`
|
||||
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
||||
|
||||
@@ -82,9 +82,11 @@ func (e *businessToolExecutor) execute(toolCode string, input BusinessToolInput)
|
||||
switch toolCode {
|
||||
case toolx.GraphCreateTicketConfirm.Code:
|
||||
item, err := TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{
|
||||
ConversationID: input.Conversation.ID,
|
||||
Title: businessToolString(input.Arguments["title"]),
|
||||
Description: businessToolString(input.Arguments["description"]),
|
||||
ConversationID: input.Conversation.ID,
|
||||
Title: businessToolString(input.Arguments["title"]),
|
||||
Description: businessToolString(input.Arguments["description"]),
|
||||
TagIDs: businessToolInt64Slice(input.Arguments["tagIds"]),
|
||||
CurrentAssigneeID: businessToolInt64(input.Arguments["assigneeId"]),
|
||||
}, businessToolPrincipal(input.AIAgent))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -106,6 +108,36 @@ func businessToolString(value any) string {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func businessToolInt64(value any) int64 {
|
||||
switch typed := value.(type) {
|
||||
case int64:
|
||||
return typed
|
||||
case int:
|
||||
return int64(typed)
|
||||
case float64:
|
||||
return int64(typed)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func businessToolInt64Slice(value any) []int64 {
|
||||
switch typed := value.(type) {
|
||||
case []int64:
|
||||
return typed
|
||||
case []any:
|
||||
ret := make([]int64, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if id := businessToolInt64(item); id > 0 {
|
||||
ret = append(ret, id)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func businessToolPrincipal(agent models.AIAgent) *dto.AuthPrincipal {
|
||||
name := strings.TrimSpace(agent.Name)
|
||||
if name == "" {
|
||||
|
||||
Reference in New Issue
Block a user