18c9354095
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
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
|
||
}
|