336 lines
10 KiB
Go
336 lines
10 KiB
Go
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)
|
||
}
|