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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user