Files
qiweimanager-master/helper/after_sales_engine.go

586 lines
19 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 (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/google/uuid"
)
var afterSalesAICollector = callAfterSalesAI
func observeAfterSalesEvent(clientID int32, raw map[string]interface{}) {
getAfterSalesIssueEngine().observeEvent(clientID, raw)
}
func (e *AfterSalesIssueEngine) observeEvent(clientID int32, raw map[string]interface{}) {
message, ok := e.extractMessage(clientID, raw)
if !ok {
return
}
e.mu.Lock()
defer e.mu.Unlock()
for i := range e.messages {
if e.messages[i].MessageID == message.MessageID {
e.messages[i] = message
e.trimMessagesLocked(time.Now())
e.updateStateMessageCountLocked()
_ = e.saveMessagesLocked()
_ = e.saveStateLocked()
return
}
}
e.messages = append(e.messages, message)
e.trimMessagesLocked(time.Now())
e.updateStateMessageCountLocked()
if err := e.saveMessagesLocked(); err != nil && globalLogger != nil {
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞垮劗濡插牓鏌涢銈呮瀺缂佽鲸鐗滅槐鎾诲磼濞戞瑥纰嶉梺瀹︽澘濡界€垫澘瀚蹇涱敃閵? %v", err)
}
if err := e.saveStateLocked(); err != nil && globalLogger != nil {
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閻撳倿鏌涢妷顔煎闁艰尙濞€閺岋絽螣閸喚鍘梺閫炲苯鍘哥紒鈧笟鈧俊鐢稿箣閻樺啿顏? %v", err)
}
}
func (e *AfterSalesIssueEngine) extractMessage(clientID int32, raw map[string]interface{}) (AfterSalesMessage, bool) {
autoEngine := getAutoReplyEngine()
autoEngine.observeGroupNames(clientID, raw)
msg := extractAutoReplyMessage(clientID, raw)
autoEngine.enrichAutoReplyMessage(&msg, time.Now())
if !msg.IsGroup || strings.TrimSpace(msg.ConversationID) == "" {
return AfterSalesMessage{}, false
}
identity := autoEngine.classifySenderIdentity(msg)
imagePath, imageRef := extractAfterSalesImageFromMessage(msg, raw)
messageID := strings.TrimSpace(firstNonEmpty(msg.ServerID, msg.LocalID))
if messageID == "" {
messageID = stableAfterSalesMessageID(clientID, msg, imagePath, imageRef)
}
fileAttachment := extractAfterSalesFileFromMessage(msg, raw, messageID)
messageType := msg.MessageType
if imagePath != "" || imageRef != "" || looksLikeImageMessage(raw) {
messageType = "image"
}
if fileAttachment.Path != "" || fileAttachment.Ref != "" || looksLikeAfterSalesFileMessage(msg, raw) {
messageType = "file"
}
if messageType != "text" && messageType != "image" && messageType != "file" {
return AfterSalesMessage{}, false
}
content := strings.TrimSpace(msg.Content)
if content == "" && messageType == "image" {
content = "image"
}
if content == "" && messageType == "file" {
content = "file"
if fileAttachment.Name != "" {
content += ": " + fileAttachment.Name
}
}
if content == "" && imagePath == "" && imageRef == "" && fileAttachment.Path == "" && fileAttachment.Ref == "" {
return AfterSalesMessage{}, false
}
sendAt := parseAfterSalesSendTime(msg.SendTime)
if sendAt <= 0 {
sendAt = time.Now().Unix()
}
senderName := strings.TrimSpace(firstNonEmpty(identity.Name, msg.FromNickName))
if senderName == "" {
senderName = "unknown customer"
}
roomName := strings.TrimSpace(msg.GroupName)
if roomName == "" || roomName == msg.ConversationID {
if resolved := autoEngine.ResolveGroupName(msg.ConversationID); resolved != "" {
roomName = resolved
}
}
if roomName == "" {
roomName = msg.ConversationID
}
return AfterSalesMessage{
MessageID: messageID,
ClientID: clientID,
ConversationID: msg.ConversationID,
RoomName: roomName,
SenderUserID: msg.FromWxID,
SenderName: senderName,
SenderIdentity: identity.Kind,
Content: content,
MessageType: messageType,
ImagePath: imagePath,
ImageRef: imageRef,
FilePath: fileAttachment.Path,
FileRef: fileAttachment.Ref,
FileName: fileAttachment.Name,
FileContent: fileAttachment.Content,
FileExtractStatus: fileAttachment.ExtractStatus,
SendTime: sendAt,
ReceivedAt: time.Now().Unix(),
}, true
}
func (e *AfterSalesIssueEngine) triggerCollectAsync(conversationID string, manual bool) (bool, string) {
conversationID = normalizeAfterSalesCollectConversationID(conversationID)
if manual {
e.mu.Lock()
messages := append([]AfterSalesMessage(nil), e.messages...)
e.mu.Unlock()
if len(filterAfterSalesMessages(messages, 0, time.Now().Unix(), conversationID)) == 0 {
return true, afterSalesCollectEmptyMessage(conversationID, manual, 0)
}
}
e.mu.Lock()
if e.state.Collecting {
e.mu.Unlock()
return false, "after-sales collection is already running"
}
e.state.Collecting = true
e.state.LastError = ""
e.updateStateMessageCountLocked()
_ = e.saveStateLocked()
e.mu.Unlock()
go e.collectLockedAsync(conversationID, manual)
if manual && conversationID != "" {
if name := getAutoReplyEngine().ResolveGroupName(conversationID); name != "" {
return true, fmt.Sprintf("started after-sales collection for group %s", name)
}
return true, "started after-sales collection for selected group"
}
return true, "started after-sales collection"
}
func (e *AfterSalesIssueEngine) collectLockedAsync(conversationID string, manual bool) {
prewarmAfterSalesGroupNames()
added, scanned, err := e.collectNow(conversationID, manual)
e.mu.Lock()
defer e.mu.Unlock()
e.state.Collecting = false
e.state.LastAddedCount = added
e.state.LastCollectedAt = time.Now().Unix()
if err != nil {
e.state.LastError = err.Error()
} else {
e.state.LastError = afterSalesCollectEmptyMessage(conversationID, manual, scanned)
if !manual {
e.state.LastCollectAt = time.Now().Unix()
}
e.repairIssuesLocked()
}
e.updateStateMessageCountLocked()
if saveErr := e.saveStateLocked(); saveErr != nil && globalLogger != nil {
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閻撳倿鏌涢妷顔煎闁艰尙濞€閺岋絽螣閸喚鍘梺閫炲苯鍘哥紒鈧笟鈧俊鐢稿箣閻樺啿顏? %v", saveErr)
}
}
func (e *AfterSalesIssueEngine) collectNow(conversationID string, manual bool) (int, int, error) {
conversationID = normalizeAfterSalesCollectConversationID(conversationID)
e.mu.Lock()
state := e.state
messages := append([]AfterSalesMessage(nil), e.messages...)
issues := append([]AfterSalesIssue(nil), e.issues...)
e.mu.Unlock()
now := time.Now()
from := int64(0)
if !manual {
from = state.LastCollectAt
if from <= 0 {
from = now.Add(-afterSalesFirstCollectWindow).Unix()
}
}
targets := filterAfterSalesMessages(messages, from, now.Unix(), conversationID)
if len(targets) == 0 {
return 0, 0, nil
}
cfg := getAutoReplyEngine().getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
return 0, len(targets), fmt.Errorf("AI config is incomplete, cannot collect after-sales issues")
}
existingFingerprints := make(map[string]AfterSalesIssue)
for _, issue := range issues {
if issue.Fingerprint != "" {
existingFingerprints[issue.Fingerprint] = issue
}
}
added := 0
for _, batch := range batchAfterSalesMessages(targets, afterSalesBatchSize) {
candidates, err := afterSalesAICollector(cfg.AI, batch)
if err != nil {
return added, len(targets), err
}
added += e.mergeAIIssueCandidates(candidates, batch, existingFingerprints)
}
return added, len(targets), nil
}
func prewarmAfterSalesGroupNames() {
engine := getAutoReplyEngine()
done := make(chan struct{})
go func() {
_ = engine.refreshIdentityGroups("after_sales_collect")
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
}
func (e *AfterSalesIssueEngine) mergeAIIssueCandidates(candidates []afterSalesAIIssueCandidate, batch []AfterSalesMessage, existing map[string]AfterSalesIssue) int {
if len(candidates) == 0 {
return 0
}
messageByID := make(map[string]AfterSalesMessage)
for _, msg := range batch {
messageByID[msg.MessageID] = msg
}
batchID := newAfterSalesID()
now := time.Now().Local().Format(time.RFC3339)
added := 0
e.mu.Lock()
for _, candidate := range candidates {
issueContent := strings.TrimSpace(candidate.IssueContent)
if issueContent == "" {
continue
}
sourceIDs := uniqueNonEmptyStrings(candidate.SourceMessageIDs)
seed := firstCandidateMessage(sourceIDs, batch, messageByID)
customerUserID := strings.TrimSpace(candidate.CustomerUserID)
if customerUserID == "" {
customerUserID = seed.SenderUserID
}
customerName := normalizeAfterSalesDisplayName(firstNonEmpty(candidate.CustomerName, seed.SenderName))
roomName := strings.TrimSpace(firstNonEmpty(candidate.RoomName, seed.RoomName))
conversationID := seed.ConversationID
if conversationID == "" && len(batch) > 0 {
conversationID = batch[0].ConversationID
}
if roomName == "" || roomName == conversationID || strings.HasPrefix(roomName, "R:") {
if resolved := getAutoReplyEngine().ResolveGroupName(conversationID); resolved != "" {
roomName = resolved
}
}
if roomName == "" {
roomName = conversationID
}
sourceClientID := seed.ClientID
sourceAccountUserID, sourceAccountName := getAutoReplyEngine().sourceAccountForClient(sourceClientID)
fingerprint := afterSalesFingerprint(conversationID, customerUserID, issueContent)
if existingIssue, ok := existing[fingerprint]; ok {
if existingIssue.Status == afterSalesIssueStatusResolved || existingIssue.Status == afterSalesIssueStatusIgnored || existingIssue.ID != "" {
continue
}
}
imagePaths, imageRefs := collectCandidateImages(candidate, sourceIDs, batch, messageByID)
fileAttachments := collectCandidateFileAttachments(sourceIDs, batch, messageByID)
issue := AfterSalesIssue{
ID: newAfterSalesID(),
CreatedAt: now,
UpdatedAt: now,
ConversationID: conversationID,
RoomName: roomName,
SourceClientID: sourceClientID,
SourceAccountUserID: sourceAccountUserID,
SourceAccountName: sourceAccountName,
CustomerUserID: customerUserID,
CustomerName: customerName,
IssueContent: issueContent,
ImagePaths: imagePaths,
ImageRefs: imageRefs,
FileAttachments: fileAttachments,
AISuggestion: strings.TrimSpace(candidate.AISuggestion),
Status: afterSalesIssueStatusPending,
SourceMessageIDs: sourceIDs,
Fingerprint: fingerprint,
CollectBatchID: batchID,
AIConfidence: candidate.Confidence,
AISuggestionEdited: false,
}
if len(issue.SourceMessageIDs) == 0 {
issue.SourceMessageIDs = messageIDsForBatch(batch)
}
e.issues = append(e.issues, issue)
existing[fingerprint] = issue
added++
}
if added > 0 {
if err := e.saveIssuesLocked(); err != nil && globalLogger != nil {
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈闁绘劖鍨濋弶顓㈡煟? %v", err)
}
}
shouldRefreshDispatch := added > 0
e.mu.Unlock()
if shouldRefreshDispatch {
e.refreshDispatchAssignmentsAsync()
}
return added
}
func (e *AfterSalesIssueEngine) autoCollectLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
e.mu.Lock()
enabled := e.state.AutoCollectEnabled
collecting := e.state.Collecting
last := e.state.LastCollectedAt
e.mu.Unlock()
if !enabled || collecting {
continue
}
if last > 0 && time.Since(time.Unix(last, 0)) < afterSalesAutoCollectEvery {
continue
}
e.triggerCollectAsync("", false)
}
}
func (e *AfterSalesIssueEngine) trimMessagesLocked(now time.Time) {
cutoff := now.Add(-afterSalesMessageBufferHours * time.Hour).Unix()
next := e.messages[:0]
for _, msg := range e.messages {
ts := msg.SendTime
if ts <= 0 {
ts = msg.ReceivedAt
}
if ts >= cutoff {
next = append(next, msg)
}
}
e.messages = next
}
func filterAfterSalesMessages(messages []AfterSalesMessage, from int64, to int64, conversationID string) []AfterSalesMessage {
conversationID = normalizeAfterSalesCollectConversationID(conversationID)
result := make([]AfterSalesMessage, 0, len(messages))
for _, msg := range messages {
if conversationID != "" && strings.TrimSpace(msg.ConversationID) != conversationID {
continue
}
ts := msg.SendTime
if ts <= 0 {
ts = msg.ReceivedAt
}
if ts > from && ts <= to {
result = append(result, msg)
}
}
sort.Slice(result, func(i, j int) bool {
if result[i].ConversationID != result[j].ConversationID {
return result[i].ConversationID < result[j].ConversationID
}
return result[i].SendTime < result[j].SendTime
})
return result
}
func normalizeAfterSalesCollectConversationID(conversationID string) string {
conversationID = strings.TrimSpace(conversationID)
if strings.EqualFold(conversationID, afterSalesManualCollectAll) {
return ""
}
return conversationID
}
func afterSalesCollectEmptyMessage(conversationID string, manual bool, scanned int) string {
if !manual || scanned > 0 {
return ""
}
if normalizeAfterSalesCollectConversationID(conversationID) != "" {
return "selected group has no cached messages to analyze"
}
return "no cached messages to analyze"
}
func batchAfterSalesMessages(messages []AfterSalesMessage, size int) [][]AfterSalesMessage {
if size <= 0 {
size = afterSalesBatchSize
}
grouped := make(map[string][]AfterSalesMessage)
keys := make([]string, 0)
for _, msg := range messages {
key := msg.ConversationID
if _, exists := grouped[key]; !exists {
keys = append(keys, key)
}
grouped[key] = append(grouped[key], msg)
}
sort.Strings(keys)
var batches [][]AfterSalesMessage
for _, key := range keys {
items := grouped[key]
sort.Slice(items, func(i, j int) bool { return items[i].SendTime < items[j].SendTime })
for start := 0; start < len(items); start += size {
end := start + size
if end > len(items) {
end = len(items)
}
batches = append(batches, append([]AfterSalesMessage(nil), items[start:end]...))
}
}
return batches
}
func firstCandidateMessage(sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) AfterSalesMessage {
for _, id := range sourceIDs {
if msg, ok := byID[id]; ok && msg.SenderIdentity != senderIdentityInternal {
return msg
}
}
for _, id := range sourceIDs {
if msg, ok := byID[id]; ok {
return msg
}
}
for _, msg := range batch {
if msg.SenderIdentity != senderIdentityInternal {
return msg
}
}
if len(batch) > 0 {
return batch[0]
}
return AfterSalesMessage{}
}
func collectCandidateImages(candidate afterSalesAIIssueCandidate, sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) ([]string, []string) {
paths := append([]string(nil), candidate.ImagePaths...)
refs := append([]string(nil), candidate.ImageRefs...)
addMessageImage := func(msg AfterSalesMessage) {
if msg.ImagePath != "" {
paths = append(paths, msg.ImagePath)
}
if msg.ImageRef != "" {
refs = append(refs, msg.ImageRef)
}
}
if len(sourceIDs) > 0 {
for _, id := range sourceIDs {
if msg, ok := byID[id]; ok {
addMessageImage(msg)
}
}
} else {
for _, msg := range batch {
addMessageImage(msg)
}
}
return uniqueExistingImagePaths(paths), uniqueNonEmptyStrings(refs)
}
func collectCandidateFileAttachments(sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) []AfterSalesFileAttachment {
files := make([]AfterSalesFileAttachment, 0)
addMessageFile := func(msg AfterSalesMessage) {
file := AfterSalesFileAttachment{
Name: msg.FileName,
Path: msg.FilePath,
Ref: msg.FileRef,
Content: msg.FileContent,
ExtractStatus: msg.FileExtractStatus,
SourceMessageID: msg.MessageID,
}
file = normalizeAfterSalesFileAttachment(file)
if file.Path != "" || file.Ref != "" || file.Content != "" {
files = append(files, file)
}
}
if len(sourceIDs) > 0 {
for _, id := range sourceIDs {
if msg, ok := byID[id]; ok {
addMessageFile(msg)
}
}
} else {
for _, msg := range batch {
addMessageFile(msg)
}
}
return normalizeAfterSalesFileAttachments(files)
}
func messageIDsForBatch(batch []AfterSalesMessage) []string {
result := make([]string, 0, len(batch))
for _, msg := range batch {
result = append(result, msg.MessageID)
}
return uniqueNonEmptyStrings(result)
}
func uniqueExistingImagePaths(items []string) []string {
seen := make(map[string]struct{})
result := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, exists := seen[item]; exists {
continue
}
if _, err := os.Stat(item); err == nil {
seen[item] = struct{}{}
result = append(result, item)
}
}
return result
}
func afterSalesFingerprint(conversationID string, customerUserID string, content string) string {
normalized := normalizeAfterSalesIssueText(content)
raw := strings.Join([]string{strings.TrimSpace(conversationID), strings.TrimSpace(customerUserID), normalized}, "|")
sum := sha1.Sum([]byte(raw))
return hex.EncodeToString(sum[:])
}
func normalizeAfterSalesIssueText(text string) string {
text = strings.ToLower(strings.TrimSpace(text))
replacer := strings.NewReplacer("\r", "", "\n", "", "\t", "", " ", "", "", ",", "。", ".", "", "?", "", "!")
return replacer.Replace(text)
}
func stableAfterSalesMessageID(clientID int32, msg autoReplyMessage, imagePath string, imageRef string) string {
raw := fmt.Sprintf("%d|%s|%s|%s|%s|%s|%s|%s|%s", clientID, msg.ConversationID, msg.SendTime, msg.FromWxID, msg.Content, imagePath, imageRef, msg.MediaFileName, msg.MediaFileID)
sum := sha1.Sum([]byte(raw))
return hex.EncodeToString(sum[:])
}
func newAfterSalesID() string {
return uuid.NewString()
}
func parseAfterSalesSendTime(raw string) int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
var n int64
if err := json.Unmarshal([]byte(raw), &n); err == nil {
if n > 1000000000000 {
n = n / 1000
}
return n
}
if _, err := fmt.Sscanf(raw, "%d", &n); err == nil {
if n > 1000000000000 {
n = n / 1000
}
return n
}
return 0
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}