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

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

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

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

528 lines
19 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 runtime
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"code.tczkiot.com/wlw/ai-agent/contract"
applicationruntime "code.tczkiot.com/wlw/ai-agent/internal/ai/application/runtime"
"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/tracex"
svc "code.tczkiot.com/wlw/ai-agent/internal/services"
"github.com/mlogclub/simple/sqls"
)
const aiReplyFailedReply = "已收到您的消息,但本次智能处理没有完成。请稍后重试,或回复“人工客服”继续处理。"
const businessIdentityMenuPrefix = "identity_menu"
const aiReplyInvocationToolCode = "runtime/ai_reply"
const aiReplyInvocationRecoveryGrace = 30 * time.Second
func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration {
if aiAgent.ReplyTimeoutSeconds <= 0 {
return time.Duration(defaultAIReplyAsyncTimeoutSeconds) * time.Second
}
if aiAgent.ReplyTimeoutSeconds > maxAIReplyAsyncTimeoutSeconds {
return time.Duration(maxAIReplyAsyncTimeoutSeconds) * time.Second
}
return time.Duration(aiAgent.ReplyTimeoutSeconds) * time.Second
}
func (s *aiReplyService) TriggerReplyAsync(requestContext context.Context, conversation models.Conversation, message models.Message) {
aiAgent := svc.AIAgentService.Get(conversation.AIAgentID)
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return
}
timeout := s.resolveReplyTimeout(*aiAgent)
invocationKey := fmt.Sprintf("message:%d:revision:%d", message.ID, aiAgent.PublishedRevisionID)
claim, err := svc.AgentToolInvocationService.ClaimRecoverable(
conversation.ID,
aiAgent.ID,
aiReplyInvocationToolCode,
invocationKey,
time.Now().Add(-(timeout + aiReplyInvocationRecoveryGrace)),
)
if err != nil {
slog.Error("failed to claim ai reply run",
"requestId", message.RequestID,
"conversation_id", conversation.ID,
"message_id", message.ID,
"revision_id", aiAgent.PublishedRevisionID,
"error", err)
return
}
if claim == nil || claim.Item == nil || !claim.Acquired {
return
}
if committedAIReply(conversation.ID, message.ID) != nil {
if err := svc.AgentToolInvocationService.Complete(claim.Item, `{}`); err != nil {
slog.Error("failed to reconcile recovered ai reply claim", "conversation_id", conversation.ID, "message_id", message.ID, "error", err)
}
return
}
proofContext := contract.BindCustomerAccessProofToMessage(requestContext, conversation.ID, message.ID, message.RequestID)
proof, hasProof := contract.CustomerAccessProofFromContext(proofContext)
go func() {
startedAt := time.Now()
ctx := tracex.ContextWithRequestID(context.Background(), message.RequestID)
if hasProof {
ctx = contract.WithCustomerAccessProof(ctx, proof)
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
defer func() {
if recovered := recover(); recovered != nil {
err := fmt.Errorf("ai reply panic: %v", recovered)
_ = svc.AgentToolInvocationService.FailRetryable(claim.Item, err)
slog.Error("panic while triggering ai reply",
"requestId", message.RequestID,
"conversation_id", conversation.ID,
"message_id", message.ID,
"error", err)
s.commitFailureReplyIfNeeded(conversation, message, *aiAgent, err)
}
}()
var triggerErr error
if s.triggerReply != nil {
triggerErr = s.triggerReply(ctx, conversation, message, *aiAgent)
} else {
triggerErr = s.TriggerReply(ctx, conversation, message, *aiAgent)
}
if triggerErr != nil {
_ = svc.AgentToolInvocationService.FailRetryable(claim.Item, triggerErr)
slog.Error("failed to trigger ai reply",
"requestId", message.RequestID,
"message_id", message.ID,
"timeout_ms", timeout.Milliseconds(),
"elapsed_ms", time.Since(startedAt).Milliseconds(),
"error", triggerErr)
s.commitFailureReplyIfNeeded(conversation, message, *aiAgent, triggerErr)
return
}
if err := svc.AgentToolInvocationService.Complete(claim.Item, `{}`); err != nil {
slog.Error("failed to complete ai reply run claim",
"requestId", message.RequestID,
"conversation_id", conversation.ID,
"message_id", message.ID,
"error", err)
}
}()
}
func committedAIReply(conversationID, messageID int64) *models.Message {
for _, prefix := range []string{"ai_reply", "identity_prompt", "ai_interrupt", "ai_interrupt_expired", "ai_resume", "ai_interrupt_resume"} {
clientMsgID := fmt.Sprintf("%s_%d", prefix, messageID)
if existing := svc.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversationID).Eq("client_msg_id", clientMsgID)); existing != nil {
return existing
}
}
return nil
}
func (s *aiReplyService) commitFailureReplyIfNeeded(conversation models.Conversation, message models.Message, aiAgent models.AIAgent, cause error) {
clientMsgID := fmt.Sprintf("ai_error_%d", message.ID)
if existing := svc.MessageService.FindOne(
sqls.NewCnd().Eq("conversation_id", conversation.ID).Eq("client_msg_id", clientMsgID),
); existing != nil {
return
}
if _, err := s.commit.CommitAIReply(replyCommitInput{
Conversation: conversation,
Message: message,
AIAgent: aiAgent,
ReplyText: aiReplyFailureText(cause),
ClientPrefix: "ai_error",
}); err != nil {
slog.Error("failed to commit ai error reply",
"requestId", message.RequestID,
"conversation_id", conversation.ID,
"message_id", message.ID,
"error", err)
}
}
func aiReplyFailureText(cause error) string {
if cause == nil {
return aiReplyFailedReply
}
message := strings.ToLower(cause.Error())
switch {
case strings.Contains(message, "ai 请求标识未设置"),
strings.Contains(message, "invalid_ai_request_id"):
return "系统内置 AI 请求失败:AI 请求标识无效。请联系管理员检查网关配置,或回复“人工客服”继续处理。"
case strings.Contains(message, "insufficient_ai_balance"),
strings.Contains(message, "ai 额度不足"):
return "系统内置 AI 额度不足,请充值后重试,或回复“人工客服”继续处理。"
case strings.Contains(message, "invalid_ai_key"),
strings.Contains(message, "missing_ai_key"),
strings.Contains(message, "ai key 格式无效"),
strings.Contains(message, "ai 授权凭证无效"):
return "系统内置 AI Key 无效或已撤销,请联系管理员检查客服设置。"
case strings.Contains(message, "ai_gateway_not_configured"),
strings.Contains(message, "system built-in llm model is not configured"),
strings.Contains(message, "系统内置模型尚未配置"):
return "系统内置 AI 模型尚未配置,请联系管理员完成配置。"
case strings.Contains(message, "context deadline exceeded"),
strings.Contains(message, "request timeout"),
strings.Contains(message, "client.timeout"):
return "系统内置 AI 请求超时,请稍后重试,或回复“人工客服”继续处理。"
case strings.Contains(message, "internal_error"),
strings.Contains(message, "网关内部异常"):
if requestID := extractAIGatewayRequestID(cause.Error()); requestID != "" {
return "系统内置 AI 网关内部异常,请稍后重试;排查编号:" + requestID + "。如仍失败,请联系管理员或回复“人工客服”。"
}
return "系统内置 AI 网关内部异常,请稍后重试;如仍失败,请联系管理员或回复“人工客服”。"
case isAIUpstreamError(message):
return aiUpstreamFailureText(message)
default:
return aiReplyFailedReply
}
}
func extractAIGatewayRequestID(message string) string {
lowerMessage := strings.ToLower(message)
for _, marker := range []string{"request_id:", "request_id="} {
start := strings.Index(lowerMessage, marker)
if start < 0 {
continue
}
value := strings.TrimSpace(message[start+len(marker):])
value = strings.TrimLeft(value, "(\"'")
end := 0
for end < len(value) {
char := value[end]
if (char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') || char == '-' || char == '_' {
end++
continue
}
break
}
if end > 0 {
return value[:end]
}
}
return ""
}
func isAIUpstreamError(message string) bool {
if strings.Contains(message, "ai_upstream_failed") || strings.Contains(message, "ai_request_failed") {
return true
}
providerMentioned := strings.Contains(message, "qwen") ||
strings.Contains(message, "千问") ||
strings.Contains(message, "deepseek") ||
strings.Contains(message, "dashscope")
if !providerMentioned {
return false
}
for _, marker := range []string{
"status code", "bad request", "unauthorized", "forbidden", "too many requests",
"returned 4", "returned 5", "返回 4", "返回 5", "invalidparameter",
"invalid_parameter", "throttling", "arrearage", "accessdenied", "error",
} {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func aiUpstreamFailureText(message string) string {
provider := "上游模型"
if strings.Contains(message, "qwen") || strings.Contains(message, "千问") || strings.Contains(message, "dashscope") {
provider = "千问"
} else if strings.Contains(message, "deepseek") {
provider = "DeepSeek"
}
switch {
case strings.Contains(message, "model not exist"),
strings.Contains(message, "model_not_found"),
strings.Contains(message, "invalid model"),
strings.Contains(message, "model.accessdenied"),
strings.Contains(message, "model access denied"),
strings.Contains(message, "模型不存在"):
return "系统内置 AI 调用失败:" + provider + "模型不存在或暂不可用,也可能尚未开通,请联系管理员检查模型名称和开通状态。"
case strings.Contains(message, "authentication"),
strings.Contains(message, "invalid api key"),
strings.Contains(message, "invalid_api_key"),
strings.Contains(message, "invalidapikey"),
strings.Contains(message, "unauthorized"):
return "系统内置 AI 调用失败:" + provider + " API Key 无效或无权限,请联系管理员检查官网模型配置。"
case strings.Contains(message, "rate limit"),
strings.Contains(message, "rate_limit"),
strings.Contains(message, "too many requests"),
strings.Contains(message, "throttling"):
return "系统内置 AI 调用失败:" + provider + "请求过于频繁,请稍后重试。"
case strings.Contains(message, "insufficient balance"),
strings.Contains(message, "insufficient quota"),
strings.Contains(message, "arrearage"),
strings.Contains(message, "quota"):
return "系统内置 AI 调用失败:" + provider + "账户额度不足或已欠费,请联系管理员处理。"
case strings.Contains(message, "maximum context length"),
strings.Contains(message, "context_length"),
strings.Contains(message, "input length"),
strings.Contains(message, "tokens exceed"),
strings.Contains(message, "too many tokens"):
return "系统内置 AI 调用失败:" + provider + "请求内容超出模型上下文长度,请缩短消息后重试。"
case strings.Contains(message, "does not support tools"),
strings.Contains(message, "tool calling is not supported"),
strings.Contains(message, "function calling is not supported"),
strings.Contains(message, "unsupported tool"),
strings.Contains(message, "unsupported function"):
return "系统内置 AI 调用失败:当前" + provider + "模型不支持客服工具调用,请联系管理员更换可用模型。"
case strings.Contains(message, "data_inspection_failed"),
strings.Contains(message, "content_filter"),
strings.Contains(message, "inappropriate content"):
return "系统内置 AI 调用失败:" + provider + "拒绝了本次内容,请调整表述后重试。"
case strings.Contains(message, "invalidparameter"),
strings.Contains(message, "invalid_parameter"),
strings.Contains(message, "bad request"),
strings.Contains(message, "返回 400"),
strings.Contains(message, "returned 400"):
return "系统内置 AI 调用失败:" + provider + "请求参数不兼容,请联系管理员检查模型与客服工具配置。"
default:
return "系统内置 AI 调用失败:" + provider + "服务返回错误,请联系管理员在 AI 回复记录中查看详细原因。"
}
}
func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent) (retErr error) {
var summary *applicationruntime.RunResult
replyCtx := aiReplyContext{
Conversation: conversation,
Message: message,
AIAgent: aiAgent,
SummaryRef: &summary,
}
if err := ctx.Err(); err != nil {
return err
}
if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) {
return nil
}
if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) {
return nil
}
identityResolution, identityErr := resolveGuestBusinessIdentity(ctx, conversation, message)
if identityErr != nil || identityResolution.NeedsPrompt {
_, err := s.commit.CommitAIReply(replyCommitInput{
Conversation: conversation,
Message: message,
AIAgent: aiAgent,
ReplyText: guestBusinessIdentityPrompt(identityResolution, identityErr),
ClientPrefix: "identity_prompt",
})
return err
}
replyCtx.Conversation = identityResolution.Conversation
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
replyCtx.PendingInterrupt = pendingInterrupt
return s.resumePendingInterrupt(ctx, replyCtx)
}
if identityResolution.CandidateProvided && isBusinessIdentityOnlyMessage(message) {
handled, err := s.sendBusinessIdentityMenu(ctx, replyCtx)
if handled || err != nil {
return err
}
}
if selection, ok := businessIdentityMenuSelection(message); ok {
latest := latestAIMessage(conversation.ID)
if latest != nil && strings.HasPrefix(latest.ClientMsgID, businessIdentityMenuPrefix+"_") {
matched, aiMessage, err := svc.CustomerQuickActionService.ExecuteSelectedReply(
ctx, &replyCtx.Conversation, selection, message.RequestID, message.ID,
)
if matched || err != nil {
return s.finishQuickActionReply(ctx, replyCtx, matched, aiMessage, err)
}
}
if actionCode, matched := legacyCardMenuActionCode(latest, selection); matched {
actionMatched, aiMessage, err := svc.CustomerQuickActionService.ExecuteActionReply(
ctx, &replyCtx.Conversation, actionCode, message.RequestID, message.ID,
)
if actionMatched || err != nil {
return s.finishQuickActionReply(ctx, replyCtx, actionMatched, aiMessage, err)
}
}
}
if matched, err := svc.CustomerQuickActionService.ExecuteMatchedReply(
ctx,
&replyCtx.Conversation,
message.Content,
message.RequestID,
message.ID,
); matched || err != nil {
return err
}
return s.executeReply(ctx, replyCtx)
}
func (s *aiReplyService) sendBusinessIdentityMenu(ctx context.Context, replyCtx aiReplyContext) (bool, error) {
actions, err := svc.CustomerQuickActionService.ListForConversation(ctx, &replyCtx.Conversation)
if err != nil || len(actions) == 0 {
return false, err
}
objectLabel := "业务对象"
switch replyCtx.Conversation.CustomerType {
case "card":
objectLabel = "卡号"
case "device":
objectLabel = "设备号"
case "mall_user":
objectLabel = "商城用户"
}
var builder strings.Builder
builder.WriteString("已识别")
builder.WriteString(objectLabel)
if identifier := strings.TrimSpace(replyCtx.Conversation.CustomerExternalID); identifier != "" {
builder.WriteString("")
builder.WriteString(identifier)
}
builder.WriteString("。\n\n请回复序号选择需要的服务:")
for index, action := range actions {
builder.WriteString(fmt.Sprintf("\n%d. %s", index+1, action.Title))
}
builder.WriteString("\n\n也可以直接输入要咨询的问题。")
_, err = svc.MessageService.SendAutomaticServiceMessageWithRequestID(
replyCtx.Conversation.ID,
fmt.Sprintf("%s_%d", businessIdentityMenuPrefix, replyCtx.Message.ID),
builder.String(),
replyCtx.Message.RequestID,
)
return true, err
}
func isBusinessIdentityOnlyMessage(message models.Message) bool {
content := businessIdentityMessageContent(message)
candidates, _ := businessIdentityCandidates(content)
for _, candidate := range candidates {
content = strings.ReplaceAll(content, candidate, "")
}
for _, marker := range []string{"卡号", "卡板", "设备号", "设备", "iccid", "imei"} {
content = strings.ReplaceAll(strings.ToLower(content), marker, "")
}
content = strings.Map(func(r rune) rune {
if r == ' ' || r == ' ' || r == '\n' || r == '\r' || r == '\t' || r == ' ' {
return -1
}
switch r {
case '', ':', '', ',', '。', '.', '', ';', '-', '_':
return -1
default:
return r
}
}, content)
return content == ""
}
func businessIdentityMenuSelection(message models.Message) (int, bool) {
content := strings.TrimSpace(businessIdentityMessageContent(message))
selection, err := strconv.Atoi(content)
return selection, err == nil && selection > 0
}
func latestAIMessage(conversationID int64) *models.Message {
return svc.MessageService.FindOne(sqls.NewCnd().
Eq("conversation_id", conversationID).
Eq("sender_type", enums.IMSenderTypeAI).
Desc("id"))
}
func legacyCardMenuActionCode(message *models.Message, selection int) (string, bool) {
if message == nil || selection <= 0 {
return "", false
}
content := businessIdentityMessageContent(*message)
if strings.Contains(content, "卡片提示停机") &&
strings.Contains(content, "无法上网") &&
strings.Contains(content, "无信号") &&
strings.Contains(content, "已充值但未恢复") {
if selection >= 1 && selection <= 4 {
return "card/network_diagnosis", true
}
return "", false
}
if strings.Contains(content, "卡片状态") &&
strings.Contains(content, "网络连接") &&
strings.Contains(content, "套餐") &&
strings.Contains(content, "其他问题") {
actions := map[int]string{
1: "card/status",
2: "card/network_diagnosis",
3: "card/package",
}
code, ok := actions[selection]
return code, ok
}
return "", false
}
func (s *aiReplyService) finishQuickActionReply(
ctx context.Context,
replyCtx aiReplyContext,
matched bool,
aiMessage string,
err error,
) error {
if err != nil || !matched {
return err
}
if strings.TrimSpace(aiMessage) == "" {
return nil
}
replyCtx.Message.Content = aiMessage
replyCtx.Message.MessageType = enums.IMMessageTypeText
return s.executeReply(ctx, replyCtx)
}
func (s *aiReplyService) resumePendingInterrupt(ctx context.Context, replyCtx aiReplyContext) error {
return s.interrupts.ResumePendingInterrupt(ctx, s, replyCtx)
}
func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyContext) error {
summary, err := s.executor.Run(ctx, runtimeReplyRunInput{
Conversation: replyCtx.Conversation,
Message: replyCtx.Message,
AIAgent: replyCtx.AIAgent,
})
replyCtx.setSummary(summary)
if err != nil {
return err
}
if summary != nil && summary.Interrupted {
return s.interrupts.HandleInterruptedSummary(s, replyCtx, summary)
}
if summary != nil && summary.HandoffRequested {
if _, err := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
replyCtx.Conversation.ID,
replyCtx.AIAgent,
summary.HandoffReason,
replyCtx.Message.RequestID,
); err != nil {
return err
}
return nil
}
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
_, err := s.commit.CommitAIReply(replyCommitInput{
Conversation: replyCtx.Conversation,
Message: replyCtx.Message,
AIAgent: replyCtx.AIAgent,
ReplyText: summary.ReplyText,
ClientPrefix: "ai_reply",
})
if err != nil {
return err
}
}
return nil
}