Files
qiweimanager-master/helper/after_sales_knowledge.go

373 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 (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
var afterSalesKnowledgeRebuildHook = func() {
if _, err := getAutoReplyEngine().rebuildKnowledgeIndex(); err != nil && globalLogger != nil {
globalLogger.Warn("[售后知识库] 重建自动客服知识索引失败: %v", err)
}
}
func (e *AfterSalesIssueEngine) resolveIssue(issueID string, resolutionContent string) (AfterSalesKnowledgeCase, error) {
issueID = strings.TrimSpace(issueID)
resolutionContent = strings.TrimSpace(resolutionContent)
if issueID == "" {
return AfterSalesKnowledgeCase{}, fmt.Errorf("issueId为空")
}
if resolutionContent == "" {
return AfterSalesKnowledgeCase{}, fmt.Errorf("请填写最终处理方案")
}
now := time.Now().Local().Format(time.RFC3339)
e.mu.Lock()
index := -1
for i := range e.issues {
if e.issues[i].ID == issueID {
index = i
break
}
}
if index < 0 {
e.mu.Unlock()
return AfterSalesKnowledgeCase{}, fmt.Errorf("问题不存在")
}
issue := e.issues[index]
normalizeAfterSalesDispatchFields(&issue)
issue.Status = afterSalesIssueStatusResolved
issue.ResolutionContent = resolutionContent
if strings.TrimSpace(issue.ResolvedAt) == "" {
issue.ResolvedAt = now
}
issue.UpdatedAt = now
knowledgeCase, err := upsertAfterSalesKnowledgeCase(issue, now)
if err != nil {
e.mu.Unlock()
return AfterSalesKnowledgeCase{}, err
}
issue.KnowledgeArchivedAt = knowledgeCase.KnowledgeArchivedAt
issue.KnowledgeSourcePath = knowledgeCase.MarkdownPath
e.issues[index] = issue
if err := e.saveIssuesLocked(); err != nil {
e.mu.Unlock()
return AfterSalesKnowledgeCase{}, err
}
e.mu.Unlock()
triggerAfterSalesKnowledgeRebuild()
return knowledgeCase, nil
}
func listAfterSalesKnowledgeCases() ([]AfterSalesKnowledgeCase, error) {
store, err := readAfterSalesKnowledgeCasesFile()
if err != nil {
return nil, err
}
cases := append([]AfterSalesKnowledgeCase(nil), store.Cases...)
for i := range cases {
cases[i] = normalizeAfterSalesKnowledgeCase(cases[i])
cases[i].MissingMarkdown = strings.TrimSpace(cases[i].MarkdownPath) == "" || !fileExists(cases[i].MarkdownPath)
}
sort.Slice(cases, func(i, j int) bool {
if cases[i].ResolvedAt != cases[j].ResolvedAt {
return cases[i].ResolvedAt > cases[j].ResolvedAt
}
return cases[i].IssueID > cases[j].IssueID
})
return cases, nil
}
func (e *AfterSalesIssueEngine) syncResolvedKnowledgeCases() error {
now := time.Now().Local().Format(time.RFC3339)
store, err := readAfterSalesKnowledgeCasesFile()
if err != nil {
return err
}
caseByIssueID := make(map[string]int)
for i, item := range store.Cases {
if id := strings.TrimSpace(item.IssueID); id != "" {
caseByIssueID[id] = i
}
}
e.mu.Lock()
changedIssues := false
changedCases := false
for i := range e.issues {
issue := &e.issues[i]
if strings.TrimSpace(issue.ID) == "" || normalizeAfterSalesStatus(issue.Status) != afterSalesIssueStatusResolved {
continue
}
if _, exists := caseByIssueID[issue.ID]; exists && strings.TrimSpace(issue.KnowledgeSourcePath) != "" {
continue
}
resolution := strings.TrimSpace(issue.ResolutionContent)
if resolution == "" {
resolution = strings.TrimSpace(issue.AISuggestion)
}
if resolution == "" {
resolution = strings.TrimSpace(issue.IssueContent)
}
if resolution == "" {
continue
}
if strings.TrimSpace(issue.ResolvedAt) == "" {
issue.ResolvedAt = firstNonEmpty(issue.UpdatedAt, now)
changedIssues = true
}
if strings.TrimSpace(issue.ResolutionContent) == "" {
issue.ResolutionContent = resolution
changedIssues = true
}
if strings.TrimSpace(issue.KnowledgeArchivedAt) == "" {
issue.KnowledgeArchivedAt = now
changedIssues = true
}
issue.KnowledgeSourcePath = afterSalesKnowledgeMarkdownPath(issue.ID)
changedIssues = true
knowledgeCase := knowledgeCaseFromIssue(*issue, issue.KnowledgeArchivedAt)
if err := writeAfterSalesKnowledgeMarkdown(knowledgeCase); err != nil {
e.mu.Unlock()
return err
}
if idx, exists := caseByIssueID[issue.ID]; exists {
store.Cases[idx] = knowledgeCase
} else {
store.Cases = append(store.Cases, knowledgeCase)
caseByIssueID[issue.ID] = len(store.Cases) - 1
}
changedCases = true
}
if changedCases {
sortAfterSalesKnowledgeCases(store.Cases)
if err := writeAfterSalesKnowledgeCasesFile(store); err != nil {
e.mu.Unlock()
return err
}
}
if changedIssues {
if err := e.saveIssuesLocked(); err != nil {
e.mu.Unlock()
return err
}
}
e.mu.Unlock()
if changedCases {
triggerAfterSalesKnowledgeRebuild()
}
return nil
}
func upsertAfterSalesKnowledgeCase(issue AfterSalesIssue, archivedAt string) (AfterSalesKnowledgeCase, error) {
issue.ID = strings.TrimSpace(issue.ID)
if issue.ID == "" {
return AfterSalesKnowledgeCase{}, fmt.Errorf("issueId为空")
}
if strings.TrimSpace(issue.ResolutionContent) == "" {
return AfterSalesKnowledgeCase{}, fmt.Errorf("请填写最终处理方案")
}
if strings.TrimSpace(issue.ResolvedAt) == "" {
issue.ResolvedAt = archivedAt
}
knowledgeCase := knowledgeCaseFromIssue(issue, archivedAt)
if err := writeAfterSalesKnowledgeMarkdown(knowledgeCase); err != nil {
return AfterSalesKnowledgeCase{}, err
}
store, err := readAfterSalesKnowledgeCasesFile()
if err != nil {
return AfterSalesKnowledgeCase{}, err
}
replaced := false
for i := range store.Cases {
if store.Cases[i].IssueID == knowledgeCase.IssueID {
store.Cases[i] = knowledgeCase
replaced = true
break
}
}
if !replaced {
store.Cases = append(store.Cases, knowledgeCase)
}
sort.Slice(store.Cases, func(i, j int) bool {
if store.Cases[i].ResolvedAt != store.Cases[j].ResolvedAt {
return store.Cases[i].ResolvedAt > store.Cases[j].ResolvedAt
}
return store.Cases[i].IssueID > store.Cases[j].IssueID
})
if err := writeAfterSalesKnowledgeCasesFile(store); err != nil {
return AfterSalesKnowledgeCase{}, err
}
return knowledgeCase, nil
}
func knowledgeCaseFromIssue(issue AfterSalesIssue, archivedAt string) AfterSalesKnowledgeCase {
knowledgeCase := AfterSalesKnowledgeCase{
IssueID: issue.ID,
CreatedAt: issue.CreatedAt,
UpdatedAt: issue.UpdatedAt,
ResolvedAt: issue.ResolvedAt,
KnowledgeArchivedAt: archivedAt,
ConversationID: issue.ConversationID,
RoomName: issue.RoomName,
CustomerUserID: issue.CustomerUserID,
CustomerName: issue.CustomerName,
IssueContent: issue.IssueContent,
AISuggestion: issue.AISuggestion,
ResolutionContent: issue.ResolutionContent,
AssignedEngineerID: issue.AssignedEngineerID,
AssignedEngineerName: issue.AssignedEngineerName,
ImageCount: len(issue.ImagePaths) + len(issue.ImageRefs),
MarkdownPath: afterSalesKnowledgeMarkdownPath(issue.ID),
}
return normalizeAfterSalesKnowledgeCase(knowledgeCase)
}
func writeAfterSalesKnowledgeMarkdown(knowledgeCase AfterSalesKnowledgeCase) error {
if err := os.MkdirAll(filepath.Dir(knowledgeCase.MarkdownPath), 0755); err != nil {
return err
}
return os.WriteFile(knowledgeCase.MarkdownPath, []byte(renderAfterSalesKnowledgeMarkdown(knowledgeCase)), 0644)
}
func sortAfterSalesKnowledgeCases(cases []AfterSalesKnowledgeCase) {
sort.Slice(cases, func(i, j int) bool {
if cases[i].ResolvedAt != cases[j].ResolvedAt {
return cases[i].ResolvedAt > cases[j].ResolvedAt
}
return cases[i].IssueID > cases[j].IssueID
})
}
func normalizeAfterSalesKnowledgeCase(item AfterSalesKnowledgeCase) AfterSalesKnowledgeCase {
item.IssueID = strings.TrimSpace(item.IssueID)
item.CustomerName = normalizeAfterSalesDisplayName(item.CustomerName)
if strings.TrimSpace(item.MarkdownPath) == "" && item.IssueID != "" {
item.MarkdownPath = afterSalesKnowledgeMarkdownPath(item.IssueID)
}
return item
}
func readAfterSalesKnowledgeCasesFile() (afterSalesKnowledgeCasesFile, error) {
var store afterSalesKnowledgeCasesFile
if err := readJSONFile(afterSalesKnowledgeCasesPath(), &store); err != nil {
return store, err
}
for i := range store.Cases {
store.Cases[i] = normalizeAfterSalesKnowledgeCase(store.Cases[i])
}
return store, nil
}
func writeAfterSalesKnowledgeCasesFile(store afterSalesKnowledgeCasesFile) error {
return atomicWriteJSON(afterSalesKnowledgeCasesPath(), store)
}
func afterSalesKnowledgeCasesPath() string {
return resolveAutoReplyPath("config/after_sales_knowledge/cases.json")
}
func afterSalesKnowledgeMarkdownDir() string {
return resolveAutoReplyPath("config/knowledge/after_sales_cases")
}
func afterSalesKnowledgeMarkdownPath(issueID string) string {
return filepath.Join(afterSalesKnowledgeMarkdownDir(), safeAfterSalesKnowledgeFileID(issueID)+".md")
}
func safeAfterSalesKnowledgeFileID(value string) string {
value = strings.TrimSpace(value)
var builder strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '-' || r == '_':
builder.WriteRune(r)
}
}
if builder.Len() == 0 {
return fmt.Sprintf("issue-%d", time.Now().UnixNano())
}
return builder.String()
}
func renderAfterSalesKnowledgeMarkdown(item AfterSalesKnowledgeCase) string {
var builder strings.Builder
writeMarkdownLine := func(label string, value string) {
value = strings.TrimSpace(value)
if value == "" {
value = "-"
}
builder.WriteString(label)
builder.WriteString(value)
builder.WriteString("\n")
}
builder.WriteString("# 售后已处理案例\n\n")
writeMarkdownLine("问题ID", item.IssueID)
writeMarkdownLine("群聊:", item.RoomName)
writeMarkdownLine("客户:", item.CustomerName)
writeMarkdownLine("负责人:", displayAfterSalesKnowledgeEngineerName(item))
writeMarkdownLine("提出时间:", item.CreatedAt)
writeMarkdownLine("处理时间:", item.ResolvedAt)
writeMarkdownLine("图片数量:", fmt.Sprintf("%d", item.ImageCount))
builder.WriteString("\n## 问题\n\n")
builder.WriteString(strings.TrimSpace(item.IssueContent))
builder.WriteString("\n\n## 最终处理方案\n\n")
builder.WriteString(strings.TrimSpace(item.ResolutionContent))
builder.WriteString("\n\n## AI建议\n\n")
aiSuggestion := strings.TrimSpace(item.AISuggestion)
if aiSuggestion == "" {
aiSuggestion = "-"
}
builder.WriteString(aiSuggestion)
builder.WriteString("\n")
return builder.String()
}
func displayAfterSalesKnowledgeEngineerName(item AfterSalesKnowledgeCase) string {
if name := strings.TrimSpace(item.AssignedEngineerName); name != "" {
return name
}
if id := strings.TrimSpace(item.AssignedEngineerID); id != "" {
return id
}
return "-"
}
func revealAfterSalesKnowledgeCase(issueID string) (bool, string) {
issueID = strings.TrimSpace(issueID)
if issueID == "" {
return false, "issueId为空"
}
path := afterSalesKnowledgeMarkdownPath(issueID)
if !fileExists(path) {
return false, "知识案例文件不存在"
}
cmd := exec.Command("explorer.exe", path)
if err := cmd.Start(); err != nil {
return false, err.Error()
}
return true, "opened"
}
func triggerAfterSalesKnowledgeRebuild() {
go afterSalesKnowledgeRebuildHook()
}