feat: enhance ticket draft workflow with new routing and confirmation prompts
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user