Files
qiweimanager-master/helper/auto_reply_handoff.go

488 lines
18 KiB
Go
Raw Permalink 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 main
import (
"fmt"
"strings"
"time"
"qiweimanager/config"
)
func (e *AutoReplyEngine) handoff(msg autoReplyMessage, reason string, hits []KnowledgeChunk) {
e.handoffWithTimings(msg, reason, hits, autoReplyTimings{})
}
func (e *AutoReplyEngine) handoffWithTimings(msg autoReplyMessage, reason string, hits []KnowledgeChunk, timings autoReplyTimings) {
if msg.isInternalSender() {
e.replyInternalNoHandoff(msg, reason, timings)
return
}
if e.shouldHoldUnknownHandoff(msg) {
e.replyUnknownNoHandoff(msg, reason, timings)
return
}
if isManualHandoffReason(reason) {
e.customerHandoffWithTimings(msg, reason, hits, timings)
return
}
e.textHandoffWithTimings(msg, reason, reason, hits, timings, "")
}
func (e *AutoReplyEngine) textHandoffWithTimings(msg autoReplyMessage, notificationReason string, recordReason string, hits []KnowledgeChunk, timings autoReplyTimings, cardStatus string) {
if err := e.sendHandoffMessage(msg, notificationReason, hits); err != nil {
e.setLastErrorWithScope(autoReplyErrorScopeHandoff, "转人工发送失败: "+err.Error())
e.addRecord(AutoReplyRecord{
RobotID: msg.RobotID,
ClientID: msg.ClientID,
UserID: msg.RobotID,
ConversationID: msg.ConversationID,
Source: msg.sourceLabel(),
FromWxID: msg.FromWxID,
FromNickName: msg.FromNickName,
Question: msg.Content,
Action: "failed",
Reason: recordReason + "; handoff_failed: " + err.Error(),
SenderIdentity: msg.SenderIdentity,
IdentitySource: msg.IdentitySource,
CardStatus: cardStatus,
KeywordScore: timings.KeywordScore,
VectorScore: timings.VectorScore,
RerankScore: timings.RerankScore,
RetrievalMode: timings.RetrievalMode,
UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "),
KnowledgeDurationMS: timings.KnowledgeDurationMS,
AIDurationMS: timings.AIDurationMS,
TotalDurationMS: timings.TotalDurationMS,
})
return
}
e.markCooldown(msg)
e.incStatus("handoff")
score := 0.0
if len(hits) > 0 {
score = hits[0].Score
}
e.addRecord(AutoReplyRecord{
RobotID: msg.RobotID,
ClientID: msg.ClientID,
UserID: msg.RobotID,
ConversationID: msg.ConversationID,
Source: msg.sourceLabel(),
FromWxID: msg.FromWxID,
FromNickName: msg.FromNickName,
Question: msg.Content,
Action: "handoff",
Reason: recordReason,
SenderIdentity: msg.SenderIdentity,
IdentitySource: msg.IdentitySource,
CardStatus: cardStatus,
Score: score,
KeywordScore: timings.KeywordScore,
VectorScore: timings.VectorScore,
RerankScore: timings.RerankScore,
RetrievalMode: timings.RetrievalMode,
UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "),
KnowledgeDurationMS: timings.KnowledgeDurationMS,
KeywordDurationMS: timings.KeywordDurationMS,
VectorDurationMS: timings.VectorDurationMS,
RerankDurationMS: timings.RerankDurationMS,
AIDurationMS: timings.AIDurationMS,
TotalDurationMS: timings.TotalDurationMS,
})
}
func (e *AutoReplyEngine) customerHandoffWithTimings(msg autoReplyMessage, reason string, hits []KnowledgeChunk, timings autoReplyTimings) {
cardResult := e.sendHandoffCards(msg)
recordReason := reason
if suffix := cardResult.reasonSuffix(); suffix != "" {
recordReason += "; " + suffix
}
e.textHandoffWithTimings(msg, reason, recordReason, hits, timings, cardResult.summary())
}
func (e *AutoReplyEngine) replyUnknownNoHandoff(msg autoReplyMessage, originalReason string, timings autoReplyTimings) {
cfg := e.getConfig()
e.startUnknownIdentityLookup(msg, originalReason)
answer := strings.TrimSpace(cfg.Identity.UnknownNoHandoffReply)
if answer == "" {
answer = "正在核验联系人身份,暂不触发转人工。如需协助请直接联系对应同事。"
}
reason := "identity_unknown_no_handoff"
if originalReason != "" {
reason += "; original_reason: " + originalReason
}
e.noteReason("identity_unknown_no_handoff")
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, answer); err != nil {
e.setLastErrorWithScope(autoReplyErrorScopeIdentity, "未知身份拦截回复失败: "+err.Error())
e.addRecord(AutoReplyRecord{
RobotID: msg.RobotID,
ClientID: msg.ClientID,
UserID: msg.RobotID,
ConversationID: msg.ConversationID,
Source: msg.sourceLabel(),
FromWxID: msg.FromWxID,
FromNickName: msg.FromNickName,
Question: msg.Content,
Action: "failed",
Reason: reason + "; send_unknown_no_handoff_failed: " + err.Error(),
SenderIdentity: msg.SenderIdentity,
IdentitySource: msg.IdentitySource,
KeywordScore: timings.KeywordScore,
VectorScore: timings.VectorScore,
RerankScore: timings.RerankScore,
RetrievalMode: timings.RetrievalMode,
UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "),
KnowledgeDurationMS: timings.KnowledgeDurationMS,
KeywordDurationMS: timings.KeywordDurationMS,
VectorDurationMS: timings.VectorDurationMS,
RerankDurationMS: timings.RerankDurationMS,
AIDurationMS: timings.AIDurationMS,
TotalDurationMS: timings.TotalDurationMS,
})
return
}
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, answer)
e.markCooldown(msg)
e.incStatus("replied")
e.addRecord(AutoReplyRecord{
RobotID: msg.RobotID,
ClientID: msg.ClientID,
UserID: msg.RobotID,
ConversationID: msg.ConversationID,
Source: msg.sourceLabel(),
FromWxID: msg.FromWxID,
FromNickName: msg.FromNickName,
Question: msg.Content,
Action: "replied",
Reason: reason,
Answer: answer,
SenderIdentity: msg.SenderIdentity,
IdentitySource: msg.IdentitySource,
KeywordScore: timings.KeywordScore,
VectorScore: timings.VectorScore,
RerankScore: timings.RerankScore,
RetrievalMode: timings.RetrievalMode,
UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "),
KnowledgeDurationMS: timings.KnowledgeDurationMS,
KeywordDurationMS: timings.KeywordDurationMS,
VectorDurationMS: timings.VectorDurationMS,
RerankDurationMS: timings.RerankDurationMS,
AIDurationMS: timings.AIDurationMS,
TotalDurationMS: timings.TotalDurationMS,
})
}
func (e *AutoReplyEngine) replyInternalNoHandoff(msg autoReplyMessage, originalReason string, timings autoReplyTimings) {
cfg := e.getConfig()
answer := strings.TrimSpace(cfg.Identity.InternalNoHandoffReply)
if answer == "" {
answer = "内部员工消息不触发转人工,如需协助请直接联系对应同事。"
}
reason := "internal_employee_no_handoff"
if originalReason != "" {
reason += "; original_reason: " + originalReason
}
e.noteReason("internal_employee_no_handoff")
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, answer); err != nil {
e.setLastErrorWithScope(autoReplyErrorScopeIdentity, "内部员工拦截回复失败: "+err.Error())
e.addRecord(AutoReplyRecord{
RobotID: msg.RobotID,
ClientID: msg.ClientID,
UserID: msg.RobotID,
ConversationID: msg.ConversationID,
Source: msg.sourceLabel(),
FromWxID: msg.FromWxID,
FromNickName: msg.FromNickName,
Question: msg.Content,
Action: "failed",
Reason: reason + "; send_internal_no_handoff_failed: " + err.Error(),
SenderIdentity: msg.SenderIdentity,
IdentitySource: msg.IdentitySource,
KeywordScore: timings.KeywordScore,
VectorScore: timings.VectorScore,
RerankScore: timings.RerankScore,
RetrievalMode: timings.RetrievalMode,
UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "),
KnowledgeDurationMS: timings.KnowledgeDurationMS,
KeywordDurationMS: timings.KeywordDurationMS,
VectorDurationMS: timings.VectorDurationMS,
RerankDurationMS: timings.RerankDurationMS,
AIDurationMS: timings.AIDurationMS,
TotalDurationMS: timings.TotalDurationMS,
})
return
}
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, answer)
e.markCooldown(msg)
e.incStatus("replied")
e.addRecord(AutoReplyRecord{
RobotID: msg.RobotID,
ClientID: msg.ClientID,
UserID: msg.RobotID,
ConversationID: msg.ConversationID,
Source: msg.sourceLabel(),
FromWxID: msg.FromWxID,
FromNickName: msg.FromNickName,
Question: msg.Content,
Action: "replied",
Reason: reason,
Answer: answer,
SenderIdentity: msg.SenderIdentity,
IdentitySource: msg.IdentitySource,
KeywordScore: timings.KeywordScore,
VectorScore: timings.VectorScore,
RerankScore: timings.RerankScore,
RetrievalMode: timings.RetrievalMode,
UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "),
KnowledgeDurationMS: timings.KnowledgeDurationMS,
KeywordDurationMS: timings.KeywordDurationMS,
VectorDurationMS: timings.VectorDurationMS,
RerankDurationMS: timings.RerankDurationMS,
AIDurationMS: timings.AIDurationMS,
TotalDurationMS: timings.TotalDurationMS,
})
}
func (e *AutoReplyEngine) sendHandoffMessage(msg autoReplyMessage, reason string, hits []KnowledgeChunk) error {
cfg := e.getConfig()
conversationID, err := e.resolveHumanConversationID(msg, cfg)
if err != nil {
return err
}
content := renderHandoffTemplate(cfg.Handoff.MessageTemplate, msg, reason)
if cfg.Handoff.IncludeKnowledgeHits && len(hits) > 0 {
content += "\n\n知识库候选"
for i, hit := range hits {
if i >= 3 {
break
}
content += fmt.Sprintf("\n%d. %s / score=%.3f", i+1, hit.Source, hit.Score)
}
}
if err := sendAutoReplyText(uint32(msg.ClientID), conversationID, content); err != nil {
return err
}
e.rememberAutoSentMessage(uint32(msg.ClientID), conversationID, content)
return nil
}
type handoffCardResult struct {
statuses []string
errors []string
}
func (r handoffCardResult) summary() string {
items := append([]string{}, r.statuses...)
items = append(items, r.errors...)
return strings.Join(items, "; ")
}
func (r handoffCardResult) reasonSuffix() string {
return strings.Join(r.errors, "; ")
}
func (e *AutoReplyEngine) sendHandoffCards(msg autoReplyMessage) handoffCardResult {
cfg := e.getConfig()
result := handoffCardResult{}
if cfg.Handoff.SendHumanCardToCustomer {
humanID := e.resolveHumanUserID(msg, cfg)
if humanID == "" {
result.errors = append(result.errors, "human_card_missing_user_id")
e.noteReason("human_card_missing_user_id")
} else if err := sendAutoReplyCard(uint32(msg.ClientID), msg.ConversationID, humanID); err != nil {
result.errors = append(result.errors, "human_card_failed: "+err.Error())
e.noteReason("human_card_failed")
} else {
result.statuses = append(result.statuses, "human_card_sent")
e.noteReason("human_card_sent")
if err := e.sendCustomerHandoffNotice(msg, cfg); err != nil {
result.errors = append(result.errors, "customer_notice_failed: "+err.Error())
e.noteReason("customer_notice_failed")
} else {
result.statuses = append(result.statuses, "customer_notice_sent")
e.noteReason("customer_notice_sent")
}
}
}
if cfg.Handoff.SendCustomerCardToHuman {
conversationID, err := e.resolveHumanConversationID(msg, cfg)
switch {
case err != nil:
result.errors = append(result.errors, "customer_card_failed: "+err.Error())
e.noteReason("customer_card_failed")
case strings.TrimSpace(msg.FromWxID) == "":
result.errors = append(result.errors, "customer_card_failed: 缺少客户user_id")
e.noteReason("customer_card_failed")
default:
if err := sendAutoReplyCard(uint32(msg.ClientID), conversationID, msg.FromWxID); err != nil {
result.errors = append(result.errors, "customer_card_failed: "+err.Error())
e.noteReason("customer_card_failed")
} else {
result.statuses = append(result.statuses, "customer_card_sent")
e.noteReason("customer_card_sent")
}
}
}
return result
}
func (e *AutoReplyEngine) sendCustomerHandoffNotice(msg autoReplyMessage, cfg config.AutoReplyConfig) error {
notice := strings.TrimSpace(cfg.Handoff.CustomerHandoffNotice)
if notice == "" {
notice = config.NewDefaultAutoReplyConfig().Handoff.CustomerHandoffNotice
}
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, notice); err != nil {
return err
}
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, notice)
return nil
}
func isManualHandoffReason(reason string) bool {
return strings.HasPrefix(strings.TrimSpace(reason), "manual_keyword:")
}
func (e *AutoReplyEngine) shouldSendHandoffCards(_ autoReplyMessage, reason string) bool {
return isManualHandoffReason(reason)
}
func (e *AutoReplyEngine) resolveHumanConversationID(msg autoReplyMessage, cfg config.AutoReplyConfig) (string, error) {
conversationID := strings.TrimSpace(cfg.Handoff.HumanConversationID)
if conversationID != "" {
return conversationID, nil
}
humanID := e.resolveHumanUserID(msg, cfg)
if humanID == "" {
return "", fmt.Errorf("未配置人工接管同事")
}
robotID := msg.RobotID
if robotID == "" {
clientIdMutex.Lock()
robotID = globalClientMap[uint32(msg.ClientID)]
clientIdMutex.Unlock()
}
if robotID == "" || strings.HasPrefix(robotID, "client:") {
return "", fmt.Errorf("无法推导人工私信会话缺少当前接管账号ID")
}
return fmt.Sprintf("S:%s_%s", robotID, humanID), nil
}
func (e *AutoReplyEngine) resolveHumanUserID(msg autoReplyMessage, cfg config.AutoReplyConfig) string {
if humanID := strings.TrimSpace(cfg.Handoff.HumanUserID); humanID != "" {
return humanID
}
return extractPeerIDFromConversation(cfg.Handoff.HumanConversationID, msg.RobotID)
}
func (m autoReplyMessage) isInternalSender() bool {
return m.SenderIdentity == senderIdentityInternal
}
func (e *AutoReplyEngine) testHandoff() error {
msg := autoReplyMessage{
ClientID: int32(GetGlobalClientId()),
RobotID: globalClientMap[GetGlobalClientId()],
ConversationID: "test",
FromWxID: "test-customer",
FromNickName: "测试客户",
Content: "这是一条自动客服转人工测试消息。",
}
if msg.ClientID == 0 {
for clientID, userID := range globalClientMap {
msg.ClientID = int32(clientID)
msg.RobotID = userID
break
}
}
if msg.ClientID == 0 {
return fmt.Errorf("没有活跃企微账号,无法测试发送")
}
return e.sendHandoffMessage(msg, "test_handoff", nil)
}
func renderHandoffTemplate(template string, msg autoReplyMessage, reason string) string {
if strings.TrimSpace(template) == "" || isLegacyHandoffTemplate(template) {
template = defaultHandoffTemplate(msg)
}
messageTime := strings.TrimSpace(msg.MessageTime)
if messageTime == "" {
messageTime = time.Now().Format("2006-01-02 15:04:05")
}
groupName := strings.TrimSpace(msg.GroupName)
if groupName == "" && msg.IsGroup {
groupName = msg.ConversationID
}
replacements := map[string]string{
"{{customerName}}": fallbackString(msg.FromNickName, "未知客户"),
"{{fromWxId}}": msg.FromWxID,
"{{source}}": msg.sourceDisplayLabel(),
"{{sourceLabel}}": msg.sourceDisplayLabel(),
"{{conversationId}}": msg.ConversationID,
"{{groupName}}": groupName,
"{{question}}": msg.Content,
"{{reason}}": handoffReasonLabel(reason),
"{{reasonCode}}": reason,
"{{messageTime}}": messageTime,
"{{time}}": messageTime,
}
for key, value := range replacements {
template = strings.ReplaceAll(template, key, value)
}
return template
}
func defaultHandoffTemplate(msg autoReplyMessage) string {
if msg.IsGroup {
return "群聊问题待处理\n\n群聊{{groupName}}\n客户{{customerName}}\n客户ID{{fromWxId}}\n来源{{sourceLabel}}\n时间{{messageTime}}\n问题{{question}}\n原因{{reason}}\n会话ID{{conversationId}}"
}
return "客户问题待处理\n\n客户{{customerName}}\n客户ID{{fromWxId}}\n来源{{sourceLabel}}\n时间{{messageTime}}\n问题{{question}}\n原因{{reason}}\n会话ID{{conversationId}}"
}
func isLegacyHandoffTemplate(template string) bool {
return strings.Contains(template, "客户问题需要人工处理") &&
strings.Contains(template, "{{customerName}}") &&
strings.Contains(template, "{{question}}")
}
func handoffReasonLabel(reason string) string {
reason = strings.TrimSpace(reason)
switch {
case reason == "knowledge_low_score":
return "知识库未匹配到答案"
case reason == "question_too_long":
return "问题过长"
case reason == "non_text_message":
return "非文本消息"
case reason == "ai_no_answer":
return "AI 无法回答"
case strings.HasPrefix(reason, "ai_error:"):
lower := strings.ToLower(reason)
if strings.Contains(lower, "deadline exceeded") || strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out") {
return "AI 请求超时"
}
return "AI 请求失败"
case strings.HasPrefix(reason, "send_reply_failed:"):
return "自动回复发送失败"
case strings.HasPrefix(reason, "send_greeting_failed:"):
return "问候回复发送失败"
case strings.HasPrefix(reason, "manual_keyword:"):
keyword := strings.TrimSpace(strings.TrimPrefix(reason, "manual_keyword:"))
if keyword == "" {
return "命中敏感关键词"
}
return "命中敏感关键词:" + keyword
default:
if reason == "" {
return "未知原因"
}
return reason
}
}
func fallbackString(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}