feat: enhance condition handling with enum value options and metadata in workflow components
This commit is contained in:
@@ -44,8 +44,25 @@ func DefaultRegistry() *Registry {
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("normalizedMessage", VariableTypeString, "Normalized customer message."),
|
||||
output("messageIntent", VariableTypeString, "Detected customer message intent."),
|
||||
output("answerScope", VariableTypeString, "Recommended answer scope."),
|
||||
enumOutput("messageIntent", "消息意图", "Detected customer message intent.", []VariableValueOption{
|
||||
valueOption("unknown", "未知意图", "系统暂时无法判断客户意图。"),
|
||||
valueOption("greeting", "打招呼", "客户在问候或开始对话。"),
|
||||
valueOption("thanks", "表达感谢", "客户在表示感谢。"),
|
||||
valueOption("end_conversation", "结束会话", "客户表示问题已处理或准备结束。"),
|
||||
valueOption("confirmation", "确认操作", "客户对上一步操作进行确认。"),
|
||||
valueOption("handoff_request", "要求人工", "客户明确要求转人工处理。"),
|
||||
valueOption("complaint", "投诉升级", "客户表达投诉、举报、起诉等升级风险。"),
|
||||
valueOption("ticket_request", "要求建单", "客户希望创建或跟进工单。"),
|
||||
valueOption("ambiguous_question", "问题不明确", "客户问题缺少必要上下文,需要追问。"),
|
||||
valueOption("business_question", "业务问题", "客户问题适合进入知识库检索。"),
|
||||
}),
|
||||
enumOutput("answerScope", "回复策略", "Recommended answer scope.", []VariableValueOption{
|
||||
valueOption("direct_reply", "直接回复客户", "无需检索知识库或转人工,可以直接生成回复。"),
|
||||
valueOption("needs_clarification", "追问补充信息", "当前信息不足,需要客户补充。"),
|
||||
valueOption("needs_handoff", "转人工处理", "需要人工客服介入。"),
|
||||
valueOption("needs_ticket", "创建工单", "需要进入工单处理流程。"),
|
||||
valueOption("needs_knowledge", "检索知识库", "需要先检索知识库再回答。"),
|
||||
}),
|
||||
output("confidence", VariableTypeNumber, "Classifier confidence."),
|
||||
output("riskSignals", VariableTypeStringArray, "Detected risk signals."),
|
||||
output("reason", VariableTypeString, "Decision reason."),
|
||||
@@ -67,12 +84,31 @@ func DefaultRegistry() *Registry {
|
||||
optionalInput("answerability", VariableTypeString, "Knowledge answerability decision."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("action", VariableTypeString, "Selected policy action."),
|
||||
enumOutput("action", "处理策略", "Selected policy action.", []VariableValueOption{
|
||||
valueOption("direct_reply", "直接回复客户", "直接发送策略节点生成的回复。"),
|
||||
valueOption("clarify", "追问补充信息", "先让客户补充必要信息。"),
|
||||
valueOption("end_conversation", "结束会话", "发送结束语并结束本轮处理。"),
|
||||
valueOption("handoff_to_human", "转人工", "进入人工接待流程。"),
|
||||
valueOption("prepare_ticket", "创建工单", "整理工单草稿并等待确认。"),
|
||||
valueOption("retrieve_knowledge", "检索知识库", "进入知识检索和 AI 回复流程。"),
|
||||
valueOption("knowledge_fallback", "知识库兜底", "知识库结果不足,发送兜底回复。"),
|
||||
}),
|
||||
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."),
|
||||
enumOutput("targetFlow", "目标流程", "Suggested target flow.", []VariableValueOption{
|
||||
valueOption("handoff_to_human", "转人工流程", "继续执行转人工节点。"),
|
||||
valueOption("prepare_ticket", "工单流程", "继续执行工单草稿和确认节点。"),
|
||||
valueOption("knowledge", "知识库流程", "继续执行知识检索节点。"),
|
||||
}),
|
||||
enumOutput("finalReplySource", "回复来源", "Source category for the final reply.", []VariableValueOption{
|
||||
valueOption("direct_reply", "策略直接回复", "由回复策略节点直接生成回复。"),
|
||||
valueOption("clarification", "追问回复", "用于追问客户补充信息。"),
|
||||
valueOption("handoff_notice", "转人工提示", "用于提示客户已进入人工处理。"),
|
||||
valueOption("ticket_result", "工单结果", "用于提示建单结果。"),
|
||||
valueOption("knowledge_answer", "知识库回答", "用于发送基于知识库生成的回复。"),
|
||||
valueOption("knowledge_fallback", "知识库兜底", "用于知识库信息不足时的兜底回复。"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
NodeSpec{
|
||||
@@ -101,7 +137,10 @@ func DefaultRegistry() *Registry {
|
||||
requiredInput("knowledgeItems", VariableTypeObjectArray, "Retrieved knowledge items."),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("answerability", VariableTypeString, "Answerability decision."),
|
||||
enumOutput("answerability", "可回答性", "Answerability decision.", []VariableValueOption{
|
||||
valueOption("answerable", "可以回答", "检索结果足够支撑回答。"),
|
||||
valueOption("unanswerable", "无法回答", "检索结果不足,应该走兜底或追问。"),
|
||||
}),
|
||||
output("reason", VariableTypeString, "Decision reason."),
|
||||
},
|
||||
},
|
||||
@@ -197,7 +236,13 @@ func DefaultRegistry() *Registry {
|
||||
OutputSchema: []VariableSpec{
|
||||
output("handoffId", VariableTypeInteger, "Handoff operation ID."),
|
||||
output("reason", VariableTypeString, "Handoff reason."),
|
||||
output("decision", VariableTypeString, "Handoff dispatch decision."),
|
||||
enumOutput("decision", "转人工结果", "Handoff dispatch decision.", []VariableValueOption{
|
||||
valueOption("assigned", "已分配客服", "已成功分配给人工客服。"),
|
||||
valueOption("team_pool", "团队队列等待", "暂未分配到客服,进入团队等待队列。"),
|
||||
valueOption("global_pool", "全局队列等待", "非服务时间或无可用团队,进入全局等待队列。"),
|
||||
valueOption("off_hours", "非服务时间", "当前不在人工客服服务时间内。"),
|
||||
valueOption("cancelled", "已取消转人工", "由于未确认或条件不满足,未执行转人工。"),
|
||||
}),
|
||||
output("teamId", VariableTypeInteger, "Assigned or pending team ID."),
|
||||
output("assigneeId", VariableTypeInteger, "Assigned agent user ID."),
|
||||
output("message", VariableTypeString, "Customer-visible handoff notice."),
|
||||
@@ -239,3 +284,18 @@ func optionalInput(name string, variableType VariableType, description string) V
|
||||
func output(name string, variableType VariableType, description string) VariableSpec {
|
||||
return VariableSpec{Name: name, Type: variableType, Description: description}
|
||||
}
|
||||
|
||||
func enumOutput(name string, label string, description string, options []VariableValueOption) VariableSpec {
|
||||
return VariableSpec{
|
||||
Name: name,
|
||||
Label: label,
|
||||
Type: VariableTypeString,
|
||||
Description: description,
|
||||
Operators: []string{"eq", "neq"},
|
||||
ValueOptions: options,
|
||||
}
|
||||
}
|
||||
|
||||
func valueOption(value any, label string, description string) VariableValueOption {
|
||||
return VariableValueOption{Value: value, Label: label, Description: description}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,19 @@ const (
|
||||
)
|
||||
|
||||
type VariableSpec struct {
|
||||
Name string `json:"name"`
|
||||
Type VariableType `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Type VariableType `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Operators []string `json:"operators,omitempty"`
|
||||
ValueOptions []VariableValueOption `json:"valueOptions,omitempty"`
|
||||
}
|
||||
|
||||
type VariableValueOption struct {
|
||||
Value any `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type NodeSpec struct {
|
||||
|
||||
@@ -357,8 +357,17 @@ func (v *definitionValidator) validateCondition(field string, sourceNodeID strin
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField); !ok {
|
||||
outputSpec, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField)
|
||||
if !ok {
|
||||
v.addError(field+".left", "condition source field does not exist: "+sourceSelectorNodeID+"."+sourceField)
|
||||
return
|
||||
}
|
||||
if len(outputSpec.Operators) > 0 && !stringInSlice(outputSpec.Operators, operator) {
|
||||
v.addError(field+".operator", "condition operator is not allowed for variable: "+operator)
|
||||
return
|
||||
}
|
||||
if !conditionOperatorWithoutRight(operator) && len(outputSpec.ValueOptions) > 0 && !valueOptionExists(outputSpec.ValueOptions, condition.Right) {
|
||||
v.addError(field+".right", "condition comparison value is not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +380,61 @@ func isSupportedConditionOperator(operator string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func conditionOperatorWithoutRight(operator string) bool {
|
||||
switch strings.TrimSpace(operator) {
|
||||
case "exists", "not_exists", "truthy", "is_true", "falsy", "is_false":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stringInSlice(items []string, value string) bool {
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item) == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func valueOptionExists(items []registry.VariableValueOption, value any) bool {
|
||||
for _, item := range items {
|
||||
if conditionValuesEqual(item.Value, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func conditionValuesEqual(left any, right any) bool {
|
||||
switch l := left.(type) {
|
||||
case string:
|
||||
r, ok := right.(string)
|
||||
return ok && l == r
|
||||
case bool:
|
||||
r, ok := right.(bool)
|
||||
return ok && l == r
|
||||
case int:
|
||||
return conditionValuesEqual(float64(l), right)
|
||||
case int64:
|
||||
return conditionValuesEqual(float64(l), right)
|
||||
case float64:
|
||||
switch r := right.(type) {
|
||||
case int:
|
||||
return l == float64(r)
|
||||
case int64:
|
||||
return l == float64(r)
|
||||
case float64:
|
||||
return l == r
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (v *definitionValidator) hasPath(sourceID string, targetID string, visiting map[string]struct{}) bool {
|
||||
if sourceID == targetID {
|
||||
return false
|
||||
|
||||
@@ -304,6 +304,19 @@ func TestValidateDefinitionRejectsUnknownConditionVariable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsInvalidConditionEnumValue(t *testing.T) {
|
||||
def := policyConditionDefinition("unknown_action")
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected invalid enum condition value to be rejected")
|
||||
}
|
||||
if !hasValidationMessage(result, "condition comparison value is not allowed") {
|
||||
t.Fatalf("expected condition enum value error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func minimalDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
@@ -322,6 +335,52 @@ func minimalDefinition() dsl.Definition {
|
||||
}
|
||||
}
|
||||
|
||||
func policyConditionDefinition(action any) dsl.Definition {
|
||||
conditionConfig, _ := json.Marshal(dsl.ConditionConfig{
|
||||
Branches: []dsl.ConditionBranch{
|
||||
{
|
||||
ID: "direct",
|
||||
Name: "Direct",
|
||||
TargetNodeID: "end_1",
|
||||
Condition: &dsl.Condition{
|
||||
Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"},
|
||||
Operator: "eq",
|
||||
Right: action,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "default",
|
||||
Name: "Default",
|
||||
TargetNodeID: "end_1",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
return dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start_1",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start_1", Type: "start"},
|
||||
{ID: "understanding_1", Type: "conversation_understanding", Inputs: map[string]dsl.VariableSelector{
|
||||
"userMessage": {NodeID: "start_1", Field: "userMessage"},
|
||||
}},
|
||||
{ID: "policy_1", Type: "reply_policy", Inputs: map[string]dsl.VariableSelector{
|
||||
"messageIntent": {NodeID: "understanding_1", Field: "messageIntent"},
|
||||
"answerScope": {NodeID: "understanding_1", Field: "answerScope"},
|
||||
}},
|
||||
{ID: "condition_1", Type: "condition", Config: conditionConfig},
|
||||
{ID: "end_1", Type: "end"},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{ID: "e1", Source: "start_1", Target: "understanding_1"},
|
||||
{ID: "e2", Source: "understanding_1", Target: "policy_1"},
|
||||
{ID: "e3", Source: "policy_1", Target: "condition_1"},
|
||||
{ID: "e4", Source: "condition_1", Target: "end_1"},
|
||||
{ID: "e5", Source: "condition_1", Target: "end_1"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mappedReplyDefinition() dsl.Definition {
|
||||
def := minimalDefinition()
|
||||
def.Nodes[1].Inputs = map[string]dsl.VariableSelector{
|
||||
|
||||
@@ -34,6 +34,36 @@ func TestBuildAIWorkflowNodeSpecsIncludesVariableContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAIWorkflowNodeSpecsIncludesConditionValueOptions(t *testing.T) {
|
||||
specs := BuildAIWorkflowNodeSpecs(workflowregistry.DefaultRegistry().List())
|
||||
|
||||
var action *workflowregistry.VariableSpec
|
||||
for _, spec := range specs {
|
||||
if spec.Type != workflowregistry.NodeTypeReplyPolicy {
|
||||
continue
|
||||
}
|
||||
for index := range spec.OutputSchema {
|
||||
if spec.OutputSchema[index].Name == "action" {
|
||||
action = &spec.OutputSchema[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if action == nil {
|
||||
t.Fatalf("expected reply_policy action output")
|
||||
}
|
||||
if action.Label != "处理策略" {
|
||||
t.Fatalf("expected user-facing action label, got %q", action.Label)
|
||||
}
|
||||
if !hasResponseVariableOption(action.ValueOptions, "direct_reply", "直接回复客户") {
|
||||
t.Fatalf("expected direct_reply option with business label, got %#v", action.ValueOptions)
|
||||
}
|
||||
if !hasResponseOperator(action.Operators, "eq") || !hasResponseOperator(action.Operators, "neq") {
|
||||
t.Fatalf("expected action to constrain condition operators, got %#v", action.Operators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAIWorkflowRunIncludesAuditDisplayFields(t *testing.T) {
|
||||
startedAt := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC)
|
||||
endedAt := startedAt.Add(1500 * time.Millisecond)
|
||||
@@ -109,3 +139,21 @@ func hasResponseVariable(items []workflowregistry.VariableSpec, name string) boo
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasResponseVariableOption(items []workflowregistry.VariableValueOption, value any, label string) bool {
|
||||
for _, item := range items {
|
||||
if item.Value == value && item.Label == label {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasResponseOperator(items []string, value string) bool {
|
||||
for _, item := range items {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user