Initial qiwei secondary development handoff
This commit is contained in:
335
helper/auto_reply_human_assist.go
Normal file
335
helper/auto_reply_human_assist.go
Normal file
@@ -0,0 +1,335 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user