2026-04-13 19:15:12 +08:00
|
|
|
|
package runtime
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
2026-08-28 22:23:13 +08:00
|
|
|
|
"fmt"
|
2026-04-13 19:15:12 +08:00
|
|
|
|
"log/slog"
|
2026-08-28 22:23:13 +08:00
|
|
|
|
"strconv"
|
2026-04-13 19:15:12 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/contract"
|
2026-08-21 00:41:07 +08:00
|
|
|
|
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"
|
2026-08-28 22:23:13 +08:00
|
|
|
|
|
|
|
|
|
|
"github.com/mlogclub/simple/sqls"
|
2026-04-13 19:15:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
|
const aiReplyFailedReply = "已收到您的消息,但本次智能处理没有完成。请稍后重试,或回复“人工客服”继续处理。"
|
|
|
|
|
|
|
|
|
|
|
|
const businessIdentityMenuPrefix = "identity_menu"
|
|
|
|
|
|
|
|
|
|
|
|
const aiReplyInvocationToolCode = "runtime/ai_reply"
|
|
|
|
|
|
|
|
|
|
|
|
const aiReplyInvocationRecoveryGrace = 30 * time.Second
|
|
|
|
|
|
|
2026-04-13 19:15:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
|
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)
|
2026-04-13 19:15:12 +08:00
|
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
proofContext := contract.BindCustomerAccessProofToMessage(requestContext, conversation.ID, message.ID, message.RequestID)
|
|
|
|
|
|
proof, hasProof := contract.CustomerAccessProofFromContext(proofContext)
|
|
|
|
|
|
go func() {
|
2026-04-13 19:15:12 +08:00
|
|
|
|
startedAt := time.Now()
|
2026-08-28 22:23:13 +08:00
|
|
|
|
ctx := tracex.ContextWithRequestID(context.Background(), message.RequestID)
|
|
|
|
|
|
if hasProof {
|
|
|
|
|
|
ctx = contract.WithCustomerAccessProof(ctx, proof)
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
2026-04-13 19:15:12 +08:00
|
|
|
|
defer cancel()
|
2026-08-28 22:23:13 +08:00
|
|
|
|
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)
|
2026-04-13 19:15:12 +08:00
|
|
|
|
slog.Error("failed to trigger ai reply",
|
2026-05-27 22:18:43 +08:00
|
|
|
|
"requestId", message.RequestID,
|
2026-04-13 19:15:12 +08:00
|
|
|
|
"message_id", message.ID,
|
|
|
|
|
|
"timeout_ms", timeout.Milliseconds(),
|
|
|
|
|
|
"elapsed_ms", time.Since(startedAt).Milliseconds(),
|
2026-08-28 22:23:13 +08:00
|
|
|
|
"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,
|
2026-04-13 19:15:12 +08:00
|
|
|
|
"error", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
|
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 回复记录中查看详细原因。"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 19:15:12 +08:00
|
|
|
|
func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent) (retErr error) {
|
2026-07-27 23:29:02 +08:00
|
|
|
|
var summary *applicationruntime.RunResult
|
2026-04-19 11:38:05 +08:00
|
|
|
|
replyCtx := aiReplyContext{
|
|
|
|
|
|
Conversation: conversation,
|
|
|
|
|
|
Message: message,
|
|
|
|
|
|
AIAgent: aiAgent,
|
|
|
|
|
|
SummaryRef: &summary,
|
|
|
|
|
|
}
|
2026-04-13 19:15:12 +08:00
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-07-25 12:04:06 +08:00
|
|
|
|
if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
|
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
|
2026-04-13 19:15:12 +08:00
|
|
|
|
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
|
2026-04-19 11:38:05 +08:00
|
|
|
|
replyCtx.PendingInterrupt = pendingInterrupt
|
|
|
|
|
|
return s.resumePendingInterrupt(ctx, replyCtx)
|
2026-04-13 19:48:58 +08:00
|
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
|
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
|
2026-04-19 11:38:05 +08:00
|
|
|
|
return s.executeReply(ctx, replyCtx)
|
2026-04-13 19:48:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 11:38:05 +08:00
|
|
|
|
func (s *aiReplyService) resumePendingInterrupt(ctx context.Context, replyCtx aiReplyContext) error {
|
|
|
|
|
|
return s.interrupts.ResumePendingInterrupt(ctx, s, replyCtx)
|
2026-04-13 19:48:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 11:38:05 +08:00
|
|
|
|
func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyContext) error {
|
2026-04-19 11:19:51 +08:00
|
|
|
|
summary, err := s.executor.Run(ctx, runtimeReplyRunInput{
|
2026-04-19 11:38:05 +08:00
|
|
|
|
Conversation: replyCtx.Conversation,
|
|
|
|
|
|
Message: replyCtx.Message,
|
|
|
|
|
|
AIAgent: replyCtx.AIAgent,
|
2026-04-19 11:19:51 +08:00
|
|
|
|
})
|
2026-04-19 11:38:05 +08:00
|
|
|
|
replyCtx.setSummary(summary)
|
2026-04-13 19:15:12 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if summary != nil && summary.Interrupted {
|
2026-04-19 11:38:05 +08:00
|
|
|
|
return s.interrupts.HandleInterruptedSummary(s, replyCtx, summary)
|
2026-04-13 19:15:12 +08:00
|
|
|
|
}
|
2026-07-25 12:04:06 +08:00
|
|
|
|
if summary != nil && summary.HandoffRequested {
|
|
|
|
|
|
if _, err := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
|
|
|
|
|
replyCtx.Conversation.ID,
|
|
|
|
|
|
replyCtx.AIAgent,
|
2026-07-29 17:02:40 +08:00
|
|
|
|
summary.HandoffReason,
|
2026-07-25 12:04:06 +08:00
|
|
|
|
replyCtx.Message.RequestID,
|
2026-07-29 17:02:40 +08:00
|
|
|
|
); err != nil {
|
|
|
|
|
|
return err
|
2026-07-25 12:04:06 +08:00
|
|
|
|
}
|
2026-07-29 17:02:40 +08:00
|
|
|
|
return nil
|
2026-07-25 12:04:06 +08:00
|
|
|
|
}
|
2026-04-13 19:15:12 +08:00
|
|
|
|
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
|
2026-06-23 23:29:29 +08:00
|
|
|
|
_, err := s.commit.CommitAIReply(replyCommitInput{
|
2026-08-28 22:23:13 +08:00
|
|
|
|
Conversation: replyCtx.Conversation,
|
|
|
|
|
|
Message: replyCtx.Message,
|
|
|
|
|
|
AIAgent: replyCtx.AIAgent,
|
|
|
|
|
|
ReplyText: summary.ReplyText,
|
|
|
|
|
|
ClientPrefix: "ai_reply",
|
2026-04-19 11:19:51 +08:00
|
|
|
|
})
|
2026-04-13 19:15:12 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|