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