diff --git a/internal/ai/runtime/workflow/executor.go b/internal/ai/runtime/workflow/executor.go index 2f7158e..3e2cdc6 100644 --- a/internal/ai/runtime/workflow/executor.go +++ b/internal/ai/runtime/workflow/executor.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "fmt" + "html" "reflect" + "regexp" "strconv" "strings" "time" @@ -24,6 +26,8 @@ import ( const maxWorkflowSteps = 128 +var workflowHTMLTagPattern = regexp.MustCompile(`<[^>]+>`) + type Input struct { Definition dsl.Definition Conversation models.Conversation @@ -259,6 +263,10 @@ func (e *Executor) executeNode(ctx context.Context, state *runState, node dsl.No "knowledgeBaseIds": utils.SplitInt64s(state.input.AIAgent.KnowledgeIDs), "conversationState": state.input.Conversation.Status, }) + case workflowregistry.NodeTypeConversationUnderstanding: + return e.executeConversationUnderstanding(state, node) + case workflowregistry.NodeTypeReplyPolicy: + return e.executeReplyPolicy(state, node) case workflowregistry.NodeTypeKnowledgeRetrieve: return e.executeKnowledgeRetrieve(ctx, state, node) case workflowregistry.NodeTypeAnswerabilityGate: @@ -292,6 +300,44 @@ func (e *Executor) executeNode(ctx context.Context, state *runState, node dsl.No return nil } +func (e *Executor) executeConversationUnderstanding(state *runState, node dsl.Node) error { + rawMessage := strings.TrimSpace(toString(state.resolveInput(node, "userMessage"))) + if rawMessage == "" { + rawMessage = state.input.UserMessage.Content + } + understanding := understandConversationMessage(rawMessage) + state.setNodeVars(node.ID, map[string]any{ + "normalizedMessage": understanding.NormalizedMessage, + "messageIntent": understanding.MessageIntent, + "answerScope": understanding.AnswerScope, + "confidence": understanding.Confidence, + "riskSignals": understanding.RiskSignals, + "reason": understanding.Reason, + }) + return nil +} + +func (e *Executor) executeReplyPolicy(state *runState, node dsl.Node) error { + intent := strings.TrimSpace(toString(state.resolveInput(node, "messageIntent"))) + scope := strings.TrimSpace(toString(state.resolveInput(node, "answerScope"))) + userMessage := normalizeWorkflowUserMessage(toString(state.resolveInput(node, "userMessage"))) + decision := decideWorkflowReplyPolicy(state.input.AIAgent, workflowReplyPolicyInput{ + MessageIntent: intent, + AnswerScope: scope, + UserMessage: userMessage, + Answerability: strings.TrimSpace(toString(state.resolveInput(node, "answerability"))), + }) + state.setNodeVars(node.ID, map[string]any{ + "action": decision.Action, + "replyText": decision.ReplyText, + "reason": decision.Reason, + "requiresFlow": decision.RequiresFlow, + "targetFlow": decision.TargetFlow, + "finalReplySource": decision.FinalReplySource, + }) + return nil +} + func (e *Executor) executeCreateTicket(state *runState, node dsl.Node) error { confirmed := truthy(state.resolveInput(node, "confirmed")) if !confirmed { @@ -344,6 +390,164 @@ func workflowAIPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal { } } +type workflowConversationUnderstanding struct { + NormalizedMessage string + MessageIntent string + AnswerScope string + Confidence float64 + RiskSignals []string + Reason string +} + +type workflowReplyPolicyInput struct { + MessageIntent string + AnswerScope string + UserMessage string + Answerability string +} + +type workflowReplyPolicyDecision struct { + Action string + ReplyText string + Reason string + RequiresFlow bool + TargetFlow string + FinalReplySource string +} + +func understandConversationMessage(rawMessage string) workflowConversationUnderstanding { + message := normalizeWorkflowUserMessage(rawMessage) + ret := workflowConversationUnderstanding{ + NormalizedMessage: message, + MessageIntent: "unknown", + AnswerScope: "needs_clarification", + Confidence: 0.5, + Reason: "message intent is unclear", + } + if message == "" { + ret.MessageIntent = "unknown" + ret.AnswerScope = "needs_clarification" + ret.Confidence = 0.9 + ret.Reason = "empty message" + return ret + } + lower := strings.ToLower(message) + switch { + case isGreetingMessage(lower): + ret.MessageIntent = "greeting" + ret.AnswerScope = "direct_reply" + ret.Confidence = 0.98 + ret.Reason = "matched greeting phrase" + case containsAnyWorkflowText(lower, "谢谢", "感谢", "多谢", "辛苦了", "thank"): + ret.MessageIntent = "thanks" + ret.AnswerScope = "direct_reply" + ret.Confidence = 0.95 + ret.Reason = "matched thanks phrase" + case containsAnyWorkflowText(lower, "再见", "拜拜", "不用了", "没事了", "结束"): + ret.MessageIntent = "end_conversation" + ret.AnswerScope = "direct_reply" + ret.Confidence = 0.9 + ret.Reason = "matched ending phrase" + case containsAnyWorkflowText(lower, "人工", "转人工", "真人", "客服"): + ret.MessageIntent = "handoff_request" + ret.AnswerScope = "needs_handoff" + ret.Confidence = 0.95 + ret.RiskSignals = append(ret.RiskSignals, "handoff_requested") + ret.Reason = "matched handoff phrase" + case containsAnyWorkflowText(lower, "投诉", "举报", "差评", "曝光", "起诉", "律师", "12315"): + ret.MessageIntent = "complaint" + ret.AnswerScope = "needs_handoff" + ret.Confidence = 0.92 + ret.RiskSignals = append(ret.RiskSignals, "complaint_escalation") + ret.Reason = "matched complaint phrase" + case containsAnyWorkflowText(lower, "工单", "报障", "售后", "登记问题", "记录问题"): + ret.MessageIntent = "ticket_request" + ret.AnswerScope = "needs_ticket" + ret.Confidence = 0.9 + ret.RiskSignals = append(ret.RiskSignals, "ticket_expected") + ret.Reason = "matched ticket phrase" + case containsAnyWorkflowText(lower, "确认", "可以", "好的", "好", "是的", "取消"): + ret.MessageIntent = "confirmation" + ret.AnswerScope = "direct_reply" + ret.Confidence = 0.8 + ret.Reason = "matched confirmation phrase" + case isAmbiguousWorkflowQuestion(lower): + ret.MessageIntent = "ambiguous_question" + ret.AnswerScope = "needs_clarification" + ret.Confidence = 0.82 + ret.Reason = "message lacks a concrete business object" + default: + ret.MessageIntent = "business_question" + ret.AnswerScope = "needs_knowledge" + ret.Confidence = 0.7 + ret.Reason = "default business question policy" + } + return ret +} + +func decideWorkflowReplyPolicy(aiAgent models.AIAgent, input workflowReplyPolicyInput) workflowReplyPolicyDecision { + intent := strings.TrimSpace(input.MessageIntent) + scope := strings.TrimSpace(input.AnswerScope) + if answerability := strings.TrimSpace(input.Answerability); answerability != "" && answerability != "answerable" { + return workflowReplyPolicyDecision{ + Action: "knowledge_fallback", + ReplyText: workflowKnowledgeFallbackReply(aiAgent), + Reason: "knowledge is not sufficient for business answer", + FinalReplySource: "knowledge_fallback", + } + } + switch { + case intent == "greeting": + return workflowReplyPolicyDecision{Action: "direct_reply", ReplyText: "您好,请问有什么可以帮您?", Reason: "greeting can be answered directly", FinalReplySource: "direct_reply"} + case intent == "thanks": + return workflowReplyPolicyDecision{Action: "direct_reply", ReplyText: "不客气,如有其他问题可以继续告诉我。", Reason: "thanks can be answered directly", FinalReplySource: "direct_reply"} + case intent == "end_conversation": + return workflowReplyPolicyDecision{Action: "end_conversation", ReplyText: "好的,如后续还有问题可以随时联系。", Reason: "conversation ending phrase", FinalReplySource: "direct_reply"} + case intent == "confirmation": + return workflowReplyPolicyDecision{Action: "direct_reply", ReplyText: "好的,请继续补充需要处理的问题。", Reason: "confirmation without pending interrupt", FinalReplySource: "direct_reply"} + case intent == "handoff_request" || scope == "needs_handoff": + return workflowReplyPolicyDecision{Action: "handoff_to_human", Reason: "user requested human support or risk requires handoff", RequiresFlow: true, TargetFlow: "handoff_to_human", FinalReplySource: "handoff_notice"} + case intent == "ticket_request" || scope == "needs_ticket": + return workflowReplyPolicyDecision{Action: "prepare_ticket", Reason: "user requested ticket handling", RequiresFlow: true, TargetFlow: "prepare_ticket", FinalReplySource: "ticket_result"} + case intent == "ambiguous_question" || scope == "needs_clarification": + return workflowReplyPolicyDecision{Action: "clarify", ReplyText: "请补充具体的产品、场景、报错信息或你希望处理的结果,我再继续帮你确认。", Reason: "message needs clarification", FinalReplySource: "clarification"} + case scope == "needs_knowledge": + return workflowReplyPolicyDecision{Action: "retrieve_knowledge", Reason: "business question should be answered with knowledge evidence", RequiresFlow: true, TargetFlow: "knowledge", FinalReplySource: "knowledge_answer"} + default: + return workflowReplyPolicyDecision{Action: "clarify", ReplyText: "请补充更具体的问题,我再继续帮你处理。", Reason: "fallback to clarification for unclear policy input", FinalReplySource: "clarification"} + } +} + +func normalizeWorkflowUserMessage(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = workflowHTMLTagPattern.ReplaceAllString(value, " ") + value = html.UnescapeString(value) + value = strings.Join(strings.Fields(value), " ") + return strings.TrimSpace(value) +} + +func isGreetingMessage(value string) bool { + trimmed := strings.Trim(value, " \r\n。.!!??~~") + return containsAnyWorkflowText(trimmed, "你好", "您好", "在吗", "在不在") || trimmed == "hello" || trimmed == "hi" +} + +func isAmbiguousWorkflowQuestion(value string) bool { + trimmed := strings.Trim(value, " \r\n。.!!??~~") + return containsAnyWorkflowText(trimmed, "怎么弄", "怎么办", "怎么处理", "帮我看看", "有问题") || len([]rune(trimmed)) <= 3 +} + +func containsAnyWorkflowText(value string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} + func (e *Executor) executeHumanConfirm(state *runState, node dsl.Node) error { prompt := strings.TrimSpace(toString(state.resolveInput(node, "prompt"))) if prompt == "" { diff --git a/internal/ai/runtime/workflow/executor_test.go b/internal/ai/runtime/workflow/executor_test.go index 2b96e3d..b5729e2 100644 --- a/internal/ai/runtime/workflow/executor_test.go +++ b/internal/ai/runtime/workflow/executor_test.go @@ -239,6 +239,55 @@ func TestExecutorPrepareTicketDraftOutputsDraftVariable(t *testing.T) { assertPath(t, result.NodePath, []string{"start_1", "draft_1", "draft_route_1", "ready_end"}) } +func TestExecutorPolicyFirstWorkflowRoutesGreetingToDirectReply(t *testing.T) { + result, err := NewExecutor().Execute(context.Background(), Input{ + Definition: policyFirstWorkflowDefinition(), + UserMessage: models.Message{ + Content: "

你好。

", + }, + AIAgent: models.AIAgent{ + KnowledgeIDs: "1", + FallbackMessage: "我暂时没有找到足够准确的信息。", + }, + }) + if err != nil { + t.Fatalf("execute workflow: %v", err) + } + if result.ReplyText != "您好,请问有什么可以帮您?" { + t.Fatalf("expected greeting reply, got %q", result.ReplyText) + } + if result.RetrieverCount != 0 { + t.Fatalf("expected greeting to skip retrieval, got retriever count %d", result.RetrieverCount) + } + assertPath(t, result.NodePath, []string{"start_1", "understanding_1", "policy_1", "policy_route_1", "send_direct_1", "end_1"}) + + understandingTrace := findNodeTrace(result.NodeTraces, "understanding_1") + if understandingTrace == nil || !strings.Contains(understandingTrace.OutputPreview, `"messageIntent":"greeting"`) || !strings.Contains(understandingTrace.OutputPreview, `"answerScope":"direct_reply"`) { + t.Fatalf("expected understanding trace to audit greeting/direct_reply, got %#v", understandingTrace) + } + policyTrace := findNodeTrace(result.NodeTraces, "policy_1") + if policyTrace == nil || !strings.Contains(policyTrace.OutputPreview, `"action":"direct_reply"`) || !strings.Contains(policyTrace.OutputPreview, `"finalReplySource":"direct_reply"`) { + t.Fatalf("expected policy trace to audit direct reply, got %#v", policyTrace) + } +} + +func TestExecutorPolicyFirstWorkflowRoutesBusinessQuestionToKnowledge(t *testing.T) { + result, err := NewExecutor().Execute(context.Background(), Input{ + Definition: policyFirstWorkflowDefinition(), + UserMessage: models.Message{ + Content: "你们价格是多少?", + }, + AIAgent: models.AIAgent{ + KnowledgeIDs: "1", + FallbackMessage: "我暂时没有找到足够准确的信息。", + }, + }) + if err != nil { + t.Fatalf("execute workflow: %v", err) + } + assertPath(t, result.NodePath, []string{"start_1", "understanding_1", "policy_1", "policy_route_1", "retrieve_end"}) +} + func TestExecutorLLMReplyUsesAgentFallbackWhenDeclaredKnowledgeIsEmpty(t *testing.T) { result, err := NewExecutor().Execute(context.Background(), Input{ Definition: emptyKnowledgeReplyDefinition(), @@ -408,6 +457,53 @@ func emptyKnowledgeReplyDefinition() dsl.Definition { } } +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", + }}, + {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"}, + }, + 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"}, + }, + } +} + func conditionalReplyDefinition() dsl.Definition { return dsl.Definition{ SchemaVersion: 1, diff --git a/internal/ai/workflow/registry/registry.go b/internal/ai/workflow/registry/registry.go index 7b74518..49b6f1c 100644 --- a/internal/ai/workflow/registry/registry.go +++ b/internal/ai/workflow/registry/registry.go @@ -3,18 +3,20 @@ package registry import "agent-desk/internal/ai/workflow/dsl" const ( - NodeTypeStart = "start" - NodeTypeKnowledgeRetrieve = "knowledge_retrieve" - NodeTypeAnswerabilityGate = "answerability_gate" - NodeTypeLLMReply = "llm_reply" - NodeTypeCondition = "condition" - NodeTypeAnalyzeConversation = "analyze_conversation" - NodeTypePrepareTicketDraft = "prepare_ticket_draft" - NodeTypeHumanConfirm = "human_confirm" - NodeTypeCreateTicket = "create_ticket" - NodeTypeHandoffToHuman = "handoff_to_human" - NodeTypeSendReply = "send_reply" - NodeTypeEnd = "end" + NodeTypeStart = "start" + NodeTypeConversationUnderstanding = "conversation_understanding" + NodeTypeReplyPolicy = "reply_policy" + NodeTypeKnowledgeRetrieve = "knowledge_retrieve" + NodeTypeAnswerabilityGate = "answerability_gate" + NodeTypeLLMReply = "llm_reply" + NodeTypeCondition = "condition" + NodeTypeAnalyzeConversation = "analyze_conversation" + NodeTypePrepareTicketDraft = "prepare_ticket_draft" + NodeTypeHumanConfirm = "human_confirm" + NodeTypeCreateTicket = "create_ticket" + NodeTypeHandoffToHuman = "handoff_to_human" + NodeTypeSendReply = "send_reply" + NodeTypeEnd = "end" ) func DefaultRegistry() *Registry { @@ -32,6 +34,47 @@ func DefaultRegistry() *Registry { output("knowledgeBaseIds", VariableTypeIntegerArray, "Knowledge bases bound to the AI Agent."), }, }, + NodeSpec{ + Type: NodeTypeConversationUnderstanding, + Title: "Conversation Understanding", + Description: "Classify customer message intent and answer scope before retrieval.", + RiskLevel: NodeRiskLevelLow, + InputSchema: []VariableSpec{ + requiredInput("userMessage", VariableTypeString, "Current user message content."), + }, + OutputSchema: []VariableSpec{ + output("normalizedMessage", VariableTypeString, "Normalized customer message."), + output("messageIntent", VariableTypeString, "Detected customer message intent."), + output("answerScope", VariableTypeString, "Recommended answer scope."), + output("confidence", VariableTypeNumber, "Classifier confidence."), + output("riskSignals", VariableTypeStringArray, "Detected risk signals."), + output("reason", VariableTypeString, "Decision reason."), + }, + DefaultInputs: map[string]dsl.VariableSelector{ + "userMessage": {NodeID: "start_1", Field: "userMessage"}, + }, + }, + NodeSpec{ + Type: NodeTypeReplyPolicy, + Title: "Reply Policy", + Description: "Decide the next customer-service action from understanding output and agent policy.", + RiskLevel: NodeRiskLevelLow, + InputSchema: []VariableSpec{ + requiredInput("messageIntent", VariableTypeString, "Detected customer message intent."), + requiredInput("answerScope", VariableTypeString, "Recommended answer scope."), + optionalInput("userMessage", VariableTypeString, "Current user message content."), + optionalInput("riskSignals", VariableTypeStringArray, "Detected risk signals."), + optionalInput("answerability", VariableTypeString, "Knowledge answerability decision."), + }, + OutputSchema: []VariableSpec{ + output("action", VariableTypeString, "Selected policy action."), + output("replyText", VariableTypeString, "Customer-visible reply text when the policy can answer directly."), + output("reason", VariableTypeString, "Policy decision reason."), + output("requiresFlow", VariableTypeBoolean, "Whether the decision should continue into workflow actions."), + output("targetFlow", VariableTypeString, "Suggested target flow."), + output("finalReplySource", VariableTypeString, "Source category for the final reply."), + }, + }, NodeSpec{ Type: NodeTypeKnowledgeRetrieve, Title: "Knowledge Retrieve", diff --git a/internal/ai/workflow/registry/registry_test.go b/internal/ai/workflow/registry/registry_test.go index 6c26275..980ba1a 100644 --- a/internal/ai/workflow/registry/registry_test.go +++ b/internal/ai/workflow/registry/registry_test.go @@ -41,6 +41,42 @@ func TestDefaultRegistryExposesSendReplyRequiredInput(t *testing.T) { } } +func TestDefaultRegistryExposesConversationUnderstandingOutputs(t *testing.T) { + spec, ok := DefaultRegistry().Get(NodeTypeConversationUnderstanding) + if !ok { + t.Fatalf("conversation_understanding node spec not found") + } + if !hasRequiredVariable(spec.InputSchema, "userMessage", VariableTypeString) { + t.Fatalf("expected conversation_understanding required input userMessage:string, got %#v", spec.InputSchema) + } + for _, want := range []string{"messageIntent", "answerScope", "riskSignals", "reason"} { + if !hasVariableName(spec.OutputSchema, want) { + t.Fatalf("expected conversation_understanding output %s, got %#v", want, spec.OutputSchema) + } + } + if !hasVariable(spec.OutputSchema, "confidence", VariableTypeNumber) { + t.Fatalf("expected conversation_understanding output confidence:number, got %#v", spec.OutputSchema) + } +} + +func TestDefaultRegistryExposesReplyPolicyOutputs(t *testing.T) { + spec, ok := DefaultRegistry().Get(NodeTypeReplyPolicy) + if !ok { + t.Fatalf("reply_policy node spec not found") + } + if !hasRequiredVariable(spec.InputSchema, "messageIntent", VariableTypeString) { + t.Fatalf("expected reply_policy required input messageIntent:string, got %#v", spec.InputSchema) + } + if !hasRequiredVariable(spec.InputSchema, "answerScope", VariableTypeString) { + t.Fatalf("expected reply_policy required input answerScope:string, got %#v", spec.InputSchema) + } + for _, want := range []string{"action", "replyText", "reason", "finalReplySource"} { + if !hasVariableName(spec.OutputSchema, want) { + t.Fatalf("expected reply_policy output %s, got %#v", want, spec.OutputSchema) + } + } +} + func hasRequiredVariable(items []VariableSpec, name string, variableType VariableType) bool { for _, item := range items { if item.Name == name && item.Type == variableType && item.Required { @@ -50,6 +86,15 @@ func hasRequiredVariable(items []VariableSpec, name string, variableType Variabl return false } +func hasVariableName(items []VariableSpec, name string) bool { + for _, item := range items { + if item.Name == name { + return true + } + } + return false +} + func hasVariable(items []VariableSpec, name string, variableType VariableType) bool { for _, item := range items { if item.Name == name && item.Type == variableType { diff --git a/internal/ai/workflow/registry/spec.go b/internal/ai/workflow/registry/spec.go index d492a3f..240e4a1 100644 --- a/internal/ai/workflow/registry/spec.go +++ b/internal/ai/workflow/registry/spec.go @@ -14,6 +14,7 @@ type VariableType string const ( VariableTypeString VariableType = "string" + VariableTypeNumber VariableType = "number" VariableTypeInteger VariableType = "integer" VariableTypeBoolean VariableType = "boolean" VariableTypeObject VariableType = "object" diff --git a/internal/services/ai_agent_workflow_service_test.go b/internal/services/ai_agent_workflow_service_test.go index 8d692e7..9afd074 100644 --- a/internal/services/ai_agent_workflow_service_test.go +++ b/internal/services/ai_agent_workflow_service_test.go @@ -56,10 +56,18 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { if !validation.Valid { t.Fatalf("expected default workflow to be valid, got %#v", validation.Errors) } - if nodeTypeByID(stored, "route_intent_1") != workflowregistry.NodeTypeCondition { - t.Fatalf("expected default workflow to start with a clear intent router, got nodes: %#v", stored.Nodes) + if nodeTypeByID(stored, "understanding_1") != workflowregistry.NodeTypeConversationUnderstanding { + t.Fatalf("expected default workflow to include conversation understanding, got nodes: %#v", stored.Nodes) + } + if nodeTypeByID(stored, "policy_1") != workflowregistry.NodeTypeReplyPolicy { + t.Fatalf("expected default workflow to include reply policy, got nodes: %#v", stored.Nodes) + } + if !workflowEdgeExists(stored, "start_1", "understanding_1") || !workflowEdgeExists(stored, "understanding_1", "policy_1") { + t.Fatalf("expected default workflow to start with policy-first understanding flow, got edges: %#v", stored.Edges) } for _, nodeType := range []string{ + workflowregistry.NodeTypeConversationUnderstanding, + workflowregistry.NodeTypeReplyPolicy, workflowregistry.NodeTypeHandoffToHuman, workflowregistry.NodeTypePrepareTicketDraft, workflowregistry.NodeTypeHumanConfirm, @@ -73,8 +81,9 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { t.Fatalf("expected default workflow to include %s node: %#v", nodeType, stored.Nodes) } } - assertConditionBranchToNodeType(t, stored, "route_intent_1", workflowregistry.NodeTypeHandoffToHuman, "contains", "人工") - assertConditionBranchToNodeType(t, stored, "route_intent_1", workflowregistry.NodeTypePrepareTicketDraft, "contains", "工单") + assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeSendReply, "eq", "direct_reply") + assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeHandoffToHuman, "eq", "handoff_to_human") + assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypePrepareTicketDraft, "eq", "prepare_ticket") assertConditionBranchToNodeID(t, stored, "answerability_route_1", "reply_1", "eq", "answerable") assertDefaultBranchToNodeID(t, stored, "answerability_route_1", "fallback_reply_1") if !workflowEdgeExists(stored, "create_ticket_1", "ticket_result_reply_1") { @@ -91,8 +100,11 @@ func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionIsValid(t *testing.T) { if !validation.Valid { t.Fatalf("expected default workflow definition to be valid, got %#v", validation.Errors) } - if nodeTypeByID(definition, "route_intent_1") != workflowregistry.NodeTypeCondition { - t.Fatalf("expected default workflow to include intent router, got nodes: %#v", definition.Nodes) + if nodeTypeByID(definition, "understanding_1") != workflowregistry.NodeTypeConversationUnderstanding { + t.Fatalf("expected default workflow to include conversation understanding, got nodes: %#v", definition.Nodes) + } + if nodeTypeByID(definition, "policy_1") != workflowregistry.NodeTypeReplyPolicy { + t.Fatalf("expected default workflow to include reply policy, got nodes: %#v", definition.Nodes) } if !workflowHasNodeType(definition, workflowregistry.NodeTypeHandoffToHuman) { t.Fatalf("expected default workflow to include human handoff node") diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index fee174f..f25a778 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -426,76 +426,94 @@ func defaultAgentWorkflowDefinition() dsl.Definition { SchemaVersion: 1, EntryNodeID: "start_1", Nodes: []dsl.Node{ - {ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始", Position: dsl.Position{X: 0, Y: 260}}, - {ID: "route_intent_1", Type: workflowregistry.NodeTypeCondition, Name: "意图分流", Position: dsl.Position{X: 260, Y: 260}, Config: mustMarshalWorkflowConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ - {ID: "handoff", Name: "需要转人工", TargetNodeID: "handoff_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"}, Operator: "contains", Right: "人工"}}, - {ID: "ticket", Name: "需要建单", TargetNodeID: "draft_ticket_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"}, Operator: "contains", Right: "工单"}}, - {ID: "complaint", Name: "投诉建单", TargetNodeID: "draft_ticket_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"}, Operator: "contains", Right: "投诉"}}, - {ID: "incident", Name: "报障建单", TargetNodeID: "draft_ticket_1", Condition: &dsl.Condition{Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"}, Operator: "contains", Right: "报障"}}, - {ID: "default", Name: "默认知识库回复", TargetNodeID: "retrieve_1", Default: true}, - }})}, - {ID: "handoff_1", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "转人工", Position: dsl.Position{X: 560, Y: 80}, Inputs: map[string]dsl.VariableSelector{ - "reason": {NodeID: "start_1", Field: "userMessage"}, - }}, - {ID: "handoff_end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 860, Y: 80}}, - {ID: "draft_ticket_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "整理工单草稿", Position: dsl.Position{X: 560, Y: 240}, Inputs: map[string]dsl.VariableSelector{ - "issue": {NodeID: "start_1", Field: "userMessage"}, - }}, - {ID: "ticket_confirm_prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "建单确认文案", Position: dsl.Position{X: 860, Y: 240}, Config: json.RawMessage(`{"staticReply":"我已整理工单草稿。请回复“确认”创建工单,或回复“取消”放弃。"}`), Inputs: map[string]dsl.VariableSelector{ + {ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始", Position: dsl.Position{X: 0, Y: 320}}, + {ID: "understanding_1", Type: workflowregistry.NodeTypeConversationUnderstanding, Name: "会话理解", Position: dsl.Position{X: 260, Y: 320}, Inputs: map[string]dsl.VariableSelector{ "userMessage": {NodeID: "start_1", Field: "userMessage"}, }}, - {ID: "ticket_confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "确认建单", Position: dsl.Position{X: 1160, Y: 240}, Inputs: map[string]dsl.VariableSelector{ + {ID: "policy_1", Type: workflowregistry.NodeTypeReplyPolicy, Name: "回复策略", Position: dsl.Position{X: 520, Y: 320}, 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: 780, Y: 320}, 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"}}, + {ID: "default", Name: "默认澄清", TargetNodeID: "policy_reply_1", Default: true}, + }})}, + {ID: "policy_reply_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送策略回复", Position: dsl.Position{X: 1080, Y: 60}, Inputs: map[string]dsl.VariableSelector{ + "replyText": {NodeID: "policy_1", Field: "replyText"}, + }}, + {ID: "handoff_1", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "转人工", Position: dsl.Position{X: 1080, Y: 180}, Inputs: map[string]dsl.VariableSelector{ + "reason": {NodeID: "start_1", Field: "userMessage"}, + }}, + {ID: "handoff_end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 1380, Y: 180}}, + {ID: "draft_ticket_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "整理工单草稿", Position: dsl.Position{X: 1080, Y: 320}, Inputs: map[string]dsl.VariableSelector{ + "issue": {NodeID: "start_1", Field: "userMessage"}, + }}, + {ID: "ticket_confirm_prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "建单确认文案", Position: dsl.Position{X: 1380, Y: 320}, 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: 1680, Y: 320}, 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: 1310, Y: 240}, Config: mustMarshalWorkflowConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + {ID: "ticket_confirm_route_1", Type: workflowregistry.NodeTypeCondition, Name: "建单确认分流", Position: dsl.Position{X: 1830, Y: 320}, 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"}}, {ID: "default", Name: "取消或未确认", TargetNodeID: "ticket_cancel_reply_1", Default: true}, }})}, - {ID: "create_ticket_1", Type: workflowregistry.NodeTypeCreateTicket, Name: "创建工单", Position: dsl.Position{X: 1460, Y: 180}, Inputs: map[string]dsl.VariableSelector{ + {ID: "create_ticket_1", Type: workflowregistry.NodeTypeCreateTicket, Name: "创建工单", Position: dsl.Position{X: 1980, Y: 260}, 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: 1760, Y: 180}, Inputs: map[string]dsl.VariableSelector{ + {ID: "ticket_result_reply_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送建单结果", Position: dsl.Position{X: 2280, Y: 260}, 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: 1460, Y: 320}, Config: json.RawMessage(`{"staticReply":"已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}`), Inputs: map[string]dsl.VariableSelector{ + {ID: "ticket_cancel_reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "取消建单提示", Position: dsl.Position{X: 1980, Y: 380}, 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: 1760, Y: 320}, Inputs: map[string]dsl.VariableSelector{ + {ID: "send_ticket_cancel_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送取消提示", Position: dsl.Position{X: 2280, Y: 380}, Inputs: map[string]dsl.VariableSelector{ "replyText": {NodeID: "ticket_cancel_reply_1", Field: "replyText"}, }}, - {ID: "retrieve_1", Type: workflowregistry.NodeTypeKnowledgeRetrieve, Name: "知识检索", Position: dsl.Position{X: 560, Y: 500}, Inputs: map[string]dsl.VariableSelector{ + {ID: "retrieve_1", Type: workflowregistry.NodeTypeKnowledgeRetrieve, Name: "知识检索", Position: dsl.Position{X: 1080, Y: 560}, Inputs: map[string]dsl.VariableSelector{ "query": {NodeID: "start_1", Field: "userMessage"}, }}, - {ID: "answerability_1", Type: workflowregistry.NodeTypeAnswerabilityGate, Name: "可回答判断", Position: dsl.Position{X: 860, Y: 500}, Inputs: map[string]dsl.VariableSelector{ + {ID: "answerability_1", Type: workflowregistry.NodeTypeAnswerabilityGate, Name: "可回答判断", Position: dsl.Position{X: 1380, Y: 560}, 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: 1010, Y: 500}, Config: mustMarshalWorkflowConfig(dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + {ID: "answerability_route_1", Type: workflowregistry.NodeTypeCondition, Name: "可回答分流", Position: dsl.Position{X: 1530, Y: 560}, 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"}}, {ID: "default", Name: "兜底追问", TargetNodeID: "fallback_reply_1", Default: true}, }})}, - {ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "AI 回复", Position: dsl.Position{X: 1160, Y: 440}, Inputs: map[string]dsl.VariableSelector{ + {ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "AI 回复", Position: dsl.Position{X: 1680, Y: 500}, 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: 1460, Y: 440}, Inputs: map[string]dsl.VariableSelector{ + {ID: "send_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送回复", Position: dsl.Position{X: 1980, Y: 500}, Inputs: map[string]dsl.VariableSelector{ "replyText": {NodeID: "reply_1", Field: "replyText"}, }}, - {ID: "fallback_reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "兜底追问", Position: dsl.Position{X: 1160, Y: 600}, Inputs: map[string]dsl.VariableSelector{ + {ID: "fallback_reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "兜底追问", Position: dsl.Position{X: 1680, Y: 620}, 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: 1460, Y: 600}, Inputs: map[string]dsl.VariableSelector{ + {ID: "send_fallback_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送兜底", Position: dsl.Position{X: 1980, Y: 620}, Inputs: map[string]dsl.VariableSelector{ "replyText": {NodeID: "fallback_reply_1", Field: "replyText"}, }}, - {ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 2060, Y: 440}}, + {ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 2580, Y: 500}}, }, Edges: []dsl.Edge{ - {ID: "edge_start_route_intent", Source: "start_1", Target: "route_intent_1"}, - {ID: "edge_intent_handoff", Source: "route_intent_1", Target: "handoff_1"}, - {ID: "edge_intent_ticket", Source: "route_intent_1", Target: "draft_ticket_1"}, - {ID: "edge_intent_knowledge_default", Source: "route_intent_1", Target: "retrieve_1"}, + {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"}, diff --git a/web/app/dashboard/ai-workflows/_components/workflow-utils.ts b/web/app/dashboard/ai-workflows/_components/workflow-utils.ts index 563176e..a0113b6 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-utils.ts +++ b/web/app/dashboard/ai-workflows/_components/workflow-utils.ts @@ -94,6 +94,7 @@ export type WorkflowDefinition = { export type WorkflowVariableType = | "string" + | "number" | "integer" | "boolean" | "object" diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index ba6269d..354906d 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -287,6 +287,7 @@ export type AIWorkflowPosition = { export type AIWorkflowVariableType = | "string" + | "number" | "integer" | "boolean" | "object"