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,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)
}