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