diff --git a/internal/ai/runtime/reply_handoff_test.go b/internal/ai/runtime/reply_handoff_test.go new file mode 100644 index 0000000..aa278c6 --- /dev/null +++ b/internal/ai/runtime/reply_handoff_test.go @@ -0,0 +1,104 @@ +package runtime + +import ( + "context" + "testing" + "time" + + "code.tczkiot.com/wlw/ai-agent/internal/models" + "code.tczkiot.com/wlw/ai-agent/internal/pkg/enums" + svc "code.tczkiot.com/wlw/ai-agent/internal/services" +) + +func TestExplicitHumanHandoffRequest(t *testing.T) { + for _, content := range []string{"转人工", "人工客服", "人工", "请转人工客服", "我要转人工", "麻烦帮我转接一下人工客服!", " 转 人工。 "} { + t.Run(content, func(t *testing.T) { + message := models.Message{Content: content, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText} + if !isExplicitHumanHandoffRequest(message) { + t.Fatal("explicit command was not recognized") + } + message.SenderType = enums.IMSenderTypeAI + if isExplicitHumanHandoffRequest(message) { + t.Fatal("AI message must not trigger handoff") + } + message.SenderType = enums.IMSenderTypeCustomer + message.MessageType = enums.IMMessageTypeImage + if isExplicitHumanHandoffRequest(message) { + t.Fatal("image content must not trigger handoff") + } + }) + } + for _, content := range []string{"", "不要转人工", "不用人工客服", "转人工是什么意思?", "怎么转人工", "有人在吗?", "他说转人工", "转人工失败怎么办", "如果不行再转人工", "请不要转人工", "“转人工”"} { + t.Run(content, func(t *testing.T) { + if isExplicitHumanHandoffRequest(models.Message{Content: content, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText}) { + t.Fatal("non-command must remain available for normal AI processing") + } + }) + } +} + +func TestTriggerReplyExplicitHandoffWithoutModel(t *testing.T) { + for _, scheduled := range []bool{true, false} { + name := "off_hours" + if scheduled { + name = "waiting_queue" + } + t.Run(name, func(t *testing.T) { + db := setupReplyCommitTestDB(t) + if err := db.AutoMigrate(&models.AgentTeam{}, &models.AgentTeamSchedule{}, &models.AgentProfile{}, &models.ConversationAssignment{}); err != nil { + t.Fatal(err) + } + agent := createReplyCommitTestAIAgent(t, db) + agent.TeamIDs = "1" + agent.ServiceMode = enums.IMConversationServiceModeAIFirst + if err := db.Save(agent).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.AgentTeam{ID: 1, Name: "客服组", Status: enums.StatusOk}).Error; err != nil { + t.Fatal(err) + } + if scheduled { + now := time.Now() + if err := db.Create(&models.AgentTeamSchedule{TeamID: 1, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk}).Error; err != nil { + t.Fatal(err) + } + } + conversation := createReplyCommitTestConversation(t, db, agent.ID) + message := models.Message{ConversationID: conversation.ID, Content: "转人工", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, RequestID: "handoff-request"} + // No model, identity prompt, or interrupt service is available: the + // command must execute the real dispatch path before any of them. + service := &aiReplyService{eligibility: newReplyEligibility()} + if err := service.TriggerReply(context.Background(), *conversation, message, *agent); err != nil { + t.Fatal(err) + } + current := svc.ConversationService.Get(conversation.ID) + if current == nil { + t.Fatal("conversation disappeared") + } + wantReply := svc.HandoffOffHoursMessage + if scheduled { + wantReply = svc.HandoffWaitingMessage + if current.Status != enums.IMConversationStatusPending || current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 || current.HandoffAt == nil || current.QueueEnteredAt == nil { + t.Fatalf("conversation did not enter waiting queue: %+v", current) + } + if current.HandoffReason != "转人工" { + t.Fatalf("handoff reason = %q", current.HandoffReason) + } + // Further messages in the waiting queue must not restart the AI. + message.Content = "有人在吗?" + if err := service.TriggerReply(context.Background(), *current, message, *agent); err != nil { + t.Fatal(err) + } + } else if current.Status != enums.IMConversationStatusAIServing || current.HandoffAt != nil { + t.Fatalf("off-hours request must preserve AI service: %+v", current) + } + var replies []models.Message + if err := db.Where("conversation_id = ?", conversation.ID).Find(&replies).Error; err != nil { + t.Fatal(err) + } + if len(replies) != 1 || replies[0].Content != wantReply || replies[0].RequestID != message.RequestID { + t.Fatalf("expected one real dispatch notice, got %+v", replies) + } + }) + } +} diff --git a/internal/ai/runtime/reply_trigger_service.go b/internal/ai/runtime/reply_trigger_service.go index f43b302..1af5f09 100644 --- a/internal/ai/runtime/reply_trigger_service.go +++ b/internal/ai/runtime/reply_trigger_service.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "log/slog" + "regexp" "strconv" "strings" "time" + "unicode" "code.tczkiot.com/wlw/ai-agent/contract" applicationruntime "code.tczkiot.com/wlw/ai-agent/internal/ai/application/runtime" @@ -27,6 +29,26 @@ const aiReplyInvocationToolCode = "runtime/ai_reply" const aiReplyInvocationRecoveryGrace = 30 * time.Second +// Match complete commands, not mentions such as “不要转人工” or “转人工是什么意思”. +// These requests must not depend on the model choosing to call a tool. +var explicitHumanHandoffCommand = regexp.MustCompile(`^(请|麻烦)?(帮我|给我)?(我要|我想要|我想)?(转接|转|找|联系|接通)?(一下)?人工(客服|服务)?(谢谢|吧)?$`) + +func isExplicitHumanHandoffRequest(message models.Message) bool { + if message.SenderType != enums.IMSenderTypeCustomer || message.MessageType != enums.IMMessageTypeText { + return false + } + content := strings.TrimFunc(strings.TrimSpace(message.Content), func(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune("。!!,,??;;", r) + }) + content = strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, content) + return explicitHumanHandoffCommand.MatchString(content) +} + func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration { if aiAgent.ReplyTimeoutSeconds <= 0 { return time.Duration(defaultAIReplyAsyncTimeoutSeconds) * time.Second @@ -313,6 +335,12 @@ func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.C if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) { return nil } + if isExplicitHumanHandoffRequest(message) { + _, err := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID( + conversation.ID, aiAgent, strings.TrimSpace(message.Content), message.RequestID, + ) + return err + } identityResolution, identityErr := resolveGuestBusinessIdentity(ctx, conversation, message) if identityErr != nil || identityResolution.NeedsPrompt { _, err := s.commit.CommitAIReply(replyCommitInput{