488 lines
18 KiB
Go
488 lines
18 KiB
Go
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
|
||
}
|