Files
qiweimanager-master/helper/after_sales_import.go

342 lines
11 KiB
Go
Raw Permalink 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"
"fmt"
"regexp"
"strings"
"time"
)
var (
afterSalesHistoryHeaderPatterns = []*regexp.Regexp{
regexp.MustCompile(`^(.{1,40}?)\s+(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
regexp.MustCompile(`^(.{1,40}?)\s+(\d{1,2}[-/月]\d{1,2}(?:日)?\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
regexp.MustCompile(`^(.{1,40}?)\s+((?:今天|昨天|前天)\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
regexp.MustCompile(`^(.{1,40}?)\s+(\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
regexp.MustCompile(`^(.{1,40}?)[:]\s*(.*)$`),
}
afterSalesHistorySystemLine = regexp.MustCompile(`^(你|您)?(修改群名为|撤回了一条消息|加入群聊|退出群聊|邀请|移出|已读|以下为新消息)`)
)
type afterSalesHistoryParsedLine struct {
Sender string
Content string
SendAt int64
}
type afterSalesHistoryCollectResult struct {
Imported int
Segments int
Added int
}
func (e *AfterSalesIssueEngine) importHistoryAndCollect(req AfterSalesHistoryImportRequest) (int, int, error) {
result, err := e.importHistoryAndCollectDetailed(req)
return result.Imported, result.Added, err
}
func (e *AfterSalesIssueEngine) importHistoryAndCollectDetailed(req AfterSalesHistoryImportRequest) (afterSalesHistoryCollectResult, error) {
conversationID := normalizeAfterSalesCollectConversationID(req.ConversationID)
roomName := strings.TrimSpace(req.RoomName)
if conversationID == "" {
return afterSalesHistoryCollectResult{}, fmt.Errorf("请选择要导入的群聊")
}
if roomName == "" {
roomName = getAutoReplyEngine().ResolveGroupName(conversationID)
}
if roomName == "" {
roomName = conversationID
}
rawText := strings.TrimSpace(req.RawText)
if rawText == "" {
return afterSalesHistoryCollectResult{}, fmt.Errorf("请先粘贴企微群聊历史消息")
}
messages := parseAfterSalesHistoryMessages(conversationID, roomName, rawText, time.Now())
if len(messages) == 0 {
return afterSalesHistoryCollectResult{}, fmt.Errorf("未能从粘贴内容中解析出可导入的历史消息")
}
imported := e.mergeHistoryMessages(messages)
if imported == 0 {
return afterSalesHistoryCollectResult{}, nil
}
added, segments, err := e.collectHistoryMessages(conversationID, messages)
e.mu.Lock()
e.state.LastAddedCount = added
e.state.LastCollectedAt = time.Now().Unix()
if err != nil {
e.state.LastError = err.Error()
} else {
e.state.LastError = ""
e.repairIssuesLocked()
}
e.updateStateMessageCountLocked()
_ = e.saveStateLocked()
e.mu.Unlock()
return afterSalesHistoryCollectResult{Imported: imported, Segments: segments, Added: added}, err
}
func (e *AfterSalesIssueEngine) mergeHistoryMessages(messages []AfterSalesMessage) int {
now := time.Now()
e.mu.Lock()
defer e.mu.Unlock()
existing := make(map[string]int, len(e.messages))
for i, msg := range e.messages {
if strings.TrimSpace(msg.MessageID) != "" {
existing[msg.MessageID] = i
}
}
imported := 0
for _, msg := range messages {
if msg.MessageID == "" {
continue
}
if idx, ok := existing[msg.MessageID]; ok {
e.messages[idx] = msg
continue
}
e.messages = append(e.messages, msg)
existing[msg.MessageID] = len(e.messages) - 1
imported++
}
e.trimMessagesLocked(now)
e.updateStateMessageCountLocked()
if imported > 0 {
_ = e.saveMessagesLocked()
_ = e.saveStateLocked()
}
return imported
}
func (e *AfterSalesIssueEngine) collectHistoryMessages(conversationID string, importedMessages []AfterSalesMessage) (int, int, error) {
e.mu.Lock()
issues := append([]AfterSalesIssue(nil), e.issues...)
e.mu.Unlock()
cfg := getAutoReplyEngine().getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
return 0, 0, fmt.Errorf("AI 配置未完整,无法收集售后问题")
}
existingFingerprints := make(map[string]AfterSalesIssue)
for _, issue := range issues {
if issue.Fingerprint != "" {
existingFingerprints[issue.Fingerprint] = issue
}
}
segments := segmentAfterSalesHistoryMessages(importedMessages)
added := 0
for _, segment := range segments {
if len(segment) == 0 {
continue
}
candidates, err := afterSalesAICollector(cfg.AI, segment)
if err != nil {
return added, len(segments), err
}
added += e.mergeAIIssueCandidates(candidates, segment, existingFingerprints)
}
return added, len(segments), nil
}
func segmentAfterSalesHistoryMessages(messages []AfterSalesMessage) [][]AfterSalesMessage {
var segments [][]AfterSalesMessage
for _, msg := range messages {
if !historyMessageLooksLikeIssue(msg) {
continue
}
segments = append(segments, []AfterSalesMessage{msg})
}
if len(segments) == 0 && len(messages) > 0 {
return batchAfterSalesMessages(messages, 6)
}
return segments
}
func historyMessageLooksLikeIssue(msg AfterSalesMessage) bool {
content := strings.TrimSpace(msg.Content)
if content == "" {
return false
}
if msg.SenderIdentity == senderIdentityInternal {
return false
}
lower := strings.ToLower(content)
noise := []string{"你好", "谢谢", "收到", "ok", "好的", "辛苦", "师傅好"}
for _, item := range noise {
if lower == strings.ToLower(item) {
return false
}
}
keywords := []string{
"坏", "故障", "报错", "不", "没", "无法", "不能", "失败", "异常", "卡", "掉线", "没电", "电压", "短接", "测试", "维修", "更换", "装不上", "启动", "低于",
}
for _, keyword := range keywords {
if strings.Contains(content, keyword) {
return true
}
}
return len([]rune(content)) >= 18
}
func parseAfterSalesHistoryMessages(conversationID, roomName, rawText string, importedAt time.Time) []AfterSalesMessage {
lines := strings.Split(strings.ReplaceAll(rawText, "\r\n", "\n"), "\n")
parsed := make([]afterSalesHistoryParsedLine, 0)
var current *afterSalesHistoryParsedLine
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || afterSalesHistorySystemLine.MatchString(line) {
continue
}
next, ok := parseAfterSalesHistoryLine(line, importedAt)
if ok {
if current != nil {
parsed = append(parsed, *current)
}
current = &next
continue
}
if current == nil {
current = &afterSalesHistoryParsedLine{Sender: "未知客户", SendAt: importedAt.Unix(), Content: line}
} else {
current.Content = strings.TrimSpace(current.Content + "\n" + line)
}
}
if current != nil {
parsed = append(parsed, *current)
}
messages := make([]AfterSalesMessage, 0, len(parsed))
for i, item := range parsed {
content := strings.TrimSpace(item.Content)
if content == "" {
continue
}
messageType := "text"
imageRef := ""
if looksLikeHistoryImageText(content) {
messageType = "image"
imageRef = content
}
sendAt := item.SendAt
if sendAt <= 0 {
sendAt = importedAt.Unix()
}
senderName := cleanAfterSalesHistorySender(item.Sender)
if senderName == "" {
senderName = "未知客户"
}
messages = append(messages, AfterSalesMessage{
MessageID: stableAfterSalesHistoryMessageID(conversationID, senderName, content, sendAt, i),
ClientID: 0,
ConversationID: conversationID,
RoomName: roomName,
SenderUserID: "history:" + senderName,
SenderName: senderName,
SenderIdentity: inferAfterSalesHistorySenderIdentity(senderName, content),
Content: content,
MessageType: messageType,
ImageRef: imageRef,
SendTime: sendAt,
ReceivedAt: importedAt.Unix(),
})
}
return messages
}
func parseAfterSalesHistoryLine(line string, base time.Time) (afterSalesHistoryParsedLine, bool) {
for idx, pattern := range afterSalesHistoryHeaderPatterns {
match := pattern.FindStringSubmatch(line)
if len(match) == 0 {
continue
}
sender := cleanAfterSalesHistorySender(match[1])
if sender == "" || looksLikeHistoryTime(sender) {
continue
}
if idx == 4 {
return afterSalesHistoryParsedLine{Sender: sender, Content: strings.TrimSpace(match[2]), SendAt: base.Unix()}, true
}
return afterSalesHistoryParsedLine{Sender: sender, SendAt: parseAfterSalesHistoryTime(match[2], base), Content: strings.TrimSpace(match[3])}, true
}
return afterSalesHistoryParsedLine{}, false
}
func parseAfterSalesHistoryTime(text string, base time.Time) int64 {
text = strings.TrimSpace(text)
replacements := map[string]string{"年": "-", "月": "-", "日": "", "/": "-"}
for old, next := range replacements {
text = strings.ReplaceAll(text, old, next)
}
if regexp.MustCompile(`^\d{1,2}-\d{1,2}\s+`).MatchString(text) {
text = fmt.Sprintf("%d-%s", base.Year(), text)
}
formats := []string{
"2006-1-2 15:04:05",
"2006-1-2 15:04",
}
for _, format := range formats {
if ts, err := time.ParseInLocation(format, text, time.Local); err == nil {
return ts.Unix()
}
}
day := time.Date(base.Year(), base.Month(), base.Day(), 0, 0, 0, 0, base.Location())
switch {
case strings.HasPrefix(text, "昨天"):
day = day.AddDate(0, 0, -1)
text = strings.TrimSpace(strings.TrimPrefix(text, "昨天"))
case strings.HasPrefix(text, "前天"):
day = day.AddDate(0, 0, -2)
text = strings.TrimSpace(strings.TrimPrefix(text, "前天"))
case strings.HasPrefix(text, "今天"):
text = strings.TrimSpace(strings.TrimPrefix(text, "今天"))
}
if clock, err := time.ParseInLocation("15:04:05", text, base.Location()); err == nil {
return time.Date(day.Year(), day.Month(), day.Day(), clock.Hour(), clock.Minute(), clock.Second(), 0, base.Location()).Unix()
}
if clock, err := time.ParseInLocation("15:04", text, base.Location()); err == nil {
return time.Date(day.Year(), day.Month(), day.Day(), clock.Hour(), clock.Minute(), 0, 0, base.Location()).Unix()
}
return base.Unix()
}
func cleanAfterSalesHistorySender(sender string) string {
sender = strings.TrimSpace(sender)
sender = strings.TrimPrefix(sender, "@")
sender = strings.ReplaceAll(sender, "未命名企业", "")
sender = regexp.MustCompile(`\s+`).ReplaceAllString(sender, " ")
return strings.TrimSpace(sender)
}
func inferAfterSalesHistorySenderIdentity(senderName string, content string) string {
text := senderName + " " + content
internalHints := []string{"郑工", "梁师傅", "师傅", "工程师", "客服", "售后"}
for _, hint := range internalHints {
if strings.Contains(text, hint) && strings.Contains(content, "按") {
return senderIdentityInternal
}
}
return senderIdentityExternal
}
func looksLikeHistoryTime(text string) bool {
return regexp.MustCompile(`^\d{1,4}[-/:年月日\s]`).MatchString(strings.TrimSpace(text))
}
func looksLikeHistoryImageText(content string) bool {
content = strings.TrimSpace(strings.Trim(content, "[]【】"))
return content == "图片" || strings.EqualFold(content, "image")
}
func stableAfterSalesHistoryMessageID(conversationID, senderName, content string, sendAt int64, index int) string {
sum := sha1.Sum([]byte(fmt.Sprintf("history|%s|%s|%d|%d|%s", conversationID, senderName, sendAt, index, content)))
return "history:" + hex.EncodeToString(sum[:])
}
func afterSalesHistoryImportMessage(imported, segments, added int) string {
if imported == 0 {
return "历史消息已存在,没有新增导入"
}
return fmt.Sprintf("已同步 %d 条历史消息,分析 %d 段,新增 %d 条售后问题", imported, segments, added)
}