diff --git a/internal/ai/workflow/registry/registry.go b/internal/ai/workflow/registry/registry.go
index 49b6f1c..3715aba 100644
--- a/internal/ai/workflow/registry/registry.go
+++ b/internal/ai/workflow/registry/registry.go
@@ -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}
+}
diff --git a/internal/ai/workflow/registry/spec.go b/internal/ai/workflow/registry/spec.go
index 240e4a1..f5a35e1 100644
--- a/internal/ai/workflow/registry/spec.go
+++ b/internal/ai/workflow/registry/spec.go
@@ -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 {
diff --git a/internal/ai/workflow/validator/validator.go b/internal/ai/workflow/validator/validator.go
index 4e0bcdb..7259bfc 100644
--- a/internal/ai/workflow/validator/validator.go
+++ b/internal/ai/workflow/validator/validator.go
@@ -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
diff --git a/internal/ai/workflow/validator/validator_test.go b/internal/ai/workflow/validator/validator_test.go
index 54037bd..3017e65 100644
--- a/internal/ai/workflow/validator/validator_test.go
+++ b/internal/ai/workflow/validator/validator_test.go
@@ -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{
diff --git a/internal/builders/ai_workflow_builder_test.go b/internal/builders/ai_workflow_builder_test.go
index dc99fb4..1d254df 100644
--- a/internal/builders/ai_workflow_builder_test.go
+++ b/internal/builders/ai_workflow_builder_test.go
@@ -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
+}
diff --git a/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx b/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx
index 2cedbe3..2b22301 100644
--- a/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx
+++ b/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx
@@ -292,6 +292,8 @@ function ConditionNodePanel({
{branches.map((branch, index) => {
const summary = summariesByBranchID.get(branch.id)
const condition = branch.condition ?? {}
+ const selectedVariable = findConditionVariable(availableVariables, condition.left)
+ const operatorOptions = getConditionOperatorOptions(selectedVariable)
const conditionRight = condition.right === undefined || condition.right === null
? ""
: String(condition.right)
@@ -344,7 +346,7 @@ function ConditionNodePanel({