feat: add conversation understanding and reply policy nodes to workflow executor

- Implemented conversation understanding and reply policy execution in the workflow executor.
- Added new node types: NodeTypeConversationUnderstanding and NodeTypeReplyPolicy.
- Enhanced input and output schemas for the new nodes.
- Updated workflow registry to include new node specifications.
- Created tests for the new workflow routes and behaviors.
- Modified existing workflows to integrate the new conversation understanding and reply policy logic.
This commit is contained in:
mlogclub
2026-06-25 22:38:34 +08:00
parent 7ba7deea96
commit 21fd119b27
9 changed files with 473 additions and 52 deletions
+204
View File
@@ -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 == "" {
@@ -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: "<p>你好。</p>",
},
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,
+43
View File
@@ -4,6 +4,8 @@ import "agent-desk/internal/ai/workflow/dsl"
const (
NodeTypeStart = "start"
NodeTypeConversationUnderstanding = "conversation_understanding"
NodeTypeReplyPolicy = "reply_policy"
NodeTypeKnowledgeRetrieve = "knowledge_retrieve"
NodeTypeAnswerabilityGate = "answerability_gate"
NodeTypeLLMReply = "llm_reply"
@@ -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",
@@ -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 {
+1
View File
@@ -14,6 +14,7 @@ type VariableType string
const (
VariableTypeString VariableType = "string"
VariableTypeNumber VariableType = "number"
VariableTypeInteger VariableType = "integer"
VariableTypeBoolean VariableType = "boolean"
VariableTypeObject VariableType = "object"
@@ -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")
+52 -34
View File
@@ -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"},
@@ -94,6 +94,7 @@ export type WorkflowDefinition = {
export type WorkflowVariableType =
| "string"
| "number"
| "integer"
| "boolean"
| "object"
+1
View File
@@ -287,6 +287,7 @@ export type AIWorkflowPosition = {
export type AIWorkflowVariableType =
| "string"
| "number"
| "integer"
| "boolean"
| "object"