379 lines
12 KiB
Go
379 lines
12 KiB
Go
|
|
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
|
|||
|
|
}
|