chore: import clean Agent Desk snapshot (2/7)

This commit is contained in:
2026-10-16 11:57:21 +00:00
parent 79176541c7
commit 89c5231c4c
461 changed files with 60092 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
package dsl
import "encoding/json"
const SchemaVersion = 2
type Definition struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Nodes []Node `json:"nodes"`
Annotations []Node `json:"annotations,omitempty"`
Edges []Edge `json:"edges"`
GlobalVariable json.RawMessage `json:"globalVariable,omitempty"`
}
type Node struct {
ID string `json:"id"`
Type string `json:"type"`
Meta NodeMeta `json:"meta"`
Data NodeData `json:"data"`
Blocks []Node `json:"blocks,omitempty"`
Edges []Edge `json:"edges,omitempty"`
}
type NodeMeta struct {
Position Position `json:"position"`
}
type NodeData struct {
Title string `json:"title,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
Inputs json.RawMessage `json:"inputs,omitempty"`
Outputs json.RawMessage `json:"outputs,omitempty"`
InputsValues map[string]Value `json:"inputsValues,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}
type Position struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type Edge struct {
SourceNodeID string `json:"sourceNodeID"`
TargetNodeID string `json:"targetNodeID"`
SourcePortID string `json:"sourcePortID,omitempty"`
TargetPortID string `json:"targetPortID,omitempty"`
}
type ValueType string
const (
ValueTypeConstant ValueType = "constant"
ValueTypeRef ValueType = "ref"
ValueTypeTemplate ValueType = "template"
)
type Value struct {
Type ValueType `json:"type"`
Content []string `json:"content,omitempty"`
ConstantContent any `json:"-"`
RawContent json.RawMessage `json:"-"`
}
type ConditionConfig struct {
Branches []ConditionBranch `json:"branches,omitempty"`
}
type ConditionBranch struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
TargetNodeID string `json:"targetNodeId"`
Condition *Condition `json:"condition,omitempty"`
Default bool `json:"default,omitempty"`
}
type Condition struct {
Expression string `json:"expression,omitempty"`
Left *Value `json:"left,omitempty"`
Operator string `json:"operator,omitempty"`
Right any `json:"right,omitempty"`
}
type FlowGramConditionItem struct {
Key string `json:"key"`
Value FlowGramCondition `json:"value"`
}
type FlowGramCondition struct {
Left Value `json:"left"`
Operator string `json:"operator"`
Right Value `json:"right"`
}
func RefValue(nodeID string, field string) Value {
return Value{Type: ValueTypeRef, Content: []string{nodeID, field}}
}
func ConstantValue(value any) Value {
raw, _ := json.Marshal(value)
return Value{Type: ValueTypeConstant, ConstantContent: value, RawContent: raw}
}
func TemplateValue(value string) Value {
return Value{Type: ValueTypeTemplate, Content: []string{value}}
}
func (v Value) Ref() (nodeID string, field string, ok bool) {
if v.Type != ValueTypeRef || len(v.Content) < 2 {
return "", "", false
}
return v.Content[0], v.Content[1], true
}
func (v *Value) UnmarshalJSON(data []byte) error {
type alias struct {
Type ValueType `json:"type"`
Content json.RawMessage `json:"content"`
}
var parsed alias
if err := json.Unmarshal(data, &parsed); err != nil {
return err
}
v.Type = parsed.Type
v.RawContent = append(v.RawContent[:0], parsed.Content...)
switch parsed.Type {
case ValueTypeRef:
var content []string
if len(parsed.Content) > 0 {
if err := json.Unmarshal(parsed.Content, &content); err != nil {
return err
}
}
v.Content = content
case ValueTypeTemplate:
var content string
if len(parsed.Content) > 0 {
if err := json.Unmarshal(parsed.Content, &content); err != nil {
return err
}
}
v.Content = []string{content}
case ValueTypeConstant:
if len(parsed.Content) > 0 {
if err := json.Unmarshal(parsed.Content, &v.ConstantContent); err != nil {
return err
}
}
default:
if len(parsed.Content) > 0 {
var content []string
if err := json.Unmarshal(parsed.Content, &content); err == nil {
v.Content = content
}
}
}
return nil
}
func (v Value) MarshalJSON() ([]byte, error) {
type alias struct {
Type ValueType `json:"type"`
Content any `json:"content,omitempty"`
}
var content any
switch v.Type {
case ValueTypeRef:
content = v.Content
case ValueTypeTemplate:
if len(v.Content) > 0 {
content = v.Content[0]
}
case ValueTypeConstant:
content = v.ConstantContent
default:
if len(v.Content) > 0 {
content = v.Content
}
}
return json.Marshal(alias{Type: v.Type, Content: content})
}
func (d *NodeData) UnmarshalJSON(data []byte) error {
type alias NodeData
var parsed alias
if err := json.Unmarshal(data, &parsed); err != nil {
return err
}
extra := make(map[string]json.RawMessage)
if err := json.Unmarshal(data, &extra); err != nil {
return err
}
delete(extra, "title")
delete(extra, "config")
delete(extra, "inputs")
delete(extra, "outputs")
delete(extra, "inputsValues")
*d = NodeData(parsed)
if len(extra) > 0 {
d.Extra = extra
}
return nil
}
func (d NodeData) MarshalJSON() ([]byte, error) {
type alias NodeData
base, err := json.Marshal(alias(d))
if err != nil {
return nil, err
}
values := make(map[string]json.RawMessage)
if err := json.Unmarshal(base, &values); err != nil {
return nil, err
}
for key, value := range d.Extra {
if _, exists := values[key]; exists {
continue
}
values[key] = value
}
return json.Marshal(values)
}

View File

@@ -0,0 +1,113 @@
package dsl_test
import (
"encoding/json"
"strings"
"testing"
"agent-desk/internal/ai/workflow/dsl"
)
func TestDefinitionUnmarshalsFlowGramStyleSchema(t *testing.T) {
raw := []byte(`{
"schemaVersion": 2,
"nodes": [{
"id": "send_1",
"type": "send_reply",
"meta": {
"position": { "x": 360, "y": 120 }
},
"data": {
"title": "发送回复",
"config": { "text": "hello" },
"inputs": {
"type": "object",
"properties": {
"replyText": { "type": "string" }
},
"required": ["replyText"]
},
"outputs": {
"type": "object",
"properties": {
"sent": { "type": "boolean" }
}
},
"inputsValues": {
"replyText": {
"type": "ref",
"content": ["start_1", "userMessage"]
}
}
}
}],
"edges": [{
"sourceNodeID": "start_1",
"targetNodeID": "send_1",
"sourcePortID": "default"
}]
}`)
var def dsl.Definition
if err := json.Unmarshal(raw, &def); err != nil {
t.Fatalf("unmarshal definition: %v", err)
}
if def.SchemaVersion != 2 {
t.Fatalf("unexpected schema version: %d", def.SchemaVersion)
}
node := def.Nodes[0]
if node.ID != "send_1" || node.Type != "send_reply" {
t.Fatalf("unexpected node identity: %#v", node)
}
if node.Meta.Position.X != 360 || node.Meta.Position.Y != 120 {
t.Fatalf("unexpected node position: %#v", node.Meta.Position)
}
if node.Data.Title != "发送回复" {
t.Fatalf("unexpected node title: %q", node.Data.Title)
}
var config map[string]string
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
if config["text"] != "hello" {
t.Fatalf("unexpected config: %s", node.Data.Config)
}
replyText := node.Data.InputsValues["replyText"]
if replyText.Type != dsl.ValueTypeRef || len(replyText.Content) != 2 || replyText.Content[0] != "start_1" || replyText.Content[1] != "userMessage" {
t.Fatalf("unexpected replyText value: %#v", replyText)
}
edge := def.Edges[0]
if edge.SourceNodeID != "start_1" || edge.TargetNodeID != "send_1" || edge.SourcePortID != "default" {
t.Fatalf("unexpected edge: %#v", edge)
}
}
func TestDefinitionPreservesCanvasAnnotations(t *testing.T) {
var def dsl.Definition
err := json.Unmarshal([]byte(`{
"schemaVersion": 2,
"nodes": [],
"annotations": [{
"id": "comment_1",
"type": "comment",
"meta": {"position": {"x": 12, "y": 34}},
"data": {"note": "check this branch", "size": {"width": 240, "height": 150}}
}],
"edges": []
}`), &def)
if err != nil {
t.Fatalf("unmarshal definition: %v", err)
}
if len(def.Annotations) != 1 || def.Annotations[0].ID != "comment_1" {
t.Fatalf("expected annotation to be preserved, got %#v", def.Annotations)
}
encoded, err := json.Marshal(def)
if err != nil {
t.Fatalf("marshal definition: %v", err)
}
if !strings.Contains(string(encoded), `"annotations"`) ||
!strings.Contains(string(encoded), `"check this branch"`) {
t.Fatalf("expected annotation JSON to round trip, got %s", encoded)
}
}

View File

@@ -0,0 +1,372 @@
package registry
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"
NodeTypeLLM = "llm"
NodeTypeHTTP = "http"
NodeTypeCode = "code"
NodeTypeVariable = "variable"
NodeTypeMultiCondition = "multi-condition"
NodeTypeLoop = "loop"
NodeTypeBlockStart = "block-start"
NodeTypeBlockEnd = "block-end"
NodeTypeComment = "comment"
NodeTypeContinue = "continue"
NodeTypeBreak = "break"
NodeTypeGroup = "group"
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 {
return NewRegistry(
NodeSpec{
Type: NodeTypeStart,
Title: "Start",
Description: "Conversation workflow entry.",
Icon: "PlayCircleIcon",
RiskLevel: NodeRiskLevelLow,
OutputSchema: []VariableSpec{
output("conversationId", "会话 ID", VariableTypeInteger, "当前客户会话的内部编号。"),
output("messageId", "消息 ID", VariableTypeInteger, "客户本轮消息的内部编号。"),
output("aiAgentId", "AI Agent ID", VariableTypeInteger, "当前处理会话的 AI Agent 编号。"),
output("userMessage", "用户消息", VariableTypeString, "客户本轮发送的原始消息内容。"),
},
},
NodeSpec{
Type: NodeTypeConversationUnderstanding,
Title: "Conversation Understanding",
Description: "Classify customer message intent and answer scope before retrieval.",
Icon: "MessageCircleIcon",
RiskLevel: NodeRiskLevelLow,
InputSchema: []VariableSpec{
requiredInput("userMessage", "用户消息", VariableTypeString, "客户本轮发送的原始消息内容。"),
},
OutputSchema: []VariableSpec{
output("normalizedMessage", "规范化消息", VariableTypeString, "经过清洗和规范化后的客户消息。"),
enumOutput("messageIntent", "消息意图", "客户消息的意图分类。", []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", "回复策略", "系统建议采用的回复处理范围。", []VariableValueOption{
valueOption("direct_reply", "直接回复客户", "无需检索知识库或转人工,可以直接生成回复。"),
valueOption("needs_clarification", "追问补充信息", "当前信息不足,需要客户补充。"),
valueOption("needs_handoff", "转人工处理", "需要人工客服介入。"),
valueOption("needs_ticket", "创建工单", "需要进入工单处理流程。"),
valueOption("needs_knowledge", "检索知识库", "需要先检索知识库再回答。"),
}),
output("confidence", "置信度", VariableTypeNumber, "意图和回复策略判断的置信度。"),
output("riskSignals", "风险信号", VariableTypeStringArray, "识别到的投诉、升级、人工介入等风险线索。"),
output("reason", "判断原因", VariableTypeString, "本次意图和回复策略判断的原因说明。"),
},
DefaultInputs: map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
},
},
NodeSpec{
Type: NodeTypeReplyPolicy,
Title: "Reply Policy",
Description: "Decide the next customer-service action from understanding output and agent policy.",
Icon: "ShieldCheckIcon",
RiskLevel: NodeRiskLevelLow,
InputSchema: []VariableSpec{
requiredInput("messageIntent", "消息意图", VariableTypeString, "上游理解节点识别出的客户消息意图。"),
requiredInput("answerScope", "回复策略", VariableTypeString, "上游理解节点建议采用的回复处理范围。"),
optionalInput("userMessage", "用户消息", VariableTypeString, "客户本轮发送的原始消息内容。"),
optionalInput("riskSignals", "风险信号", VariableTypeStringArray, "上游识别到的风险线索列表。"),
optionalInput("answerability", "可回答性", VariableTypeString, "知识库结果是否足够支撑回答的判断。"),
},
OutputSchema: []VariableSpec{
enumOutput("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, "可直接发送给客户的回复文本。"),
output("reason", "策略原因", VariableTypeString, "选择当前处理策略的原因说明。"),
output("requiresFlow", "需要继续流程", VariableTypeBoolean, "是否需要继续执行后续工作流节点。"),
enumOutput("targetFlow", "目标流程", "建议继续执行的业务流程。", []VariableValueOption{
valueOption("handoff_to_human", "转人工流程", "继续执行转人工节点。"),
valueOption("prepare_ticket", "工单流程", "继续执行工单草稿和确认节点。"),
valueOption("knowledge", "知识库流程", "继续执行知识检索节点。"),
}),
enumOutput("finalReplySource", "回复来源", "最终回复内容的来源类别。", []VariableValueOption{
valueOption("direct_reply", "策略直接回复", "由回复策略节点直接生成回复。"),
valueOption("clarification", "追问回复", "用于追问客户补充信息。"),
valueOption("handoff_notice", "转人工提示", "用于提示客户已进入人工处理。"),
valueOption("ticket_result", "工单结果", "用于提示建单结果。"),
valueOption("knowledge_answer", "知识库回答", "用于发送基于知识库生成的回复。"),
valueOption("knowledge_fallback", "知识库兜底", "用于知识库信息不足时的兜底回复。"),
}),
},
},
NodeSpec{
Type: NodeTypeKnowledgeRetrieve,
Title: "Knowledge Retrieve",
Description: "Retrieve knowledge for the current user message.",
Icon: "BookOpenIcon",
RiskLevel: NodeRiskLevelLow,
ConfigSchema: map[string]any{
"knowledgeBaseIds": map[string]any{
"type": string(VariableTypeIntegerArray),
"label": "知识库",
"required": true,
"description": "本节点检索时使用的知识库列表,按顺序表示优先级。",
},
},
InputSchema: []VariableSpec{
requiredInput("query", "检索问题", VariableTypeString, "用于检索知识库的客户问题或查询文本。"),
},
OutputSchema: []VariableSpec{
output("items", "知识条目", VariableTypeObjectArray, "从知识库命中的原始知识条目列表。"),
output("summary", "检索摘要", VariableTypeString, "对本次知识检索结果的简短摘要。"),
},
DefaultInputs: map[string]dsl.Value{
"query": dsl.RefValue("start_1", "userMessage"),
},
},
NodeSpec{
Type: NodeTypeAnswerabilityGate,
Title: "Answerability Gate",
Description: "Decide whether retrieved knowledge is enough to answer.",
Icon: "HelpCircleIcon",
RiskLevel: NodeRiskLevelLow,
InputSchema: []VariableSpec{
requiredInput("userMessage", "用户消息", VariableTypeString, "客户本轮发送的原始消息内容。"),
requiredInput("knowledgeItems", "知识条目", VariableTypeObjectArray, "上游知识检索节点命中的知识条目列表。"),
},
OutputSchema: []VariableSpec{
enumOutput("answerability", "可回答性", "知识库结果是否足够支撑回答的判断。", []VariableValueOption{
valueOption("answerable", "可以回答", "检索结果足够支撑回答。"),
valueOption("unanswerable", "无法回答", "检索结果不足,应该走兜底或追问。"),
}),
output("reason", "判断原因", VariableTypeString, "可回答性判断的原因说明。"),
},
},
NodeSpec{
Type: NodeTypeLLMReply,
Title: "LLM Reply",
Description: "Generate a reply or structured analysis with the configured model.",
Icon: "BotIcon",
RiskLevel: NodeRiskLevelMedium,
InputSchema: []VariableSpec{
requiredInput("userMessage", "用户消息", VariableTypeString, "客户本轮发送的原始消息内容。"),
optionalInput("knowledgeItems", "知识条目", VariableTypeObjectArray, "可用于生成回复的知识库检索结果。"),
},
OutputSchema: []VariableSpec{
output("replyText", "回复内容", VariableTypeString, "大模型生成的客户可见回复文本。"),
},
},
NodeSpec{
Type: NodeTypeCondition,
Title: "Condition",
Description: "Route by controlled workflow variables.",
Icon: "GitBranchIcon",
RiskLevel: NodeRiskLevelLow,
OutputSchema: []VariableSpec{
output("matched", "是否命中", VariableTypeBoolean, "条件节点是否命中了某个条件分支。"),
},
},
NodeSpec{
Type: NodeTypeAnalyzeConversation,
Title: "Analyze Conversation",
Description: "Analyze intent, risk, and recommended next action.",
Icon: "SearchIcon",
RiskLevel: NodeRiskLevelLow,
InputSchema: []VariableSpec{
requiredInput("userMessage", "用户消息", VariableTypeString, "客户本轮发送的原始消息内容。"),
},
OutputSchema: []VariableSpec{
output("intent", "用户意图", VariableTypeString, "从会话中识别出的客户意图。"),
output("riskLevel", "风险等级", VariableTypeString, "本轮会话的风险等级判断。"),
output("needTicket", "需要工单", VariableTypeBoolean, "是否建议进入工单处理流程。"),
output("needHumanHandoff", "需要转人工", VariableTypeBoolean, "是否建议转人工客服处理。"),
},
},
NodeSpec{
Type: NodeTypePrepareTicketDraft,
Title: "Prepare Ticket Draft",
Description: "Build a ticket draft from conversation context.",
Icon: "ClipboardListIcon",
RiskLevel: NodeRiskLevelMedium,
InputSchema: []VariableSpec{
requiredInput("issue", "问题摘要", VariableTypeString, "需要整理进工单的客户问题摘要。"),
},
OutputSchema: []VariableSpec{
output("ticketDraft", "工单草稿", VariableTypeObject, "根据会话内容整理出的待确认工单草稿。"),
output("ready", "草稿就绪", VariableTypeBoolean, "工单草稿是否已具备创建所需的关键信息。"),
output("title", "工单标题", VariableTypeString, "工单草稿标题。"),
output("description", "工单描述", VariableTypeString, "工单草稿描述。"),
output("missingFields", "缺失字段", VariableTypeStringArray, "仍需客户补充的字段列表。"),
output("followUpQuestions", "追问问题", VariableTypeStringArray, "用于补齐工单信息的追问问题。"),
},
},
NodeSpec{
Type: NodeTypeHumanConfirm,
Title: "Human Confirm",
Description: "Interrupt and wait for explicit user confirmation.",
Icon: "UserCheckIcon",
RiskLevel: NodeRiskLevelMedium,
Interruptible: true,
InputSchema: []VariableSpec{
requiredInput("prompt", "确认提示", VariableTypeString, "发送给客户用于确认操作的提示文本。"),
},
OutputSchema: []VariableSpec{
output("confirmed", "已确认", VariableTypeBoolean, "客户是否明确确认继续执行。"),
output("responseText", "确认回复", VariableTypeString, "客户针对确认提示给出的回复文本。"),
},
},
NodeSpec{
Type: NodeTypeCreateTicket,
Title: "Create Ticket",
Description: "Create a ticket from a confirmed draft.",
Icon: "TicketIcon",
RiskLevel: NodeRiskLevelHigh,
RequiresConfirmationPredecessor: true,
InputSchema: []VariableSpec{
requiredInput("ticketDraft", "工单草稿", VariableTypeObject, "已经由客户确认的工单草稿内容。"),
requiredInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认创建工单。"),
optionalInput("tagIds", "工单标签", VariableTypeIntegerArray, "创建工单时附加的标签 ID 列表。"),
optionalInput("assigneeId", "处理人", VariableTypeInteger, "创建工单后默认指派的客服用户 ID。"),
},
OutputSchema: []VariableSpec{
output("ticketId", "工单 ID", VariableTypeInteger, "创建成功后的工单内部编号。"),
output("ticketNo", "工单编号", VariableTypeString, "创建成功后的客户可见工单编号。"),
output("created", "已创建", VariableTypeBoolean, "工单是否已经成功创建。"),
output("message", "结果消息", VariableTypeString, "发送给客户的工单创建结果说明。"),
},
},
NodeSpec{
Type: NodeTypeHandoffToHuman,
Title: "Handoff To Human",
Description: "Transfer the conversation to human support.",
Icon: "HeadphonesIcon",
RiskLevel: NodeRiskLevelHigh,
RequiresConfirmationPredecessor: true,
InputSchema: []VariableSpec{
requiredInput("reason", "转人工原因", VariableTypeString, "触发转人工处理的业务原因。"),
requiredInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认转人工。"),
},
OutputSchema: []VariableSpec{
output("handoffId", "转人工记录 ID", VariableTypeInteger, "本次转人工操作的内部记录编号。"),
output("reason", "转人工原因", VariableTypeString, "本次转人工处理的原因说明。"),
enumOutput("decision", "转人工结果", "转人工分配或排队结果。", []VariableValueOption{
valueOption("assigned", "已分配客服", "已成功分配给人工客服。"),
valueOption("team_pool", "团队队列等待", "暂未分配到客服,进入团队等待队列。"),
valueOption("global_pool", "全局队列等待", "非服务时间或无可用团队,进入全局等待队列。"),
valueOption("off_hours", "非服务时间", "当前不在人工客服服务时间内。"),
valueOption("cancelled", "已取消转人工", "由于未确认或条件不满足,未执行转人工。"),
}),
output("teamId", "客服组 ID", VariableTypeInteger, "已分配或等待中的客服组编号。"),
output("assigneeId", "客服 ID", VariableTypeInteger, "已分配的人工客服用户编号。"),
output("message", "转人工提示", VariableTypeString, "发送给客户的转人工结果提示。"),
},
},
NodeSpec{
Type: NodeTypeSendReply,
Title: "Send Reply",
Description: "Return or commit customer-visible reply text.",
Icon: "SendIcon",
RiskLevel: NodeRiskLevelLow,
InputSchema: []VariableSpec{
requiredInput("replyText", "回复内容", VariableTypeString, "将发送或返回给客户的最终回复文本。"),
},
OutputSchema: []VariableSpec{
output("sent", "已发送", VariableTypeBoolean, "回复是否已经成功发送或返回。"),
output("replyMessageId", "回复消息 ID", VariableTypeInteger, "发送成功后的回复消息编号。"),
},
},
NodeSpec{
Type: NodeTypeEnd,
Title: "End",
Description: "End workflow execution.",
Icon: "FlagIcon",
RiskLevel: NodeRiskLevelLow,
OutputSchema: []VariableSpec{
output("status", "结束状态", VariableTypeString, "工作流执行结束时的状态。"),
},
},
NodeSpec{
Type: NodeTypeLLM,
Title: "LLM",
Description: "Call the large language model and generate responses.",
RiskLevel: NodeRiskLevelLow,
OutputSchema: []VariableSpec{
output("result", "Result", VariableTypeString, "The generated model response."),
},
},
officialNodeSpec(NodeTypeHTTP, "HTTP", "Send an HTTP request."),
officialNodeSpec(NodeTypeCode, "Code", "Run JavaScript code."),
officialNodeSpec(NodeTypeVariable, "Variable", "Assign workflow variables."),
officialNodeSpec(NodeTypeMultiCondition, "Multi Condition", "Route through multiple condition branches."),
officialNodeSpec(NodeTypeLoop, "Loop", "Iterate over an array in a sub-canvas."),
officialNodeSpec(NodeTypeBlockStart, "Block Start", "Start a container block."),
officialNodeSpec(NodeTypeBlockEnd, "Block End", "End a container block."),
officialNodeSpec(NodeTypeComment, "Comment", "Add a canvas annotation."),
officialNodeSpec(NodeTypeContinue, "Continue", "Continue the current loop."),
officialNodeSpec(NodeTypeBreak, "Break", "Break the current loop."),
officialNodeSpec(NodeTypeGroup, "Group", "Group related workflow nodes."),
)
}
func officialNodeSpec(nodeType string, title string, description string) NodeSpec {
return NodeSpec{
Type: nodeType,
Title: title,
Description: description,
Icon: "",
RiskLevel: NodeRiskLevelLow,
}
}
func requiredInput(name string, label string, variableType VariableType, description string) VariableSpec {
return VariableSpec{Name: name, Label: label, Type: variableType, Required: true, Description: description}
}
func optionalInput(name string, label string, variableType VariableType, description string) VariableSpec {
return VariableSpec{Name: name, Label: label, Type: variableType, Description: description}
}
func output(name string, label string, variableType VariableType, description string) VariableSpec {
return VariableSpec{Name: name, Label: label, 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}
}

View File

@@ -0,0 +1,137 @@
package registry
import "testing"
func TestDefaultRegistryMarksOnlyRuntimeSupportedNodesExecutable(t *testing.T) {
registry := DefaultRegistry()
for _, nodeType := range []string{NodeTypeCreateTicket, NodeTypeHumanConfirm, NodeTypeSendReply, NodeTypeLLM} {
spec, ok := registry.Get(nodeType)
if !ok || !spec.Executable {
t.Fatalf("expected %s to be executable, got %#v", nodeType, spec)
}
}
for _, nodeType := range []string{NodeTypeHTTP, NodeTypeCode, NodeTypeLoop} {
spec, ok := registry.Get(nodeType)
if !ok || spec.Executable {
t.Fatalf("expected %s to be unavailable in server runtime, got %#v", nodeType, spec)
}
}
}
func TestDefaultRegistryVariablesHaveBusinessLabels(t *testing.T) {
for _, spec := range DefaultRegistry().List() {
for _, variable := range append(spec.InputSchema, spec.OutputSchema...) {
if variable.Label == "" {
t.Fatalf("%s.%s is missing business label", spec.Type, variable.Name)
}
if variable.Description == "" || variable.Description == variable.Name {
t.Fatalf("%s.%s is missing readable description", spec.Type, variable.Name)
}
}
}
}
func TestDefaultRegistryExposesStartOutputs(t *testing.T) {
spec, ok := DefaultRegistry().Get(NodeTypeStart)
if !ok {
t.Fatalf("start node spec not found")
}
if !hasVariable(spec.OutputSchema, "userMessage", VariableTypeString) {
t.Fatalf("expected start output userMessage:string, got %#v", spec.OutputSchema)
}
if hasVariableName(spec.OutputSchema, "knowledgeBaseIds") {
t.Fatalf("did not expect start output knowledgeBaseIds after knowledge binding moved to retrieve node, got %#v", spec.OutputSchema)
}
}
func TestDefaultRegistryExposesKnowledgeRetrieveInputsAndOutputs(t *testing.T) {
spec, ok := DefaultRegistry().Get(NodeTypeKnowledgeRetrieve)
if !ok {
t.Fatalf("knowledge_retrieve node spec not found")
}
if !hasRequiredVariable(spec.InputSchema, "query", VariableTypeString) {
t.Fatalf("expected knowledge_retrieve required input query:string, got %#v", spec.InputSchema)
}
if !hasVariable(spec.OutputSchema, "items", VariableTypeObjectArray) {
t.Fatalf("expected knowledge_retrieve output items:array<object>, got %#v", spec.OutputSchema)
}
if spec.ConfigSchema == nil {
t.Fatalf("expected knowledge_retrieve config schema")
}
}
func TestDefaultRegistryExposesSendReplyRequiredInput(t *testing.T) {
spec, ok := DefaultRegistry().Get(NodeTypeSendReply)
if !ok {
t.Fatalf("send_reply node spec not found")
}
if !hasRequiredVariable(spec.InputSchema, "replyText", VariableTypeString) {
t.Fatalf("expected send_reply required input replyText:string, got %#v", spec.InputSchema)
}
if !hasVariable(spec.OutputSchema, "sent", VariableTypeBoolean) {
t.Fatalf("expected send_reply output sent:boolean, got %#v", spec.OutputSchema)
}
}
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 {
return true
}
}
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 {
return true
}
}
return false
}

View File

@@ -0,0 +1,134 @@
package registry
import "agent-desk/internal/ai/workflow/dsl"
type NodeRiskLevel string
const (
NodeRiskLevelLow NodeRiskLevel = "low"
NodeRiskLevelMedium NodeRiskLevel = "medium"
NodeRiskLevelHigh NodeRiskLevel = "high"
)
type VariableType string
const (
VariableTypeString VariableType = "string"
VariableTypeNumber VariableType = "number"
VariableTypeInteger VariableType = "integer"
VariableTypeBoolean VariableType = "boolean"
VariableTypeObject VariableType = "object"
VariableTypeStringArray VariableType = "array<string>"
VariableTypeIntegerArray VariableType = "array<int>"
VariableTypeObjectArray VariableType = "array<object>"
VariableTypeAny VariableType = "any"
)
type VariableSpec struct {
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 {
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Category string `json:"category"`
Executable bool `json:"executable"`
RiskLevel NodeRiskLevel `json:"riskLevel"`
Interruptible bool `json:"interruptible"`
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
ConfigSchema any `json:"configSchema,omitempty"`
InputSchema []VariableSpec `json:"inputSchema,omitempty"`
OutputSchema []VariableSpec `json:"outputSchema,omitempty"`
DefaultInputs map[string]dsl.Value `json:"defaultInputs,omitempty"`
}
type Registry struct {
specsByType map[string]NodeSpec
specs []NodeSpec
}
func NewRegistry(specs ...NodeSpec) *Registry {
ret := &Registry{
specsByType: make(map[string]NodeSpec, len(specs)),
specs: make([]NodeSpec, 0, len(specs)),
}
for _, spec := range specs {
if spec.Type == "" {
continue
}
spec.Executable = IsExecutableNodeType(spec.Type)
if spec.Category == "" {
spec.Category = NodeCategory(spec.Type)
}
ret.specsByType[spec.Type] = spec
ret.specs = append(ret.specs, spec)
}
return ret
}
func IsExecutableNodeType(nodeType string) bool {
switch nodeType {
case NodeTypeStart,
NodeTypeConversationUnderstanding,
NodeTypeReplyPolicy,
NodeTypeKnowledgeRetrieve,
NodeTypeAnswerabilityGate,
NodeTypeCondition,
NodeTypeAnalyzeConversation,
NodeTypePrepareTicketDraft,
NodeTypeHumanConfirm,
NodeTypeCreateTicket,
NodeTypeLLMReply,
NodeTypeLLM,
NodeTypeSendReply,
NodeTypeHandoffToHuman,
NodeTypeEnd:
return true
default:
return false
}
}
func NodeCategory(nodeType string) string {
switch nodeType {
case NodeTypeStart, NodeTypeEnd:
return "trigger"
case NodeTypeCondition, NodeTypeMultiCondition, NodeTypeLoop, NodeTypeBlockStart, NodeTypeBlockEnd, NodeTypeContinue, NodeTypeBreak:
return "control"
case NodeTypeConversationUnderstanding, NodeTypeReplyPolicy, NodeTypeAnswerabilityGate, NodeTypeAnalyzeConversation, NodeTypeLLMReply, NodeTypeLLM, NodeTypeKnowledgeRetrieve:
return "ai"
case NodeTypePrepareTicketDraft, NodeTypeHumanConfirm, NodeTypeCreateTicket, NodeTypeHandoffToHuman, NodeTypeSendReply:
return "business"
default:
return "utility"
}
}
func (r *Registry) Get(nodeType string) (NodeSpec, bool) {
if r == nil {
return NodeSpec{}, false
}
spec, ok := r.specsByType[nodeType]
return spec, ok
}
func (r *Registry) List() []NodeSpec {
if r == nil {
return nil
}
return append([]NodeSpec(nil), r.specs...)
}

View File

@@ -0,0 +1,640 @@
package validator
import (
"encoding/json"
"fmt"
"strings"
"agent-desk/internal/ai/workflow/dsl"
"agent-desk/internal/ai/workflow/registry"
)
type Error struct {
Field string `json:"field"`
Message string `json:"message"`
}
type Result struct {
Valid bool `json:"valid"`
Errors []Error `json:"errors"`
}
func ValidateDefinition(def dsl.Definition, reg *registry.Registry) Result {
if reg == nil {
reg = registry.DefaultRegistry()
}
v := definitionValidator{
def: def,
registry: reg,
nodesByID: make(map[string]dsl.Node, len(def.Nodes)),
outgoing: make(map[string][]string),
incoming: make(map[string][]string),
startNodeIDs: make([]string, 0, 1),
endNodeIDs: make([]string, 0, 1),
}
v.validate()
return Result{
Valid: len(v.errors) == 0,
Errors: v.errors,
}
}
type definitionValidator struct {
def dsl.Definition
registry *registry.Registry
nodesByID map[string]dsl.Node
outgoing map[string][]string
incoming map[string][]string
startNodeIDs []string
endNodeIDs []string
errors []Error
}
func (v *definitionValidator) validate() {
v.validateNodes()
v.validateEdges()
v.validateKnowledgeRetrieveConfigs()
v.validateReachability()
v.validateConfirmationGuards()
v.validateVariableMappings()
v.validateConditions()
}
func (v *definitionValidator) validateNodes() {
for index, node := range v.def.Nodes {
node.ID = strings.TrimSpace(node.ID)
node.Type = strings.TrimSpace(node.Type)
field := fmt.Sprintf("nodes[%d]", index)
if node.ID == "" {
v.addError(field+".id", "node id is required")
continue
}
if _, exists := v.nodesByID[node.ID]; exists {
v.addError(field+".id", "duplicate node id: "+node.ID)
continue
}
v.nodesByID[node.ID] = node
if node.Type == "" {
v.addError(field+".type", "node type is required")
continue
}
if _, ok := v.registry.Get(node.Type); !ok {
v.addError(field+".type", "unknown node type: "+node.Type)
continue
}
if !registry.IsExecutableNodeType(node.Type) {
v.addError(field+".type", "node type is not supported by the server runtime: "+node.Type)
continue
}
switch node.Type {
case registry.NodeTypeStart:
v.startNodeIDs = append(v.startNodeIDs, node.ID)
case registry.NodeTypeEnd:
v.endNodeIDs = append(v.endNodeIDs, node.ID)
}
}
if len(v.startNodeIDs) != 1 {
v.addError("nodes", "workflow must contain exactly one start node")
}
if len(v.endNodeIDs) == 0 {
v.addError("nodes", "workflow must contain at least one end node")
}
}
func (v *definitionValidator) validateEdges() {
for index, edge := range v.def.Edges {
source := strings.TrimSpace(edge.SourceNodeID)
target := strings.TrimSpace(edge.TargetNodeID)
field := fmt.Sprintf("edges[%d]", index)
if source == "" {
v.addError(field+".sourceNodeID", "edge source node is required")
} else if _, ok := v.nodesByID[source]; !ok {
v.addError(field+".sourceNodeID", "edge source node does not exist: "+source)
}
if target == "" {
v.addError(field+".targetNodeID", "edge target node is required")
} else if _, ok := v.nodesByID[target]; !ok {
v.addError(field+".targetNodeID", "edge target node does not exist: "+target)
}
if source != "" && target != "" {
v.outgoing[source] = append(v.outgoing[source], target)
v.incoming[target] = append(v.incoming[target], source)
}
}
}
func (v *definitionValidator) validateReachability() {
entryNodeID := v.entryNodeID()
if entryNodeID == "" {
return
}
if _, ok := v.nodesByID[entryNodeID]; !ok {
return
}
reachable := make(map[string]struct{}, len(v.nodesByID))
queue := []string{entryNodeID}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if _, exists := reachable[current]; exists {
continue
}
reachable[current] = struct{}{}
for _, target := range v.outgoing[current] {
if _, exists := reachable[target]; !exists {
queue = append(queue, target)
}
}
}
for id := range v.nodesByID {
if _, ok := reachable[id]; !ok {
v.addError("nodes", "node is not reachable from entry node: "+id)
}
}
}
func (v *definitionValidator) validateConfirmationGuards() {
for id, node := range v.nodesByID {
spec, ok := v.registry.Get(node.Type)
if !ok || !spec.RequiresConfirmationPredecessor {
continue
}
if !v.hasConfirmationPredecessor(id, make(map[string]struct{})) {
v.addError("nodes."+id, node.Type+" requires human_confirm before execution")
}
v.validateConfirmedInput(id, node)
}
}
func (v *definitionValidator) validateConfirmedInput(nodeID string, node dsl.Node) {
value, ok := node.Data.InputsValues["confirmed"]
sourceNodeID, sourceField, refOK := value.Ref()
field := "nodes." + nodeID + ".data.inputsValues.confirmed"
if !ok || !refOK || strings.TrimSpace(sourceNodeID) == "" || strings.TrimSpace(sourceField) == "" {
v.addError(field, "confirmed input must come from human_confirm.confirmed")
return
}
sourceNode, ok := v.nodesByID[sourceNodeID]
if !ok {
return
}
if sourceNode.Type != registry.NodeTypeHumanConfirm || strings.TrimSpace(sourceField) != "confirmed" {
v.addError(field, "confirmed input must come from human_confirm.confirmed")
}
}
func (v *definitionValidator) validateVariableMappings() {
for id, node := range v.nodesByID {
spec, ok := v.registry.Get(node.Type)
if !ok {
continue
}
for _, input := range spec.InputSchema {
if !input.Required {
continue
}
value, ok := node.Data.InputsValues[input.Name]
if !ok {
v.addError("nodes."+id+".data.inputsValues."+input.Name, "required input mapping is missing: "+input.Name)
continue
}
v.validateInputValue(id, input, value)
}
for inputName, value := range node.Data.InputsValues {
if _, ok := findInputSpec(spec.InputSchema, inputName); ok {
continue
}
sourceNodeID, sourceField, refOK := value.Ref()
if !refOK {
continue
}
sourceNode, sourceOK := v.nodesByID[strings.TrimSpace(sourceNodeID)]
if !sourceOK {
v.addError("nodes."+id+".data.inputsValues."+inputName, "input source node does not exist: "+sourceNodeID)
continue
}
sourceSpec, sourceSpecOK := v.registry.Get(sourceNode.Type)
if !sourceSpecOK {
continue
}
if _, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField); !ok {
v.addError("nodes."+id+".data.inputsValues."+inputName, "input source field does not exist: "+sourceNodeID+"."+sourceField)
}
}
}
}
func (v *definitionValidator) validateInputValue(nodeID string, input registry.VariableSpec, value dsl.Value) {
sourceNodeID, sourceField, ok := value.Ref()
if !ok {
if value.Type == dsl.ValueTypeConstant || value.Type == dsl.ValueTypeTemplate {
return
}
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input mapping source is required")
return
}
sourceNodeID = strings.TrimSpace(sourceNodeID)
sourceField = strings.TrimSpace(sourceField)
sourceNode, ok := v.nodesByID[sourceNodeID]
if !ok {
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input source node does not exist: "+sourceNodeID)
return
}
if !v.hasPath(sourceNodeID, nodeID, make(map[string]struct{})) {
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input source node is not available before current node: "+sourceNodeID)
return
}
sourceSpec, ok := v.registry.Get(sourceNode.Type)
if !ok {
return
}
output, ok := findOutputSpec(sourceSpec.OutputSchema, sourceField)
if !ok {
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, "input source field does not exist: "+sourceNodeID+"."+sourceField)
return
}
if !variableTypesCompatible(input.Type, output.Type) {
v.addError("nodes."+nodeID+".data.inputsValues."+input.Name, fmt.Sprintf("input type mismatch: %s expects %s but %s.%s is %s", input.Name, input.Type, sourceNodeID, sourceField, output.Type))
}
}
func (v *definitionValidator) validateConditions() {
for index, node := range v.def.Nodes {
if strings.TrimSpace(node.Type) != registry.NodeTypeCondition {
continue
}
if rawConditions, ok := node.Data.Extra["conditions"]; ok {
v.validateFlowGramConditions(index, node, rawConditions)
continue
}
field := fmt.Sprintf("nodes[%d].config.branches", index)
config := dsl.ConditionConfig{}
if len(node.Data.Config) > 0 {
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
v.addError(field, "condition branches config must be valid JSON")
continue
}
}
if len(config.Branches) == 0 {
v.addError(field, "condition node must include at least one branch")
continue
}
defaultCount := 0
seenBranchIDs := make(map[string]struct{}, len(config.Branches))
for branchIndex, branch := range config.Branches {
branchField := fmt.Sprintf("%s[%d]", field, branchIndex)
branchID := strings.TrimSpace(branch.ID)
if branchID == "" {
v.addError(branchField+".id", "condition branch id is required")
} else if _, exists := seenBranchIDs[branchID]; exists {
v.addError(branchField+".id", "duplicate condition branch id: "+branchID)
}
seenBranchIDs[branchID] = struct{}{}
targetNodeID := strings.TrimSpace(branch.TargetNodeID)
if targetNodeID == "" {
v.addError(branchField+".targetNodeId", "condition branch target node is required")
} else if _, ok := v.nodesByID[targetNodeID]; !ok {
v.addError(branchField+".targetNodeId", "condition branch target node does not exist: "+targetNodeID)
}
if !v.hasConditionBranchEdge(strings.TrimSpace(node.ID), targetNodeID, branchID) {
v.addError(branchField+".targetNodeId", "condition branch target must have an outgoing edge: "+targetNodeID)
}
if branch.Default {
defaultCount++
if branch.Condition != nil {
v.addError(branchField+".condition", "default condition branch must not define a condition")
}
if branchIndex != len(config.Branches)-1 {
v.addError(branchField, "default condition branch must be last")
}
continue
}
v.validateCondition(branchField+".condition", strings.TrimSpace(node.ID), branch.Condition)
}
if defaultCount != 1 {
v.addError(field, "condition node must include exactly one default branch")
}
}
}
func (v *definitionValidator) validateFlowGramConditions(index int, node dsl.Node, raw json.RawMessage) {
field := fmt.Sprintf("nodes[%d].data.conditions", index)
var conditions []dsl.FlowGramConditionItem
if err := json.Unmarshal(raw, &conditions); err != nil {
v.addError(field, "condition data must be valid JSON")
return
}
if len(conditions) == 0 {
v.addError(field, "condition node must include at least one condition")
return
}
seenKeys := make(map[string]struct{}, len(conditions))
for conditionIndex, item := range conditions {
itemField := fmt.Sprintf("%s[%d]", field, conditionIndex)
key := strings.TrimSpace(item.Key)
if key == "" {
v.addError(itemField+".key", "condition key is required")
} else if _, exists := seenKeys[key]; exists {
v.addError(itemField+".key", "duplicate condition key: "+key)
}
seenKeys[key] = struct{}{}
if !v.hasConditionPortEdge(strings.TrimSpace(node.ID), key) {
v.addError(itemField+".key", "condition output port must have an outgoing edge: "+key)
}
v.validateFlowGramCondition(itemField+".value", strings.TrimSpace(node.ID), item.Value)
}
if !v.hasConditionPortEdge(strings.TrimSpace(node.ID), "else") {
v.addError(field, "condition else port must have an outgoing edge")
}
}
func (v *definitionValidator) validateFlowGramCondition(field string, sourceNodeID string, condition dsl.FlowGramCondition) {
operator := strings.TrimSpace(condition.Operator)
if !isSupportedConditionOperator(operator) {
v.addError(field+".operator", "unsupported condition operator: "+operator)
return
}
sourceSelectorNodeID, sourceField, ok := condition.Left.Ref()
sourceSelectorNodeID = strings.TrimSpace(sourceSelectorNodeID)
sourceField = strings.TrimSpace(sourceField)
if !ok || sourceSelectorNodeID == "" || sourceField == "" {
v.addError(field+".left", "condition left variable is required")
return
}
if _, exists := v.nodesByID[sourceSelectorNodeID]; !exists {
v.addError(field+".left", "condition source node does not exist: "+sourceSelectorNodeID)
return
}
if sourceNodeID != "" && !v.hasPath(sourceSelectorNodeID, sourceNodeID, make(map[string]struct{})) && sourceSelectorNodeID != sourceNodeID {
v.addError(field+".left", "condition source node is not available before branch: "+sourceSelectorNodeID)
}
if !conditionOperatorWithoutRight(operator) && condition.Right.Type == "" {
v.addError(field+".right", "condition comparison value is required")
}
}
func (v *definitionValidator) hasConditionPortEdge(sourceID string, sourcePortID string) bool {
for _, edge := range v.def.Edges {
if strings.TrimSpace(edge.SourceNodeID) == sourceID &&
strings.TrimSpace(edge.SourcePortID) == sourcePortID {
return true
}
}
return false
}
func (v *definitionValidator) validateKnowledgeRetrieveConfigs() {
for index, node := range v.def.Nodes {
if strings.TrimSpace(node.Type) != registry.NodeTypeKnowledgeRetrieve {
continue
}
field := fmt.Sprintf("nodes[%d].config.knowledgeBaseIds", index)
ids, ok := readKnowledgeBaseIDsFromConfig(node.Data.Config)
if !ok || len(ids) == 0 {
v.addError(field, "知识检索节点需要选择至少一个知识库")
continue
}
for _, id := range ids {
if id <= 0 {
v.addError(field, "知识库 ID 必须大于 0")
break
}
}
}
}
func readKnowledgeBaseIDsFromConfig(raw json.RawMessage) ([]int64, bool) {
if len(raw) == 0 {
return nil, false
}
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, false
}
rawIDs, ok := cfg["knowledgeBaseIds"]
if !ok {
return nil, false
}
values, ok := rawIDs.([]any)
if !ok {
return nil, false
}
ret := make([]int64, 0, len(values))
for _, value := range values {
switch v := value.(type) {
case float64:
ret = append(ret, int64(v))
case int64:
ret = append(ret, v)
case int:
ret = append(ret, int64(v))
default:
return nil, false
}
}
return ret, true
}
func (v *definitionValidator) validateCondition(field string, sourceNodeID string, condition *dsl.Condition) {
if condition == nil {
v.addError(field, "condition branch condition is required")
return
}
operator := strings.TrimSpace(condition.Operator)
if operator == "" && strings.TrimSpace(condition.Expression) != "" {
v.addError(field+".expression", "free-form condition expressions are not supported")
return
}
if !isSupportedConditionOperator(operator) {
v.addError(field+".operator", "unsupported condition operator: "+operator)
return
}
if condition.Left == nil {
v.addError(field+".left", "condition left variable is required")
return
}
sourceSelectorNodeID, sourceField, leftOK := condition.Left.Ref()
sourceSelectorNodeID = strings.TrimSpace(sourceSelectorNodeID)
sourceField = strings.TrimSpace(sourceField)
if !leftOK || sourceSelectorNodeID == "" || sourceField == "" {
v.addError(field+".left", "condition left variable is required")
return
}
sourceNode, ok := v.nodesByID[sourceSelectorNodeID]
if !ok {
v.addError(field+".left", "condition source node does not exist: "+sourceSelectorNodeID)
return
}
if sourceNodeID != "" && !v.hasPath(sourceSelectorNodeID, sourceNodeID, make(map[string]struct{})) && sourceSelectorNodeID != sourceNodeID {
v.addError(field+".left", "condition source node is not available before branch: "+sourceSelectorNodeID)
return
}
sourceSpec, ok := v.registry.Get(sourceNode.Type)
if !ok {
return
}
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")
}
}
func isSupportedConditionOperator(operator string) bool {
switch strings.TrimSpace(operator) {
case "eq", "equals", "neq", "not_equals", "contains", "exists", "not_exists", "truthy", "is_true", "falsy", "is_false", "gt", "gte", "lt", "lte":
return true
default:
return false
}
}
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
}
if _, seen := visiting[sourceID]; seen {
return false
}
visiting[sourceID] = struct{}{}
for _, next := range v.outgoing[sourceID] {
if next == targetID {
return true
}
if v.hasPath(next, targetID, visiting) {
return true
}
}
return false
}
func (v *definitionValidator) hasConditionBranchEdge(sourceID string, targetID string, sourcePortID string) bool {
if sourceID == "" || targetID == "" || sourcePortID == "" {
return true
}
for _, edge := range v.def.Edges {
if strings.TrimSpace(edge.SourceNodeID) == sourceID &&
strings.TrimSpace(edge.TargetNodeID) == targetID &&
strings.TrimSpace(edge.SourcePortID) == sourcePortID {
return true
}
}
return false
}
func (v *definitionValidator) entryNodeID() string {
if len(v.startNodeIDs) != 1 {
return ""
}
return v.startNodeIDs[0]
}
func findInputSpec(items []registry.VariableSpec, name string) (registry.VariableSpec, bool) {
name = strings.TrimSpace(name)
for _, item := range items {
if item.Name == name {
return item, true
}
}
return registry.VariableSpec{}, false
}
func findOutputSpec(items []registry.VariableSpec, name string) (registry.VariableSpec, bool) {
name = strings.TrimSpace(name)
for _, item := range items {
if item.Name == name {
return item, true
}
}
return registry.VariableSpec{}, false
}
func variableTypesCompatible(input registry.VariableType, output registry.VariableType) bool {
return input == registry.VariableTypeAny || output == registry.VariableTypeAny || input == output
}
func (v *definitionValidator) hasConfirmationPredecessor(nodeID string, visiting map[string]struct{}) bool {
if _, seen := visiting[nodeID]; seen {
return false
}
visiting[nodeID] = struct{}{}
for _, source := range v.incoming[nodeID] {
node, ok := v.nodesByID[source]
if !ok {
continue
}
if node.Type == registry.NodeTypeHumanConfirm {
return true
}
if v.hasConfirmationPredecessor(source, visiting) {
return true
}
}
return false
}
func (v *definitionValidator) addError(field string, message string) {
v.errors = append(v.errors, Error{Field: field, Message: message})
}

View File

@@ -0,0 +1,469 @@
package validator_test
import (
"encoding/json"
"strings"
"testing"
"agent-desk/internal/ai/workflow/dsl"
"agent-desk/internal/ai/workflow/registry"
"agent-desk/internal/ai/workflow/validator"
)
func TestValidateDefinitionAcceptsMinimalFlowGramStyleFlow(t *testing.T) {
result := validator.ValidateDefinition(minimalDefinition(), registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected valid definition, got errors: %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsNodeMissingFromServerRuntime(t *testing.T) {
def := dsl.Definition{
Nodes: []dsl.Node{
node("start_1", "start", nil, nil),
node("http_1", "http", nil, nil),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{edge("start_1", "http_1"), edge("http_1", "end_1")},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid || !hasValidationMessage(result, "not supported by the server runtime") {
t.Fatalf("expected unsupported-runtime error, got %#v", result.Errors)
}
}
func TestValidateDefinitionAcceptsOfficialFlowGramCondition(t *testing.T) {
def := dsl.Definition{
Nodes: []dsl.Node{
node("start_0", "start", nil, nil),
{
ID: "condition_0",
Type: "condition",
Data: dsl.NodeData{
Title: "Condition",
Extra: map[string]json.RawMessage{
"conditions": mustJSON([]dsl.FlowGramConditionItem{
{
Key: "if_0",
Value: dsl.FlowGramCondition{
Left: dsl.RefValue("start_0", "query"),
Operator: "contains",
Right: dsl.ConstantValue("hello"),
},
},
}),
},
},
},
node("matched_end", "end", nil, nil),
node("else_end", "end", nil, nil),
},
Edges: []dsl.Edge{
edge("start_0", "condition_0"),
portEdge("condition_0", "matched_end", "if_0"),
portEdge("condition_0", "else_end", "else"),
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected official FlowGram condition to be valid, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsMissingStart(t *testing.T) {
def := minimalDefinition()
def.Nodes = []dsl.Node{
node("reply_1", "send_reply", inputs("replyText", dsl.RefValue("start_1", "userMessage")), nil),
node("end_1", "end", nil, nil),
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected missing start to be invalid")
}
if !hasValidationMessage(result, "exactly one start node") {
t.Fatalf("expected start error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsMissingRequiredInputValue(t *testing.T) {
def := minimalDefinition()
def.Nodes[1].Data.InputsValues = nil
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected missing required input mapping to be invalid")
}
if !hasValidationMessage(result, "required input mapping is missing") {
t.Fatalf("expected required-input error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConstantConfirmationForHighRiskNode(t *testing.T) {
def := dsl.Definition{
Nodes: []dsl.Node{
node("start_1", "start", nil, nil),
node("confirm_1", "human_confirm", inputs("prompt", dsl.ConstantValue("请确认")), nil),
node("create_1", "create_ticket", map[string]dsl.Value{
"ticketDraft": dsl.ConstantValue(map[string]any{"title": "测试", "description": "测试描述"}),
"confirmed": dsl.ConstantValue(false),
}, nil),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
edge("start_1", "confirm_1"),
edge("confirm_1", "create_1"),
edge("create_1", "end_1"),
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid || !hasValidationMessage(result, "confirmed input must come from human_confirm.confirmed") {
t.Fatalf("expected confirmation-source error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownInputSourceNode(t *testing.T) {
def := minimalDefinition()
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("missing_1", "replyText")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unknown input source node to be invalid")
}
if !hasValidationMessage(result, "input source node does not exist") {
t.Fatalf("expected source-node error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnavailableInputSourceNode(t *testing.T) {
def := minimalDefinition()
def.Nodes = append(def.Nodes, node("late_1", "llm_reply", inputs("userMessage", dsl.RefValue("reply_1", "sent")), nil))
def.Edges = append(def.Edges, edge("reply_1", "late_1"))
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("late_1", "replyText")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected downstream input source to be invalid")
}
if !hasValidationMessage(result, "input source node is not available before current node") {
t.Fatalf("expected source availability error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownInputSourceField(t *testing.T) {
def := minimalDefinition()
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("start_1", "missing")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unknown input source field to be invalid")
}
if !hasValidationMessage(result, "input source field does not exist") {
t.Fatalf("expected source-field error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsIncompatibleInputType(t *testing.T) {
def := minimalDefinition()
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("start_1", "conversationId")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected incompatible input type to be invalid")
}
if !hasValidationMessage(result, "input type mismatch") {
t.Fatalf("expected type-mismatch error, got %#v", result.Errors)
}
}
func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
def := dsl.Definition{
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
node("start_1", "start", nil, nil),
node("draft_1", "prepare_ticket_draft", inputs("issue", dsl.RefValue("start_1", "userMessage")), nil),
node("confirm_1", "human_confirm", inputs("prompt", dsl.RefValue("start_1", "userMessage")), nil),
node("create_1", "create_ticket", map[string]dsl.Value{
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
"confirmed": dsl.RefValue("confirm_1", "confirmed"),
}, nil),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
edge("start_1", "draft_1"),
edge("draft_1", "confirm_1"),
edge("confirm_1", "create_1"),
edge("create_1", "end_1"),
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected confirmed create_ticket to be valid, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T) {
def := minimalDefinition()
def.Nodes = []dsl.Node{
node("start_1", "start", nil, nil),
node("draft_1", "prepare_ticket_draft", inputs("issue", dsl.RefValue("start_1", "userMessage")), nil),
node("create_1", "create_ticket", map[string]dsl.Value{
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
"confirmed": dsl.RefValue("start_1", "userMessage"),
}, nil),
node("end_1", "end", nil, nil),
}
def.Edges = []dsl.Edge{
edge("start_1", "draft_1"),
edge("draft_1", "create_1"),
edge("create_1", "end_1"),
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected confirmed input from non-confirm node to be invalid")
}
if !hasValidationMessage(result, "confirmed input must come from human_confirm.confirmed") {
t.Fatalf("expected confirmed-source error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsHandoffWithoutConfirmedInput(t *testing.T) {
def := dsl.Definition{
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
node("start_1", "start", nil, nil),
node("confirm_1", "human_confirm", inputs("prompt", dsl.RefValue("start_1", "userMessage")), nil),
node("handoff_1", "handoff_to_human", inputs("reason", dsl.RefValue("start_1", "userMessage")), nil),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
edge("start_1", "confirm_1"),
edge("confirm_1", "handoff_1"),
edge("handoff_1", "end_1"),
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected handoff without confirmed input to be invalid")
}
if !hasValidationMessage(result, "required input mapping is missing: confirmed") {
t.Fatalf("expected missing confirmed input error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConditionBranchTargetWithoutEdge(t *testing.T) {
def := conditionDefinition()
def.Edges = []dsl.Edge{edge("start_1", "condition_1")}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected condition branch target without edge to be invalid")
}
if !hasValidationMessage(result, "condition branch target must have an outgoing edge") {
t.Fatalf("expected branch edge error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConditionBranchTargetWithoutPortEdge(t *testing.T) {
def := conditionDefinition()
def.Edges = []dsl.Edge{
edge("start_1", "condition_1"),
edge("condition_1", "end_1"),
portEdge("condition_1", "end_1", "default"),
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected condition branch target without matching port edge to be invalid")
}
if !hasValidationMessage(result, "condition branch target must have an outgoing edge") {
t.Fatalf("expected branch port edge error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownConditionVariable(t *testing.T) {
def := conditionDefinition()
var config dsl.ConditionConfig
if err := json.Unmarshal(def.Nodes[1].Data.Config, &config); err != nil {
t.Fatalf("unmarshal condition config: %v", err)
}
config.Branches[0].Condition.Left = &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{"start_1", "missing"}}
def.Nodes[1].Data.Config = mustJSON(config)
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unknown condition variable to be invalid")
}
if !hasValidationMessage(result, "condition source field does not exist") {
t.Fatalf("expected condition variable error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsKnowledgeRetrieveWithoutKnowledgeBases(t *testing.T) {
def := knowledgeRetrieveDefinition(nil)
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected knowledge_retrieve without knowledge bases to be invalid")
}
if !hasValidationMessage(result, "需要选择至少一个知识库") {
t.Fatalf("expected missing knowledge base error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsKnowledgeRetrieveWithInvalidKnowledgeBaseID(t *testing.T) {
def := knowledgeRetrieveDefinition(map[string]any{"knowledgeBaseIds": []int64{0, -1}})
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected invalid knowledge base id to be invalid")
}
if !hasValidationMessage(result, "知识库 ID 必须大于 0") {
t.Fatalf("expected invalid knowledge base id error, got %#v", result.Errors)
}
}
func TestValidateDefinitionAcceptsKnowledgeRetrieveWithKnowledgeBases(t *testing.T) {
def := knowledgeRetrieveDefinition(map[string]any{"knowledgeBaseIds": []int64{1, 2}})
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected knowledge_retrieve with knowledge bases to be valid, got %#v", result.Errors)
}
}
func minimalDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
node("start_1", "start", nil, nil),
node("reply_1", "send_reply", inputs("replyText", dsl.RefValue("start_1", "userMessage")), map[string]any{"text": "hello"}),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
edge("start_1", "reply_1"),
edge("reply_1", "end_1"),
},
}
}
func knowledgeRetrieveDefinition(config any) dsl.Definition {
return dsl.Definition{
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
node("start_1", "start", nil, nil),
node("retrieve_1", "knowledge_retrieve", inputs("query", dsl.RefValue("start_1", "userMessage")), config),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
edge("start_1", "retrieve_1"),
edge("retrieve_1", "end_1"),
},
}
}
func conditionDefinition() dsl.Definition {
conditionConfig := dsl.ConditionConfig{
Branches: []dsl.ConditionBranch{
{
ID: "hello",
Name: "Hello",
TargetNodeID: "end_1",
Condition: &dsl.Condition{
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{"start_1", "userMessage"}},
Operator: "eq",
Right: "hello",
},
},
{
ID: "default",
Name: "Default",
TargetNodeID: "end_1",
Default: true,
},
},
}
return dsl.Definition{
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
node("start_1", "start", nil, nil),
node("condition_1", "condition", nil, conditionConfig),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
edge("start_1", "condition_1"),
portEdge("condition_1", "end_1", "hello"),
portEdge("condition_1", "end_1", "default"),
},
}
}
func node(id string, nodeType string, inputValues map[string]dsl.Value, config any) dsl.Node {
return dsl.Node{
ID: id,
Type: nodeType,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 0, Y: 0}},
Data: dsl.NodeData{
Title: nodeType,
Config: mustJSON(config),
InputsValues: inputValues,
},
}
}
func edge(source string, target string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
}
func portEdge(source string, target string, sourcePortID string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: sourcePortID}
}
func inputs(name string, value dsl.Value) map[string]dsl.Value {
return map[string]dsl.Value{name: value}
}
func mustJSON(value any) json.RawMessage {
if value == nil {
return nil
}
raw, err := json.Marshal(value)
if err != nil {
panic(err)
}
return raw
}
func hasValidationMessage(result validator.Result, want string) bool {
for _, item := range result.Errors {
if strings.Contains(item.Message, want) {
return true
}
}
return false
}