Files
qiweimanager-master/helper/auto_reply_human_assist.go

336 lines
10 KiB
Go
Raw 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"
)
type humanAssistPending struct {
Key string
ConversationKey string
Msg autoReplyMessage
RawData map[string]interface{}
ReceivedAt time.Time
HumanReplies []string
}
type humanAssistAssessment struct {
Decision string
Reason string
}
func (e *AutoReplyEngine) maybeDelayForHumanAssist(job AutoReplyJob, msg autoReplyMessage) bool {
cfg := e.getConfig()
if job.SkipHumanAssist || !cfg.HumanAssist.Enabled || cfg.HumanAssist.WaitSeconds <= 0 {
return false
}
if strings.TrimSpace(msg.ConversationID) == "" || msg.isSelfMessage() {
return false
}
key := humanAssistPendingKey(msg)
conversationKey := e.humanAssistConversationKey(msg)
pending := &humanAssistPending{
Key: key,
ConversationKey: conversationKey,
Msg: msg,
RawData: job.RawData,
ReceivedAt: job.ReceivedAt,
}
e.mu.Lock()
if e.humanPending == nil {
e.humanPending = make(map[string]*humanAssistPending)
}
e.humanPending[key] = pending
e.mu.Unlock()
e.noteReason("human_assist_waiting")
go e.finishHumanAssistAfterDelay(key)
return true
}
func (e *AutoReplyEngine) finishHumanAssistAfterDelay(key string) {
cfg := e.getConfig()
wait := time.Duration(cfg.HumanAssist.WaitSeconds) * time.Second
if wait <= 0 {
wait = 15 * time.Second
}
time.Sleep(wait)
after := time.Duration(cfg.HumanAssist.AfterHumanReplyDelaySeconds) * time.Second
if after > 0 && e.pendingHasHumanReply(key) {
time.Sleep(after)
}
pending := e.takeHumanAssistPending(key)
if pending == nil {
return
}
if len(pending.HumanReplies) > 0 {
assessment := e.assessHumanReply(pending.Msg, pending.HumanReplies)
if assessment.Decision == "sufficient" {
e.incStatus("ignored")
e.noteReason("human_reply_sufficient")
e.addRecord(AutoReplyRecord{
RobotID: pending.Msg.RobotID,
ClientID: pending.Msg.ClientID,
UserID: pending.Msg.RobotID,
ConversationID: pending.Msg.ConversationID,
Source: pending.Msg.sourceLabel(),
FromWxID: pending.Msg.FromWxID,
FromNickName: pending.Msg.FromNickName,
Question: pending.Msg.Content,
Action: "ignored",
Reason: "human_reply_sufficient: " + assessment.Reason,
Answer: strings.Join(pending.HumanReplies, "\n"),
SenderIdentity: pending.Msg.SenderIdentity,
IdentitySource: pending.Msg.IdentitySource,
})
return
}
e.noteReason("human_reply_need_supplement")
}
retryJob := AutoReplyJob{
ClientID: pending.Msg.ClientID,
RawData: pending.RawData,
ReceivedAt: pending.ReceivedAt,
SkipHumanAssist: true,
SupplementReason: "human_assist_supplement",
}
select {
case e.queue <- retryJob:
default:
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "human assist retry queue is full")
e.addRecord(AutoReplyRecord{
RobotID: pending.Msg.RobotID,
ClientID: pending.Msg.ClientID,
UserID: pending.Msg.RobotID,
ConversationID: pending.Msg.ConversationID,
Source: pending.Msg.sourceLabel(),
FromWxID: pending.Msg.FromWxID,
FromNickName: pending.Msg.FromNickName,
Question: pending.Msg.Content,
Action: "failed",
Reason: "human_assist_retry_queue_full",
SenderIdentity: pending.Msg.SenderIdentity,
IdentitySource: pending.Msg.IdentitySource,
})
}
}
func (e *AutoReplyEngine) pendingHasHumanReply(key string) bool {
e.mu.Lock()
defer e.mu.Unlock()
pending := e.humanPending[key]
return pending != nil && len(pending.HumanReplies) > 0
}
func (e *AutoReplyEngine) takeHumanAssistPending(key string) *humanAssistPending {
e.mu.Lock()
defer e.mu.Unlock()
pending := e.humanPending[key]
delete(e.humanPending, key)
return pending
}
func (e *AutoReplyEngine) observeHumanReply(msg autoReplyMessage) bool {
content := strings.TrimSpace(msg.Content)
if content == "" || strings.TrimSpace(msg.ConversationID) == "" {
return false
}
if e.consumeAutoSentMessage(msg) {
return true
}
cfg := e.getConfig()
if !cfg.HumanAssist.Enabled {
return false
}
if len([]rune(content)) < cfg.HumanAssist.MinimumHumanReplyLengthRunes {
return false
}
conversationKey := e.humanAssistConversationKey(msg)
e.mu.Lock()
defer e.mu.Unlock()
count := 0
for _, pending := range e.humanPending {
if pending.ConversationKey != conversationKey {
continue
}
pending.HumanReplies = append(pending.HumanReplies, content)
count++
}
if count > 0 {
e.status.HumanAssistObservedCount += count
}
return count > 0
}
func (e *AutoReplyEngine) assessHumanReply(msg autoReplyMessage, replies []string) humanAssistAssessment {
joined := strings.TrimSpace(strings.Join(replies, "\n"))
if joined == "" {
return humanAssistAssessment{Decision: "need_supplement", Reason: "empty human reply"}
}
if isLikelyHoldingReply(joined) {
return humanAssistAssessment{Decision: "need_supplement", Reason: "holding reply"}
}
searchText := e.contextualSearchText(msg.Content, msg)
result := e.searchKnowledgeDetailed(searchText)
hits := result.Hits
if len(hits) == 0 {
if len([]rune(joined)) >= 8 {
return humanAssistAssessment{Decision: "sufficient", Reason: "no knowledge hit and human replied"}
}
return humanAssistAssessment{Decision: "need_supplement", Reason: "short human reply"}
}
if e.humanReplyCoversKnowledge(joined, hits) {
return humanAssistAssessment{Decision: "sufficient", Reason: "keyword coverage"}
}
if decision, reason, err := e.askHumanReplyAssessment(msg, joined, hits); err == nil {
return humanAssistAssessment{Decision: decision, Reason: reason}
}
return humanAssistAssessment{Decision: "need_supplement", Reason: "knowledge not covered"}
}
func (e *AutoReplyEngine) askHumanReplyAssessment(msg autoReplyMessage, humanReply string, hits []KnowledgeChunk) (string, string, error) {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
return "", "", fmt.Errorf("AI not configured")
}
systemPrompt := prependAISystemPrompt(cfg, "你负责判断人工客服回复是否已经覆盖知识库要点。只输出一行SUFFICIENT、NEED_SUPPLEMENT 或 CONFLICT后面可以用冒号补充一个简短原因。")
var b strings.Builder
b.WriteString("客户问题:\n")
b.WriteString(msg.Content)
b.WriteString("\n\n人工回复\n")
b.WriteString(humanReply)
b.WriteString("\n\n知识库片段\n")
for i, hit := range compactKnowledgeHitsForAI(hits) {
if i >= 4 {
break
}
b.WriteString(fmt.Sprintf("[%d] %s\n%s\n", i+1, hit.Title, truncateTextForPrompt(hit.Content, 700)))
}
var result *AIResult
var err error
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
result, err = callOllamaChat(cfg.AI, systemPrompt, b.String())
default:
result, err = callOpenAICompatibleChat(cfg.AI, systemPrompt, b.String())
}
if err != nil {
return "", "", err
}
answer := strings.TrimSpace(strings.ToUpper(result.Answer))
reason := strings.TrimSpace(result.Answer)
switch {
case strings.HasPrefix(answer, "SUFFICIENT"):
return "sufficient", reason, nil
case strings.HasPrefix(answer, "CONFLICT"):
return "need_supplement", reason, nil
case strings.HasPrefix(answer, "NEED_SUPPLEMENT"):
return "need_supplement", reason, nil
default:
return "need_supplement", reason, nil
}
}
func (e *AutoReplyEngine) humanReplyCoversKnowledge(reply string, hits []KnowledgeChunk) bool {
replyNorm := normalizeGreetingText(reply)
if len([]rune(replyNorm)) < 10 {
return false
}
keywords := topKnowledgeKeywords(hits, 8)
if len(keywords) == 0 {
return len([]rune(replyNorm)) >= 20
}
matches := 0
for _, keyword := range keywords {
if strings.Contains(replyNorm, normalizeGreetingText(keyword)) {
matches++
}
}
return matches >= 2 || (matches >= 1 && len([]rune(replyNorm)) >= 30)
}
func topKnowledgeKeywords(hits []KnowledgeChunk, limit int) []string {
seen := make(map[string]bool)
result := make([]string, 0, limit)
for _, hit := range hits {
for _, token := range strings.FieldsFunc(hit.Title+" "+hit.Content, func(r rune) bool {
return r == ' ' || r == '\n' || r == '\t' || r == '' || r == '。' || r == ',' || r == '.' || r == ':' || r == '' || r == '' || r == ';'
}) {
token = strings.TrimSpace(token)
if len([]rune(token)) < 3 || seen[token] {
continue
}
seen[token] = true
result = append(result, token)
if len(result) >= limit {
return result
}
}
}
return result
}
func isLikelyHoldingReply(text string) bool {
n := normalizeGreetingText(text)
for _, token := range []string{"稍等", "等下", "看下", "我看看", "确认一下", "稍后", "一会"} {
if strings.Contains(n, normalizeGreetingText(token)) {
return true
}
}
return false
}
func humanAssistPendingKey(msg autoReplyMessage) string {
key := msg.dedupeKey()
if key == "" {
key = fmt.Sprintf("%d|%s|%s|%d", msg.ClientID, msg.ConversationID, normalizeGreetingText(msg.Content), time.Now().UnixNano())
}
return key
}
func (e *AutoReplyEngine) humanAssistConversationKey(msg autoReplyMessage) string {
return e.contextKeyForMessage(msg)
}
func (e *AutoReplyEngine) rememberAutoSentMessage(clientID uint32, conversationID string, content string) {
key := autoSentFingerprint(clientID, conversationID, content)
if key == "" {
return
}
e.mu.Lock()
if e.autoSent == nil {
e.autoSent = make(map[string]time.Time)
}
now := time.Now()
for item, ts := range e.autoSent {
if now.Sub(ts) > 10*time.Minute {
delete(e.autoSent, item)
}
}
e.autoSent[key] = now
e.mu.Unlock()
}
func (e *AutoReplyEngine) consumeAutoSentMessage(msg autoReplyMessage) bool {
key := autoSentFingerprint(uint32(msg.ClientID), msg.ConversationID, msg.Content)
if key == "" {
return false
}
e.mu.Lock()
defer e.mu.Unlock()
if ts, ok := e.autoSent[key]; ok && time.Since(ts) < 10*time.Minute {
delete(e.autoSent, key)
return true
}
return false
}
func autoSentFingerprint(clientID uint32, conversationID string, content string) string {
conversationID = strings.TrimSpace(conversationID)
content = normalizeGreetingText(content)
if clientID == 0 || conversationID == "" || content == "" {
return ""
}
return fmt.Sprintf("%d|%s|%s", clientID, conversationID, content)
}