Files
ai-agent/internal/services/customer_quick_action_service.go
T

379 lines
12 KiB
Go
Raw Normal View History

package services
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"code.tczkiot.com/wlw/ai-agent/contract"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
"github.com/mlogclub/simple/common/strs"
)
var CustomerQuickActionService = &customerQuickActionService{}
type customerQuickActionService struct {
mu sync.RWMutex
actions map[string]contract.CustomerQuickAction
}
func SetCustomerQuickActions(actions []contract.CustomerQuickAction) error {
registered := make(map[string]contract.CustomerQuickAction, len(actions))
for _, action := range actions {
action.Code = strings.TrimSpace(action.Code)
action.Title = strings.TrimSpace(action.Title)
action.Description = strings.TrimSpace(action.Description)
action.Message = strings.TrimSpace(action.Message)
if action.Code == "" || action.Title == "" || action.Message == "" {
return fmt.Errorf("ai-agent: customer quick action code, title and message are required")
}
if action.Execute == nil {
return fmt.Errorf("ai-agent: customer quick action executor is required: %s", action.Code)
}
if _, exists := registered[action.Code]; exists {
return fmt.Errorf("ai-agent: duplicate customer quick action code: %s", action.Code)
}
registered[action.Code] = action
}
CustomerQuickActionService.mu.Lock()
CustomerQuickActionService.actions = registered
CustomerQuickActionService.mu.Unlock()
return nil
}
func (s *customerQuickActionService) ListForConversation(ctx context.Context, conversation *models.Conversation) ([]contract.CustomerQuickAction, error) {
if conversation == nil {
return nil, nil
}
conversation = resolveQuickActionConversation(ctx, conversation)
s.mu.RLock()
actions := make([]contract.CustomerQuickAction, 0, len(s.actions))
for _, action := range s.actions {
if quickActionSupportsCustomerType(action, conversation.CustomerType) {
actions = append(actions, action)
}
}
s.mu.RUnlock()
businessContext := quickActionBusinessContext(ctx, conversation)
ret := make([]contract.CustomerQuickAction, 0, len(actions))
for _, action := range actions {
if action.Available != nil {
available, err := action.Available(ctx, businessContext)
if err != nil {
slog.Warn("check customer quick action availability failed", "code", action.Code, "conversation_id", conversation.ID, "error", err)
continue
}
if !available {
continue
}
}
ret = append(ret, action)
}
sort.Slice(ret, func(i, j int) bool {
if ret[i].Sort == ret[j].Sort {
return ret[i].Code < ret[j].Code
}
return ret[i].Sort < ret[j].Sort
})
return ret, nil
}
func (s *customerQuickActionService) ExecuteAndRecord(
ctx context.Context,
conversationID int64,
code string,
clientMsgID string,
external openidentity.ExternalUser,
requestID string,
) (*models.Message, *models.Message, error) {
conversation := ConversationService.Get(conversationID)
if conversation == nil {
return nil, nil, errorsx.InvalidParamI18n("error.e0116")
}
if !ConversationService.IsCustomerConversationOwner(conversation, external) {
return nil, nil, errorsx.ForbiddenI18n("error.e0222")
}
conversation = resolveQuickActionConversation(ctx, conversation)
action, ok := s.resolve(code, conversation.CustomerType)
if !ok {
return nil, nil, errorsx.InvalidParam("customer quick action is unavailable")
}
businessContext := quickActionBusinessContext(ctx, conversation)
if action.Available != nil {
available, err := action.Available(ctx, businessContext)
if err != nil {
return nil, nil, err
}
if !available {
return nil, nil, errorsx.InvalidParam("customer quick action is currently unavailable")
}
}
if action.TriggerAI {
customerMessage, err := MessageService.SendCustomerMessageWithContextAndRequestID(ctx,
conversation.ID, clientMsgID, enums.IMMessageTypeText, action.Message, "", external, requestID,
)
return customerMessage, nil, err
}
reply, err := action.Execute(ctx, businessContext)
if err != nil {
return nil, nil, err
}
reply = strings.TrimSpace(reply)
if reply == "" {
return nil, nil, errorsx.InvalidParam("customer quick action returned an empty reply")
}
customerMessage, err := MessageService.SendCustomerMessageWithoutAIReplyWithContextAndRequestID(ctx,
conversation.ID,
clientMsgID,
enums.IMMessageTypeText,
action.Message,
"",
external,
requestID,
)
if err != nil {
return nil, nil, err
}
replyClientMsgID := strs.UUID()
if value := strings.TrimSpace(clientMsgID); value != "" {
if len(value) > 96 {
value = value[:96]
}
replyClientMsgID = value + "_auto_reply"
}
replyMessage, err := MessageService.SendAutomaticServiceMessageWithRequestID(
conversation.ID,
replyClientMsgID,
reply,
requestID,
)
if err != nil {
return customerMessage, nil, err
}
return customerMessage, replyMessage, nil
}
// ExecuteMatchedReply executes a deterministic quick action for an already
// recorded customer message. It is used by the AI reply pipeline so explicit
// read commands do not depend on a model deciding whether to call a tool.
func (s *customerQuickActionService) ExecuteMatchedReply(
ctx context.Context,
conversation *models.Conversation,
content string,
requestID string,
sourceMessageID int64,
) (bool, error) {
if conversation == nil || strings.TrimSpace(content) == "" {
return false, nil
}
actions := s.matchingDeterministicActions(content, conversation.CustomerType)
if len(actions) == 0 {
return false, nil
}
return s.executeDeterministicReplies(ctx, conversation, actions, requestID, sourceMessageID)
}
func (s *customerQuickActionService) ExecuteSelectedReply(
ctx context.Context,
conversation *models.Conversation,
selection int,
requestID string,
sourceMessageID int64,
) (matched bool, aiMessage string, err error) {
if conversation == nil || selection <= 0 {
return false, "", nil
}
actions, err := s.ListForConversation(ctx, conversation)
if err != nil {
return false, "", err
}
if selection > len(actions) {
return false, "", nil
}
action := actions[selection-1]
return s.executeActionReply(ctx, conversation, action, requestID, sourceMessageID)
}
// ExecuteActionReply executes a registered quick action by code for an already
// recorded customer message. It is used for deterministic conversational
// choices whose display order is not the main quick-action menu order.
func (s *customerQuickActionService) ExecuteActionReply(
ctx context.Context,
conversation *models.Conversation,
code string,
requestID string,
sourceMessageID int64,
) (matched bool, aiMessage string, err error) {
if conversation == nil || strings.TrimSpace(code) == "" {
return false, "", nil
}
actions, err := s.ListForConversation(ctx, conversation)
if err != nil {
return false, "", err
}
for _, action := range actions {
if action.Code == strings.TrimSpace(code) {
return s.executeActionReply(ctx, conversation, action, requestID, sourceMessageID)
}
}
return false, "", nil
}
func (s *customerQuickActionService) executeActionReply(
ctx context.Context,
conversation *models.Conversation,
action contract.CustomerQuickAction,
requestID string,
sourceMessageID int64,
) (matched bool, aiMessage string, err error) {
if action.TriggerAI {
return true, action.Message, nil
}
matched, err = s.executeDeterministicReplies(
ctx, conversation, []contract.CustomerQuickAction{action}, requestID, sourceMessageID,
)
return matched, "", err
}
func (s *customerQuickActionService) executeDeterministicReplies(
ctx context.Context,
conversation *models.Conversation,
actions []contract.CustomerQuickAction,
requestID string,
sourceMessageID int64,
) (bool, error) {
businessContext := quickActionBusinessContext(ctx, conversation)
replies := make([]string, 0, len(actions))
for _, action := range actions {
if action.Available != nil {
available, err := action.Available(ctx, businessContext)
if err != nil {
return true, err
}
if !available {
continue
}
}
reply, err := action.Execute(ctx, businessContext)
if err != nil {
return true, err
}
reply = strings.TrimSpace(reply)
if reply == "" {
return true, errorsx.InvalidParam("customer quick action returned an empty reply")
}
replies = append(replies, reply)
}
if len(replies) == 0 {
return false, nil
}
clientMsgID := fmt.Sprintf("matched_action_%d", sourceMessageID)
_, err := MessageService.SendAutomaticServiceMessageWithRequestID(
conversation.ID,
clientMsgID,
strings.Join(replies, "\n\n"),
requestID,
)
return true, err
}
func (s *customerQuickActionService) matchingDeterministicActions(content, customerType string) []contract.CustomerQuickAction {
s.mu.RLock()
defer s.mu.RUnlock()
matched := make([]contract.CustomerQuickAction, 0, 1)
for _, action := range s.actions {
if action.TriggerAI || action.MatchIntent == nil || !quickActionSupportsCustomerType(action, customerType) {
continue
}
if action.MatchIntent(content) {
matched = append(matched, action)
}
}
// Free text must only bypass the Agent when it is one short, unambiguous
// lookup. Negations, explanations and compound requests need conversational
// reasoning; returning one or more keyword templates here would silently
// discard the customer's actual intent. Menu selections and explicit action
// codes use separate deterministic entry points and are unaffected.
if len(matched) != 1 || !isHighConfidenceDeterministicQuickActionMessage(content) {
return nil
}
sort.Slice(matched, func(i, j int) bool {
if matched[i].Sort == matched[j].Sort {
return matched[i].Code < matched[j].Code
}
return matched[i].Sort < matched[j].Sort
})
return matched
}
func isHighConfidenceDeterministicQuickActionMessage(content string) bool {
text := strings.TrimSpace(content)
if text == "" || len([]rune(text)) > 28 {
return false
}
for _, marker := range []string{
"不是", "而是", "不要", "别", "搞错", "说错",
"为什么", "怎么", "如何", "能否", "可以吗", "咨询", "原因",
"另外", "还有", "顺便", "同时", "并且", "而且", "以及",
"\n", "", ";",
} {
if strings.Contains(text, marker) {
return false
}
}
return true
}
func quickActionBusinessContext(ctx context.Context, conversation *models.Conversation) contract.BusinessReadContext {
if conversation == nil {
return contract.BusinessReadContext{}
}
businessContext := contract.BusinessReadContext{
ConversationID: conversation.ID,
CustomerType: conversation.CustomerType,
CustomerID: conversation.CustomerID,
CustomerExternalID: conversation.CustomerExternalID,
CustomerName: conversation.CustomerName,
}
if proof, ok := contract.CustomerAccessProofFromContext(ctx); ok {
businessContext.AccessProof = &proof
businessContext.RequestMessageID = proof.MessageID
businessContext.RequestID = proof.RequestID
}
return businessContext
}
func (s *customerQuickActionService) resolve(code, customerType string) (contract.CustomerQuickAction, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
action, ok := s.actions[strings.TrimSpace(code)]
if !ok || !quickActionSupportsCustomerType(action, customerType) {
return contract.CustomerQuickAction{}, false
}
return action, true
}
func quickActionSupportsCustomerType(action contract.CustomerQuickAction, customerType string) bool {
if len(action.CustomerTypes) == 0 {
return true
}
for _, candidate := range action.CustomerTypes {
if strings.EqualFold(strings.TrimSpace(candidate), strings.TrimSpace(customerType)) {
return true
}
}
return false
}