fix(runtime): dispatch explicit human handoff requests without model
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||||
applicationruntime "code.tczkiot.com/wlw/ai-agent/internal/ai/application/runtime"
|
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
|
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 {
|
func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration {
|
||||||
if aiAgent.ReplyTimeoutSeconds <= 0 {
|
if aiAgent.ReplyTimeoutSeconds <= 0 {
|
||||||
return time.Duration(defaultAIReplyAsyncTimeoutSeconds) * time.Second
|
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)) {
|
if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) {
|
||||||
return nil
|
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)
|
identityResolution, identityErr := resolveGuestBusinessIdentity(ctx, conversation, message)
|
||||||
if identityErr != nil || identityResolution.NeedsPrompt {
|
if identityErr != nil || identityResolution.NeedsPrompt {
|
||||||
_, err := s.commit.CommitAIReply(replyCommitInput{
|
_, err := s.commit.CommitAIReply(replyCommitInput{
|
||||||
|
|||||||
Reference in New Issue
Block a user