Initial qiwei secondary development handoff

This commit is contained in:
2026-06-23 21:11:20 +08:00
commit 858cb68f4f
207 changed files with 52782 additions and 0 deletions

View File

@@ -0,0 +1,487 @@
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
}