Initial qiwei secondary development handoff

This commit is contained in:
2026-06-23 21:11:20 +08:00
commit 858cb68f4f
207 changed files with 52782 additions and 0 deletions

View File

@@ -0,0 +1,585 @@
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 ""
}