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) }