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,
+55 -12
View File
@@ -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",
@@ -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"