Files
ai-agent/internal/services/customer_quick_action_service.go
T
t 18c9354095 refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
2026-08-28 22:23:13 +08:00

379 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}