From fa0010e8beb11eaf33d255780ade5a847804f7ea Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 30 Jun 2026 17:26:28 +0800 Subject: [PATCH] feat: enhance ticket draft workflow with new routing and confirmation prompts --- internal/ai/runtime/workflow/executor.go | 79 ++++++++++++++--- internal/ai/runtime/workflow/executor_test.go | 86 +++++++++++++++++++ internal/ai/workflow/registry/registry.go | 5 ++ .../ai_agent_workflow_service_test.go | 34 ++++++++ internal/services/ai_workflow_service.go | 35 ++++++-- 5 files changed, 220 insertions(+), 19 deletions(-) diff --git a/internal/ai/runtime/workflow/executor.go b/internal/ai/runtime/workflow/executor.go index dacd607..6b014e1 100644 --- a/internal/ai/runtime/workflow/executor.go +++ b/internal/ai/runtime/workflow/executor.go @@ -27,6 +27,7 @@ import ( const maxWorkflowSteps = 128 var workflowHTMLTagPattern = regexp.MustCompile(`<[^>]+>`) +var workflowTemplateVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`) type Input struct { Definition dsl.Definition @@ -624,18 +625,28 @@ func (e *Executor) executePrepareTicketDraft(ctx context.Context, state *runStat return err } state.setNodeVars(node.ID, map[string]any{ - "ticketDraft": map[string]any{ - "ready": result.Ready, - "title": strings.TrimSpace(result.Title), - "description": strings.TrimSpace(result.Description), - "missingFields": result.MissingFields, - "followUpQuestions": result.FollowUpQuestions, - "conversationFacts": result.ConversationFacts, - }, + "ticketDraft": ticketDraftWorkflowOutput(result), + "ready": result.Ready, + "title": strings.TrimSpace(result.Title), + "description": strings.TrimSpace(result.Description), + "missingFields": result.MissingFields, + "followUpQuestions": result.FollowUpQuestions, + "conversationFacts": result.ConversationFacts, }) return nil } +func ticketDraftWorkflowOutput(result graphs.PrepareTicketDraftResult) map[string]any { + return map[string]any{ + "ready": result.Ready, + "title": strings.TrimSpace(result.Title), + "description": strings.TrimSpace(result.Description), + "missingFields": result.MissingFields, + "followUpQuestions": result.FollowUpQuestions, + "conversationFacts": result.ConversationFacts, + } +} + func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runState, node dsl.Node) error { userMessage := strings.TrimSpace(toString(state.resolveInput(node, "userMessage"))) input := graphs.AnalyzeConversationInput{ @@ -761,7 +772,7 @@ func (e *Executor) executeAnswerabilityGate(state *runState, node dsl.Node) erro func (e *Executor) executeLLMReply(ctx context.Context, state *runState, node dsl.Node) error { if staticReply := strings.TrimSpace(readStringConfig(node.Data.Config, "staticReply")); staticReply != "" { - state.setNodeVars(node.ID, map[string]any{"replyText": staticReply}) + state.setNodeVars(node.ID, map[string]any{"replyText": renderWorkflowTemplate(staticReply, state.resolvedInputs(node))}) return nil } userPrompt := strings.TrimSpace(toString(state.resolveInput(node, "userMessage"))) @@ -942,11 +953,16 @@ func (s *runState) resolveInput(node dsl.Node, inputName string) any { return s.resolveValue(value) } -func (s *runState) nodeInputPreview(node dsl.Node) map[string]any { +func (s *runState) resolvedInputs(node dsl.Node) map[string]any { inputs := make(map[string]any, len(node.Data.InputsValues)) for name, value := range node.Data.InputsValues { inputs[name] = s.resolveValue(value) } + return inputs +} + +func (s *runState) nodeInputPreview(node dsl.Node) map[string]any { + inputs := s.resolvedInputs(node) ret := map[string]any{ "inputs": inputs, } @@ -961,6 +977,49 @@ func (s *runState) nodeInputPreview(node dsl.Node) map[string]any { return ret } +func renderWorkflowTemplate(template string, values map[string]any) string { + if strings.TrimSpace(template) == "" || len(values) == 0 { + return template + } + return workflowTemplateVariablePattern.ReplaceAllStringFunc(template, func(match string) string { + parts := workflowTemplateVariablePattern.FindStringSubmatch(match) + if len(parts) < 2 { + return match + } + name := strings.TrimSpace(parts[1]) + value, ok := values[name] + if !ok { + return "" + } + return workflowTemplateValueString(value) + }) +} + +func workflowTemplateValueString(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(v) + case []string: + return strings.TrimSpace(strings.Join(v, "\n")) + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + if text := workflowTemplateValueString(item); text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n") + default: + raw, err := json.Marshal(v) + if err != nil { + return strings.TrimSpace(fmt.Sprint(v)) + } + return strings.TrimSpace(string(raw)) + } +} + func (s *runState) nodeOutputPreview(nodeID string) map[string]any { ret := map[string]any{ "outputs": s.vars[nodeID], diff --git a/internal/ai/runtime/workflow/executor_test.go b/internal/ai/runtime/workflow/executor_test.go index 9893d1f..f6adc8f 100644 --- a/internal/ai/runtime/workflow/executor_test.go +++ b/internal/ai/runtime/workflow/executor_test.go @@ -239,6 +239,58 @@ func TestExecutorPrepareTicketDraftOutputsDraftVariable(t *testing.T) { assertPath(t, result.NodePath, []string{"start_1", "draft_1", "draft_route_1", "ready_end"}) } +func TestExecutorPrepareTicketDraftRoutesIncompleteDraftToFollowUp(t *testing.T) { + db := setupWorkflowExecutorHandoffDB(t) + aiAgent := createWorkflowExecutorHandoffAIAgent(t, db, "1") + conversation := createWorkflowExecutorHandoffConversation(t, db, aiAgent.ID) + userMessage := createWorkflowExecutorCustomerMessage(t, db, conversation.ID, "") + + result, err := NewExecutor().Execute(context.Background(), Input{ + Definition: ticketDraftReadyWorkflowDefinition(), + Conversation: conversation, + UserMessage: userMessage, + AIAgent: aiAgent, + }) + if err != nil { + t.Fatalf("execute workflow: %v", err) + } + if result.Interrupted { + t.Fatalf("expected incomplete draft to avoid confirmation interrupt") + } + if !strings.Contains(result.ReplyText, "Please provide") { + t.Fatalf("expected follow-up questions in reply, got %q", result.ReplyText) + } + assertPath(t, result.NodePath, []string{"start_1", "draft_1", "draft_route_1", "followup_1", "send_followup_1", "end_1"}) +} + +func TestExecutorTicketConfirmationPromptIncludesDraftTitleAndDescription(t *testing.T) { + db := setupWorkflowExecutorHandoffDB(t) + aiAgent := createWorkflowExecutorHandoffAIAgent(t, db, "1") + conversation := createWorkflowExecutorHandoffConversation(t, db, aiAgent.ID) + userMessage := createWorkflowExecutorCustomerMessage(t, db, conversation.ID, "订单支付失败,请帮我登记工单") + + result, err := NewExecutor().Execute(context.Background(), Input{ + Definition: ticketDraftReadyWorkflowDefinition(), + Conversation: conversation, + UserMessage: userMessage, + AIAgent: aiAgent, + }) + if err != nil { + t.Fatalf("execute workflow: %v", err) + } + if !result.Interrupted { + t.Fatalf("expected ready draft to interrupt for confirmation") + } + if len(result.Interrupts) != 1 { + t.Fatalf("expected one interrupt, got %#v", result.Interrupts) + } + prompt := result.Interrupts[0].InfoPreview + if !strings.Contains(prompt, "订单支付失败") || !strings.Contains(prompt, "Issue: 订单支付失败") { + t.Fatalf("expected confirmation prompt to include draft title and description, got %q", prompt) + } + assertPath(t, result.NodePath, []string{"start_1", "draft_1", "draft_route_1", "prompt_1", "confirm_1"}) +} + func TestExecutorPolicyFirstWorkflowRoutesGreetingToDirectReply(t *testing.T) { result, err := NewExecutor().Execute(context.Background(), Input{ Definition: policyFirstWorkflowDefinition(), @@ -512,6 +564,40 @@ func conditionalReplyDefinition() dsl.Definition { ) } +func ticketDraftReadyWorkflowDefinition() dsl.Definition { + return wfTestDefinition( + []dsl.Node{ + wfTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil), + wfTestNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "Draft", wfTestInputs("issue", "start_1", "userMessage"), nil), + wfTestNode("draft_route_1", workflowregistry.NodeTypeCondition, "Draft Route", nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + wfTestConditionBranch("ready", "Ready", "prompt_1", "draft_1", "ready", "is_true", nil), + {ID: "default", Name: "Need More Info", TargetNodeID: "followup_1", Default: true}, + }}), + wfTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", map[string]dsl.Value{ + "userMessage": dsl.RefValue("start_1", "userMessage"), + "ticketTitle": dsl.RefValue("draft_1", "title"), + "ticketDescription": dsl.RefValue("draft_1", "description"), + }, map[string]any{"staticReply": "请确认创建工单:\n标题:{{ticketTitle}}\n描述:{{ticketDescription}}"}), + wfTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", wfTestInputs("prompt", "prompt_1", "replyText"), nil), + wfTestNode("followup_1", workflowregistry.NodeTypeLLMReply, "Follow Up", map[string]dsl.Value{ + "userMessage": dsl.RefValue("start_1", "userMessage"), + "followUpQuestions": dsl.RefValue("draft_1", "followUpQuestions"), + }, map[string]any{"staticReply": "{{followUpQuestions}}"}), + wfTestNode("send_followup_1", workflowregistry.NodeTypeSendReply, "Send Follow Up", wfTestInputs("replyText", "followup_1", "replyText"), nil), + wfTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil), + }, + []dsl.Edge{ + wfTestEdge("start_1", "draft_1", "edge_start_draft"), + wfTestEdge("draft_1", "draft_route_1", "edge_draft_route"), + wfTestEdge("draft_route_1", "prompt_1", "ready"), + wfTestEdge("draft_route_1", "followup_1", "default"), + wfTestEdge("prompt_1", "confirm_1", "edge_prompt_confirm"), + wfTestEdge("followup_1", "send_followup_1", "edge_followup_send"), + wfTestEdge("send_followup_1", "end_1", "edge_followup_end"), + }, + ) +} + func createTicketWorkflowDefinition() dsl.Definition { return wfTestDefinition( []dsl.Node{ diff --git a/internal/ai/workflow/registry/registry.go b/internal/ai/workflow/registry/registry.go index b3f1bab..f9cb5dc 100644 --- a/internal/ai/workflow/registry/registry.go +++ b/internal/ai/workflow/registry/registry.go @@ -200,6 +200,11 @@ func DefaultRegistry() *Registry { }, OutputSchema: []VariableSpec{ output("ticketDraft", "工单草稿", VariableTypeObject, "根据会话内容整理出的待确认工单草稿。"), + output("ready", "草稿就绪", VariableTypeBoolean, "工单草稿是否已具备创建所需的关键信息。"), + output("title", "工单标题", VariableTypeString, "工单草稿标题。"), + output("description", "工单描述", VariableTypeString, "工单草稿描述。"), + output("missingFields", "缺失字段", VariableTypeStringArray, "仍需客户补充的字段列表。"), + output("followUpQuestions", "追问问题", VariableTypeStringArray, "用于补齐工单信息的追问问题。"), }, }, NodeSpec{ diff --git a/internal/services/ai_agent_workflow_service_test.go b/internal/services/ai_agent_workflow_service_test.go index 94bcdfd..d909c8d 100644 --- a/internal/services/ai_agent_workflow_service_test.go +++ b/internal/services/ai_agent_workflow_service_test.go @@ -2,6 +2,7 @@ package services import ( "encoding/json" + "strings" "testing" "agent-desk/internal/ai/workflow/dsl" @@ -84,12 +85,15 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeSendReply, "eq", "direct_reply") assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeHandoffToHuman, "eq", "handoff_to_human") assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypePrepareTicketDraft, "eq", "prepare_ticket") + assertConditionBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_confirm_prompt_1", "is_true", nil) + assertDefaultBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_followup_reply_1") assertConditionBranchToNodeID(t, stored, "answerability_route_1", "reply_1", "eq", "answerable") assertDefaultBranchToNodeID(t, stored, "answerability_route_1", "fallback_reply_1") if !workflowEdgeExists(stored, "create_ticket_1", "ticket_result_reply_1") { t.Fatalf("expected create_ticket to flow into a customer-visible result reply") } assertConditionBranchesHavePortEdges(t, stored, "policy_route_1") + assertConditionBranchesHavePortEdges(t, stored, "ticket_draft_route_1") assertConditionBranchesHavePortEdges(t, stored, "ticket_confirm_route_1") assertConditionBranchesHavePortEdges(t, stored, "answerability_route_1") assertConditionBranchOrder(t, stored, "policy_route_1", []string{ @@ -135,6 +139,25 @@ func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionIsValid(t *testing.T) { } } +func TestAIWorkflowServiceDefaultAgentWorkflowTicketPromptIncludesDraftFields(t *testing.T) { + definition := AIWorkflowService.DefaultAgentWorkflowDefinition() + prompt := workflowNodeByID(t, definition, "ticket_confirm_prompt_1") + if _, ok := prompt.Data.InputsValues["ticketTitle"]; !ok { + t.Fatalf("expected ticket confirm prompt to map ticketTitle") + } + if _, ok := prompt.Data.InputsValues["ticketDescription"]; !ok { + t.Fatalf("expected ticket confirm prompt to map ticketDescription") + } + config := map[string]any{} + if err := json.Unmarshal(prompt.Data.Config, &config); err != nil { + t.Fatalf("unmarshal prompt config: %v", err) + } + staticReply, _ := config["staticReply"].(string) + if !strings.Contains(staticReply, "{{ticketTitle}}") || !strings.Contains(staticReply, "{{ticketDescription}}") { + t.Fatalf("expected prompt template to include ticket title and description, got %q", staticReply) + } +} + func TestAIWorkflowServiceDefaultAgentWorkflowLayoutDoesNotOverlap(t *testing.T) { definition := AIWorkflowService.DefaultAgentWorkflowDefinition() assertWorkflowLayoutDoesNotOverlap(t, definition) @@ -273,6 +296,17 @@ func nodeTypeByID(def dsl.Definition, nodeID string) string { return "" } +func workflowNodeByID(t *testing.T, def dsl.Definition, nodeID string) dsl.Node { + t.Helper() + for _, node := range def.Nodes { + if node.ID == nodeID { + return node + } + } + t.Fatalf("workflow node not found: %s", nodeID) + return dsl.Node{} +} + func assertConditionBranchToNodeType(t *testing.T, def dsl.Definition, sourceID string, targetType string, operator string, right any) { t.Helper() nodeTypes := workflowNodeTypeMap(def) diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index def3cec..e47a024 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -446,19 +446,32 @@ func defaultAgentWorkflowDefinition() dsl.Definition { workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 4320, 98.5, workflowInputs("replyText", "policy_1", "replyText"), nil), workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 2480, 0, nil, nil), workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 2020, 379, workflowInputs("issue", "start_1", "userMessage"), nil), - workflowNode("ticket_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认文案", 2480, 379, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "我已整理工单草稿。请回复“确认”创建工单,或回复“取消”放弃。"}), - workflowNode("ticket_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 2940, 379, workflowInputs("prompt", "ticket_confirm_prompt_1", "replyText"), nil), - workflowNode("ticket_confirm_route_1", workflowregistry.NodeTypeCondition, "建单确认分流", 3400, 329, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + workflowNode("ticket_draft_route_1", workflowregistry.NodeTypeCondition, "草稿就绪分流", 2480, 329, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + workflowConditionBranch("ready", "草稿完整", "ticket_confirm_prompt_1", "draft_ticket_1", "ready", "is_true", nil), + {ID: "default", Name: "补充信息", TargetNodeID: "ticket_followup_reply_1", Default: true}, + }}), + workflowNode("ticket_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认文案", 2940, 285.5, map[string]dsl.Value{ + "userMessage": dsl.RefValue("start_1", "userMessage"), + "ticketTitle": dsl.RefValue("draft_ticket_1", "title"), + "ticketDescription": dsl.RefValue("draft_ticket_1", "description"), + }, map[string]any{"staticReply": "我已整理工单草稿,请确认是否创建:\n标题:{{ticketTitle}}\n描述:{{ticketDescription}}\n请回复“确认”创建工单,或回复“取消”放弃。"}), + workflowNode("ticket_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 3400, 285.5, workflowInputs("prompt", "ticket_confirm_prompt_1", "replyText"), nil), + workflowNode("ticket_confirm_route_1", workflowregistry.NodeTypeCondition, "建单确认分流", 3860, 235.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ workflowConditionBranch("confirmed", "已确认", "create_ticket_1", "ticket_confirm_1", "confirmed", "is_true", nil), {ID: "default", Name: "取消或未确认", TargetNodeID: "ticket_cancel_reply_1", Default: true}, }}), - workflowNode("create_ticket_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 3860, 285.5, map[string]dsl.Value{ + workflowNode("create_ticket_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 4780, 192, map[string]dsl.Value{ "ticketDraft": dsl.RefValue("draft_ticket_1", "ticketDraft"), "confirmed": dsl.RefValue("ticket_confirm_1", "confirmed"), }, nil), - workflowNode("ticket_result_reply_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 4320, 285.5, workflowInputs("replyText", "create_ticket_1", "message"), nil), - workflowNode("ticket_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消建单提示", 3860, 472.5, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}), - workflowNode("send_ticket_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 4320, 472.5, workflowInputs("replyText", "ticket_cancel_reply_1", "replyText"), nil), + workflowNode("ticket_result_reply_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 5240, 192, workflowInputs("replyText", "create_ticket_1", "message"), nil), + workflowNode("ticket_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消建单提示", 4320, 379, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}), + workflowNode("send_ticket_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 4780, 379, workflowInputs("replyText", "ticket_cancel_reply_1", "replyText"), nil), + workflowNode("ticket_followup_reply_1", workflowregistry.NodeTypeLLMReply, "追问工单信息", 3860, 1033.5, map[string]dsl.Value{ + "userMessage": dsl.RefValue("start_1", "userMessage"), + "followUpQuestions": dsl.RefValue("draft_ticket_1", "followUpQuestions"), + }, map[string]any{"staticReply": "为了创建工单,还需要补充以下信息:\n{{followUpQuestions}}"}), + workflowNode("send_ticket_followup_1", workflowregistry.NodeTypeSendReply, "发送工单追问", 4780, 1033.5, workflowInputs("replyText", "ticket_followup_reply_1", "replyText"), nil), workflowNode("retrieve_1", workflowregistry.NodeTypeKnowledgeRetrieve, "知识检索", 2480, 753, workflowInputs("query", "start_1", "userMessage"), nil), workflowNode("answerability_1", workflowregistry.NodeTypeAnswerabilityGate, "可回答判断", 2940, 753, map[string]dsl.Value{ "userMessage": dsl.RefValue("start_1", "userMessage"), @@ -478,7 +491,7 @@ func defaultAgentWorkflowDefinition() dsl.Definition { "knowledgeItems": dsl.RefValue("retrieve_1", "items"), }, nil), workflowNode("send_fallback_1", workflowregistry.NodeTypeSendReply, "发送兜底", 4320, 846.5, workflowInputs("replyText", "fallback_reply_1", "replyText"), nil), - workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 4780, 472.5, nil, nil), + workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 5700, 472.5, nil, nil), }, Edges: []dsl.Edge{ workflowEdge("start_1", "understanding_1"), @@ -493,7 +506,9 @@ func defaultAgentWorkflowDefinition() dsl.Definition { workflowPortEdge("policy_route_1", "policy_reply_1", "default"), workflowEdge("policy_reply_1", "end_1"), workflowEdge("handoff_1", "handoff_end_1"), - workflowEdge("draft_ticket_1", "ticket_confirm_prompt_1"), + workflowEdge("draft_ticket_1", "ticket_draft_route_1"), + workflowPortEdge("ticket_draft_route_1", "ticket_confirm_prompt_1", "ready"), + workflowPortEdge("ticket_draft_route_1", "ticket_followup_reply_1", "default"), workflowEdge("ticket_confirm_prompt_1", "ticket_confirm_1"), workflowEdge("ticket_confirm_1", "ticket_confirm_route_1"), workflowPortEdge("ticket_confirm_route_1", "create_ticket_1", "confirmed"), @@ -502,6 +517,8 @@ func defaultAgentWorkflowDefinition() dsl.Definition { workflowEdge("ticket_result_reply_1", "end_1"), workflowEdge("ticket_cancel_reply_1", "send_ticket_cancel_1"), workflowEdge("send_ticket_cancel_1", "end_1"), + workflowEdge("ticket_followup_reply_1", "send_ticket_followup_1"), + workflowEdge("send_ticket_followup_1", "end_1"), workflowEdge("retrieve_1", "answerability_1"), workflowEdge("answerability_1", "answerability_route_1"), workflowPortEdge("answerability_route_1", "reply_1", "answerable"),