Initial qiwei secondary development handoff
This commit is contained in:
873
helper/after_sales_dispatch.go
Normal file
873
helper/after_sales_dispatch.go
Normal file
@@ -0,0 +1,873 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"qiweimanager/config"
|
||||
)
|
||||
|
||||
const (
|
||||
afterSalesDispatchUnassigned = "unassigned"
|
||||
afterSalesDispatchSuggested = "suggested"
|
||||
afterSalesDispatchAssigned = "assigned"
|
||||
|
||||
afterSalesNotifyNotSent = "not_sent"
|
||||
afterSalesNotifySent = "sent"
|
||||
afterSalesNotifyFailed = "failed"
|
||||
|
||||
defaultDispatchNotifyCooldownSeconds = 300
|
||||
defaultDispatchMinConfidence = 0.75
|
||||
|
||||
dispatchSourceAI = "ai_suggested"
|
||||
dispatchSourceManual = "manual_assigned"
|
||||
dispatchSourceRule = "rule_suggested"
|
||||
)
|
||||
|
||||
type AfterSalesEngineer struct {
|
||||
UserID string `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Remark string `json:"remark"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type AfterSalesDispatchRule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
EngineerUserID string `json:"engineerUserId"`
|
||||
EngineerName string `json:"engineerName"`
|
||||
ConversationIDs []string `json:"conversationIds"`
|
||||
CustomerNames []string `json:"customerNames"`
|
||||
ProductKeywords []string `json:"productKeywords"`
|
||||
IssueKeywords []string `json:"issueKeywords"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type AfterSalesDispatchConfig struct {
|
||||
Engineers []AfterSalesEngineer `json:"engineers"`
|
||||
Rules []AfterSalesDispatchRule `json:"rules"`
|
||||
NotifyTemplate string `json:"notifyTemplate"`
|
||||
NotifyCooldownSeconds int `json:"notifyCooldownSeconds"`
|
||||
AutoNotifyEnabled bool `json:"autoNotifyEnabled"`
|
||||
AutoNotifyMinConfidence float64 `json:"autoNotifyMinConfidence"`
|
||||
}
|
||||
|
||||
type AfterSalesDispatchSummary struct {
|
||||
Pending int `json:"pending"`
|
||||
Unassigned int `json:"unassigned"`
|
||||
Assigned int `json:"assigned"`
|
||||
Sent int `json:"sent"`
|
||||
Failed int `json:"failed"`
|
||||
TodayNew int `json:"todayNew"`
|
||||
NotSent int `json:"notSent"`
|
||||
}
|
||||
|
||||
type AfterSalesDispatchQueue struct {
|
||||
Issues []AfterSalesIssue `json:"issues"`
|
||||
Summary AfterSalesDispatchSummary `json:"summary"`
|
||||
Config AfterSalesDispatchConfig `json:"config"`
|
||||
}
|
||||
|
||||
type AfterSalesNotifyResult struct {
|
||||
IssueID string `json:"issueId"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type afterSalesDispatchMatch struct {
|
||||
Rule AfterSalesDispatchRule
|
||||
Priority int
|
||||
MatchedLabel string
|
||||
}
|
||||
|
||||
type afterSalesDispatchAIChoice struct {
|
||||
EngineerUserID string `json:"engineer_user_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
var (
|
||||
afterSalesDispatchMatcher = callAfterSalesDispatchAI
|
||||
afterSalesDispatchAIConfigSource = currentAfterSalesDispatchAIConfig
|
||||
)
|
||||
|
||||
func defaultAfterSalesDispatchConfig() AfterSalesDispatchConfig {
|
||||
return AfterSalesDispatchConfig{
|
||||
Engineers: []AfterSalesEngineer{},
|
||||
Rules: []AfterSalesDispatchRule{},
|
||||
NotifyTemplate: "",
|
||||
NotifyCooldownSeconds: defaultDispatchNotifyCooldownSeconds,
|
||||
AutoNotifyEnabled: false,
|
||||
AutoNotifyMinConfidence: defaultDispatchMinConfidence,
|
||||
}
|
||||
}
|
||||
|
||||
func afterSalesDispatchConfigPath() string {
|
||||
return resolveAutoReplyPath("config/after_sales_dispatch_config.json")
|
||||
}
|
||||
|
||||
func readAfterSalesDispatchConfig() (AfterSalesDispatchConfig, error) {
|
||||
cfg := defaultAfterSalesDispatchConfig()
|
||||
if err := readJSONFile(afterSalesDispatchConfigPath(), &cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
normalizeAfterSalesDispatchConfig(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func saveAfterSalesDispatchConfig(cfg AfterSalesDispatchConfig) error {
|
||||
normalizeAfterSalesDispatchConfig(&cfg)
|
||||
return atomicWriteJSON(afterSalesDispatchConfigPath(), cfg)
|
||||
}
|
||||
|
||||
func normalizeAfterSalesDispatchConfig(cfg *AfterSalesDispatchConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.NotifyCooldownSeconds <= 0 {
|
||||
cfg.NotifyCooldownSeconds = defaultDispatchNotifyCooldownSeconds
|
||||
}
|
||||
if cfg.AutoNotifyMinConfidence <= 0 || cfg.AutoNotifyMinConfidence > 1 {
|
||||
cfg.AutoNotifyMinConfidence = defaultDispatchMinConfidence
|
||||
}
|
||||
for i := range cfg.Engineers {
|
||||
cfg.Engineers[i].UserID = strings.TrimSpace(cfg.Engineers[i].UserID)
|
||||
cfg.Engineers[i].Name = strings.TrimSpace(cfg.Engineers[i].Name)
|
||||
cfg.Engineers[i].Description = strings.TrimSpace(cfg.Engineers[i].Description)
|
||||
cfg.Engineers[i].Remark = strings.TrimSpace(cfg.Engineers[i].Remark)
|
||||
if !cfg.Engineers[i].Enabled && cfg.Engineers[i].UserID != "" {
|
||||
cfg.Engineers[i].Enabled = true
|
||||
}
|
||||
}
|
||||
cfg.Engineers = uniqueAfterSalesEngineers(cfg.Engineers)
|
||||
for i := range cfg.Rules {
|
||||
rule := &cfg.Rules[i]
|
||||
rule.ID = strings.TrimSpace(rule.ID)
|
||||
if rule.ID == "" {
|
||||
rule.ID = newAfterSalesID()
|
||||
}
|
||||
rule.Name = strings.TrimSpace(rule.Name)
|
||||
rule.EngineerUserID = strings.TrimSpace(rule.EngineerUserID)
|
||||
rule.EngineerName = strings.TrimSpace(rule.EngineerName)
|
||||
rule.ConversationIDs = uniqueNonEmptyStrings(rule.ConversationIDs)
|
||||
rule.CustomerNames = uniqueNonEmptyStrings(rule.CustomerNames)
|
||||
rule.ProductKeywords = uniqueNonEmptyStrings(rule.ProductKeywords)
|
||||
rule.IssueKeywords = uniqueNonEmptyStrings(rule.IssueKeywords)
|
||||
if !rule.Enabled && rule.EngineerUserID != "" {
|
||||
rule.Enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueAfterSalesEngineers(items []AfterSalesEngineer) []AfterSalesEngineer {
|
||||
seen := make(map[string]bool)
|
||||
result := make([]AfterSalesEngineer, 0, len(items))
|
||||
for _, item := range items {
|
||||
item.UserID = strings.TrimSpace(item.UserID)
|
||||
if item.UserID == "" || seen[item.UserID] {
|
||||
continue
|
||||
}
|
||||
seen[item.UserID] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeAfterSalesDispatchFields(issue *AfterSalesIssue) {
|
||||
if issue == nil {
|
||||
return
|
||||
}
|
||||
issue.AssignedEngineerID = strings.TrimSpace(issue.AssignedEngineerID)
|
||||
issue.AssignedEngineerName = strings.TrimSpace(issue.AssignedEngineerName)
|
||||
issue.DispatchStatus = normalizeAfterSalesDispatchStatus(issue.DispatchStatus, issue.AssignedEngineerID)
|
||||
issue.NotifyStatus = normalizeAfterSalesNotifyStatus(issue.NotifyStatus)
|
||||
issue.DispatchReason = strings.TrimSpace(issue.DispatchReason)
|
||||
issue.DispatchRuleID = strings.TrimSpace(issue.DispatchRuleID)
|
||||
issue.DispatchSource = strings.TrimSpace(issue.DispatchSource)
|
||||
if issue.DispatchConfidence < 0 {
|
||||
issue.DispatchConfidence = 0
|
||||
}
|
||||
if issue.DispatchConfidence > 1 {
|
||||
issue.DispatchConfidence = 1
|
||||
}
|
||||
issue.NotifyError = strings.TrimSpace(issue.NotifyError)
|
||||
if issue.NotifyStatus == afterSalesNotifySent {
|
||||
issue.NotifyError = ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAfterSalesDispatchStatus(status string, engineerID string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case afterSalesDispatchSuggested:
|
||||
if strings.TrimSpace(engineerID) != "" {
|
||||
return afterSalesDispatchSuggested
|
||||
}
|
||||
case afterSalesDispatchAssigned:
|
||||
if strings.TrimSpace(engineerID) != "" {
|
||||
return afterSalesDispatchAssigned
|
||||
}
|
||||
}
|
||||
return afterSalesDispatchUnassigned
|
||||
}
|
||||
|
||||
func normalizeAfterSalesNotifyStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case afterSalesNotifySent:
|
||||
return afterSalesNotifySent
|
||||
case afterSalesNotifyFailed:
|
||||
return afterSalesNotifyFailed
|
||||
default:
|
||||
return afterSalesNotifyNotSent
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) dispatchQueue() (AfterSalesDispatchQueue, error) {
|
||||
cfg, err := readAfterSalesDispatchConfig()
|
||||
if err != nil {
|
||||
return AfterSalesDispatchQueue{}, err
|
||||
}
|
||||
e.refreshDispatchAssignments(cfg)
|
||||
e.mu.Lock()
|
||||
issues := append([]AfterSalesIssue(nil), e.issues...)
|
||||
e.mu.Unlock()
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
return issues[i].CreatedAt > issues[j].CreatedAt
|
||||
})
|
||||
return AfterSalesDispatchQueue{
|
||||
Issues: issues,
|
||||
Summary: summarizeAfterSalesDispatch(issues),
|
||||
Config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) refreshDispatchAssignments(cfg AfterSalesDispatchConfig) {
|
||||
candidates := e.dispatchAssignmentCandidates(cfg)
|
||||
for _, issue := range candidates {
|
||||
choice, err := dispatchIssueWithAI(cfg, issue)
|
||||
e.applyDispatchChoice(issue.ID, cfg, choice, err)
|
||||
}
|
||||
if cfg.AutoNotifyEnabled {
|
||||
for _, issueID := range e.autoNotifyCandidateIDs(cfg) {
|
||||
e.notifyEngineer(issueID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) refreshDispatchAssignmentsAsync() {
|
||||
go func() {
|
||||
cfg, err := readAfterSalesDispatchConfig()
|
||||
if err != nil {
|
||||
if globalLogger != nil {
|
||||
globalLogger.Warn("[工程师派单] 加载配置失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
e.refreshDispatchAssignments(cfg)
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) dispatchAssignmentCandidates(cfg AfterSalesDispatchConfig) []AfterSalesIssue {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
changed := e.applyLegacyDispatchSuggestionsLocked(cfg)
|
||||
if changed {
|
||||
_ = e.saveIssuesLocked()
|
||||
}
|
||||
result := make([]AfterSalesIssue, 0)
|
||||
for _, issue := range e.issues {
|
||||
if shouldDispatchWithAI(issue, cfg) {
|
||||
result = append(result, issue)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func shouldDispatchWithAI(issue AfterSalesIssue, cfg AfterSalesDispatchConfig) bool {
|
||||
if issue.Status != afterSalesIssueStatusPending {
|
||||
return false
|
||||
}
|
||||
if issue.DispatchSource == dispatchSourceManual || issue.DispatchStatus == afterSalesDispatchAssigned {
|
||||
return false
|
||||
}
|
||||
if issue.NotifyStatus == afterSalesNotifySent {
|
||||
return false
|
||||
}
|
||||
if len(enabledDispatchEngineers(cfg)) == 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func enabledDispatchEngineers(cfg AfterSalesDispatchConfig) []AfterSalesEngineer {
|
||||
result := make([]AfterSalesEngineer, 0, len(cfg.Engineers))
|
||||
for _, engineer := range cfg.Engineers {
|
||||
if !engineer.Enabled || strings.TrimSpace(engineer.UserID) == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(engineer.Description) == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, engineer)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dispatchIssueWithAI(cfg AfterSalesDispatchConfig, issue AfterSalesIssue) (afterSalesDispatchAIChoice, error) {
|
||||
aiCfg, err := afterSalesDispatchAIConfigSource()
|
||||
if err != nil {
|
||||
return afterSalesDispatchAIChoice{}, err
|
||||
}
|
||||
return afterSalesDispatchMatcher(aiCfg, issue, enabledDispatchEngineers(cfg))
|
||||
}
|
||||
|
||||
func currentAfterSalesDispatchAIConfig() (config.AIConfig, error) {
|
||||
appConfig := config.GetGlobalConfig()
|
||||
if appConfig == nil {
|
||||
return config.AIConfig{}, fmt.Errorf("AI 配置未加载")
|
||||
}
|
||||
appConfig.ApplyDefaults()
|
||||
aiCfg := appConfig.AutoReplyConfig.AI
|
||||
if strings.TrimSpace(aiCfg.BaseURL) == "" || strings.TrimSpace(aiCfg.Model) == "" {
|
||||
return config.AIConfig{}, fmt.Errorf("AI 配置未完整")
|
||||
}
|
||||
return aiCfg, nil
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) applyDispatchChoice(issueID string, cfg AfterSalesDispatchConfig, choice afterSalesDispatchAIChoice, matchErr error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for i := range e.issues {
|
||||
issue := &e.issues[i]
|
||||
if issue.ID != issueID {
|
||||
continue
|
||||
}
|
||||
before := *issue
|
||||
normalizeAfterSalesDispatchFields(issue)
|
||||
if !shouldDispatchWithAI(*issue, cfg) {
|
||||
return
|
||||
}
|
||||
if matchErr != nil {
|
||||
clearDispatchSuggestion(issue, "AI匹配失败: "+matchErr.Error())
|
||||
} else if choice.Confidence < cfg.AutoNotifyMinConfidence || strings.TrimSpace(choice.EngineerUserID) == "" {
|
||||
clearDispatchSuggestion(issue, strings.TrimSpace(firstNonEmpty(choice.Reason, "AI置信度低,需人工确认")))
|
||||
issue.DispatchConfidence = choice.Confidence
|
||||
issue.DispatchSource = dispatchSourceAI
|
||||
} else {
|
||||
issue.AssignedEngineerID = strings.TrimSpace(choice.EngineerUserID)
|
||||
issue.AssignedEngineerName = dispatchEngineerName(choice.EngineerUserID, "", cfg)
|
||||
issue.DispatchStatus = afterSalesDispatchSuggested
|
||||
issue.DispatchReason = strings.TrimSpace(choice.Reason)
|
||||
if issue.DispatchReason == "" {
|
||||
issue.DispatchReason = "AI根据工程师职责说明匹配"
|
||||
}
|
||||
issue.DispatchRuleID = ""
|
||||
issue.DispatchConfidence = choice.Confidence
|
||||
issue.DispatchSource = dispatchSourceAI
|
||||
}
|
||||
if !reflect.DeepEqual(*issue, before) {
|
||||
issue.UpdatedAt = time.Now().Local().Format(time.RFC3339)
|
||||
_ = e.saveIssuesLocked()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func clearDispatchSuggestion(issue *AfterSalesIssue, reason string) {
|
||||
issue.AssignedEngineerID = ""
|
||||
issue.AssignedEngineerName = ""
|
||||
issue.DispatchStatus = afterSalesDispatchUnassigned
|
||||
issue.DispatchReason = strings.TrimSpace(reason)
|
||||
issue.DispatchRuleID = ""
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) autoNotifyCandidateIDs(cfg AfterSalesDispatchConfig) []string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
result := make([]string, 0)
|
||||
for _, issue := range e.issues {
|
||||
if issue.Status != afterSalesIssueStatusPending {
|
||||
continue
|
||||
}
|
||||
if issue.NotifyStatus != afterSalesNotifyNotSent {
|
||||
continue
|
||||
}
|
||||
if issue.DispatchSource != dispatchSourceAI || issue.DispatchConfidence < cfg.AutoNotifyMinConfidence {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(issue.AssignedEngineerID) == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, issue.ID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func summarizeAfterSalesDispatch(issues []AfterSalesIssue) AfterSalesDispatchSummary {
|
||||
var summary AfterSalesDispatchSummary
|
||||
today := time.Now().Local().Format("2006-01-02")
|
||||
for _, issue := range issues {
|
||||
if issue.Status != afterSalesIssueStatusPending {
|
||||
continue
|
||||
}
|
||||
summary.Pending++
|
||||
if strings.HasPrefix(issue.CreatedAt, today) {
|
||||
summary.TodayNew++
|
||||
}
|
||||
switch normalizeAfterSalesDispatchStatus(issue.DispatchStatus, issue.AssignedEngineerID) {
|
||||
case afterSalesDispatchAssigned, afterSalesDispatchSuggested:
|
||||
summary.Assigned++
|
||||
default:
|
||||
summary.Unassigned++
|
||||
}
|
||||
switch normalizeAfterSalesNotifyStatus(issue.NotifyStatus) {
|
||||
case afterSalesNotifySent:
|
||||
summary.Sent++
|
||||
case afterSalesNotifyFailed:
|
||||
summary.Failed++
|
||||
default:
|
||||
summary.NotSent++
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) applyLegacyDispatchSuggestionsLocked(cfg AfterSalesDispatchConfig) bool {
|
||||
changed := false
|
||||
for i := range e.issues {
|
||||
issue := &e.issues[i]
|
||||
before := *issue
|
||||
normalizeAfterSalesDispatchFields(issue)
|
||||
if issue.Status == afterSalesIssueStatusPending &&
|
||||
issue.DispatchStatus != afterSalesDispatchAssigned &&
|
||||
issue.DispatchSource != dispatchSourceManual &&
|
||||
len(enabledDispatchEngineers(cfg)) == 0 {
|
||||
if match, ok := matchAfterSalesDispatchRule(*issue, cfg); ok {
|
||||
issue.AssignedEngineerID = strings.TrimSpace(match.Rule.EngineerUserID)
|
||||
issue.AssignedEngineerName = dispatchEngineerName(match.Rule.EngineerUserID, match.Rule.EngineerName, cfg)
|
||||
issue.DispatchStatus = afterSalesDispatchSuggested
|
||||
issue.DispatchReason = match.MatchedLabel
|
||||
issue.DispatchRuleID = match.Rule.ID
|
||||
issue.DispatchConfidence = 1
|
||||
issue.DispatchSource = dispatchSourceRule
|
||||
} else if issue.DispatchStatus == afterSalesDispatchSuggested {
|
||||
issue.AssignedEngineerID = ""
|
||||
issue.AssignedEngineerName = ""
|
||||
issue.DispatchStatus = afterSalesDispatchUnassigned
|
||||
issue.DispatchReason = ""
|
||||
issue.DispatchRuleID = ""
|
||||
issue.DispatchConfidence = 0
|
||||
issue.DispatchSource = ""
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(*issue, before) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func matchAfterSalesDispatchRule(issue AfterSalesIssue, cfg AfterSalesDispatchConfig) (afterSalesDispatchMatch, bool) {
|
||||
best := afterSalesDispatchMatch{}
|
||||
found := false
|
||||
for _, rule := range cfg.Rules {
|
||||
if !rule.Enabled || strings.TrimSpace(rule.EngineerUserID) == "" {
|
||||
continue
|
||||
}
|
||||
if priority, label := dispatchRuleMatchPriority(issue, rule); priority > 0 {
|
||||
if !found || priority > best.Priority {
|
||||
best = afterSalesDispatchMatch{Rule: rule, Priority: priority, MatchedLabel: label}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
func callAfterSalesDispatchAI(aiCfg config.AIConfig, issue AfterSalesIssue, engineers []AfterSalesEngineer) (afterSalesDispatchAIChoice, error) {
|
||||
if len(engineers) == 0 {
|
||||
return afterSalesDispatchAIChoice{}, fmt.Errorf("未配置工程师职责说明")
|
||||
}
|
||||
aiCfg.MaxTokens = maxInt(aiCfg.MaxTokens, 700)
|
||||
aiCfg.TimeoutSeconds = maxInt(aiCfg.TimeoutSeconds, 20)
|
||||
systemPrompt := `你是售后问题派单助手。请根据售后问题内容和工程师职责说明,选择最应该处理该问题的一位工程师。
|
||||
规则:
|
||||
1. 只能从给定工程师列表中选择 user_id。
|
||||
2. 如果没有明确匹配,engineer_user_id 输出空字符串,confidence 不高于 0.5。
|
||||
3. confidence 是 0 到 1 的数字,越确定越接近 1。
|
||||
4. 只输出 JSON 对象,不要 markdown,不要解释文字。
|
||||
JSON 字段:engineer_user_id, reason, confidence。`
|
||||
userPrompt := buildAfterSalesDispatchPrompt(issue, engineers)
|
||||
var result *AIResult
|
||||
var err error
|
||||
switch strings.ToLower(strings.TrimSpace(aiCfg.Provider)) {
|
||||
case "local", "ollama":
|
||||
result, err = callOllamaChat(aiCfg, systemPrompt, userPrompt)
|
||||
default:
|
||||
result, err = callOpenAICompatibleChat(aiCfg, systemPrompt, userPrompt)
|
||||
}
|
||||
if err != nil {
|
||||
return afterSalesDispatchAIChoice{}, err
|
||||
}
|
||||
choice, err := parseAfterSalesDispatchAIResponse(result.Answer)
|
||||
if err != nil {
|
||||
return afterSalesDispatchAIChoice{}, err
|
||||
}
|
||||
if choice.EngineerUserID != "" && !dispatchEngineerExists(choice.EngineerUserID, engineers) {
|
||||
return afterSalesDispatchAIChoice{}, fmt.Errorf("AI 返回了未配置的工程师: %s", choice.EngineerUserID)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func buildAfterSalesDispatchPrompt(issue AfterSalesIssue, engineers []AfterSalesEngineer) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("售后问题:\n")
|
||||
b.WriteString("问题ID:")
|
||||
b.WriteString(issue.ID)
|
||||
b.WriteString("\n群聊:")
|
||||
b.WriteString(firstNonEmpty(issue.RoomName, issue.ConversationID))
|
||||
b.WriteString("\n客户:")
|
||||
b.WriteString(normalizeAfterSalesDisplayName(issue.CustomerName))
|
||||
b.WriteString("\n问题描述:")
|
||||
b.WriteString(strings.TrimSpace(issue.IssueContent))
|
||||
b.WriteString("\nAI建议:")
|
||||
b.WriteString(strings.TrimSpace(issue.AISuggestion))
|
||||
b.WriteString("\n图片数量:")
|
||||
b.WriteString(fmt.Sprintf("%d", len(issue.ImagePaths)+len(issue.ImageRefs)))
|
||||
b.WriteString("\n\n工程师列表:\n")
|
||||
for _, engineer := range engineers {
|
||||
b.WriteString("- user_id=")
|
||||
b.WriteString(engineer.UserID)
|
||||
b.WriteString(" name=")
|
||||
b.WriteString(firstNonEmpty(engineer.Name, engineer.UserID))
|
||||
if engineer.Remark != "" {
|
||||
b.WriteString(" remark=")
|
||||
b.WriteString(engineer.Remark)
|
||||
}
|
||||
b.WriteString("\n 职责说明:")
|
||||
b.WriteString(engineer.Description)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n请选择最匹配的一位工程师。无法明确判断时返回空 engineer_user_id。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseAfterSalesDispatchAIResponse(text string) (afterSalesDispatchAIChoice, error) {
|
||||
text = strings.TrimSpace(stripJSONMarkdownFence(text))
|
||||
if text == "" {
|
||||
return afterSalesDispatchAIChoice{}, fmt.Errorf("AI 返回为空")
|
||||
}
|
||||
start := strings.Index(text, "{")
|
||||
end := strings.LastIndex(text, "}")
|
||||
if start < 0 || end < start {
|
||||
return afterSalesDispatchAIChoice{}, fmt.Errorf("AI 未返回 JSON 对象: %s", truncateText(text, 200))
|
||||
}
|
||||
var choice afterSalesDispatchAIChoice
|
||||
if err := json.Unmarshal([]byte(text[start:end+1]), &choice); err != nil {
|
||||
return afterSalesDispatchAIChoice{}, fmt.Errorf("解析派单 AI JSON 失败: %w", err)
|
||||
}
|
||||
choice.EngineerUserID = strings.TrimSpace(choice.EngineerUserID)
|
||||
choice.Reason = strings.TrimSpace(choice.Reason)
|
||||
if choice.Confidence < 0 {
|
||||
choice.Confidence = 0
|
||||
}
|
||||
if choice.Confidence > 1 {
|
||||
choice.Confidence = 1
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func dispatchEngineerExists(userID string, engineers []AfterSalesEngineer) bool {
|
||||
userID = strings.TrimSpace(userID)
|
||||
for _, engineer := range engineers {
|
||||
if strings.TrimSpace(engineer.UserID) == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dispatchRuleMatchPriority(issue AfterSalesIssue, rule AfterSalesDispatchRule) (int, string) {
|
||||
if containsAnyNormalized(rule.ConversationIDs, issue.ConversationID) || containsAnyText(rule.CustomerNames, issue.CustomerName) {
|
||||
return 300, dispatchRuleLabel(rule, "群聊/客户")
|
||||
}
|
||||
productText := strings.Join([]string{issue.RoomName, issue.IssueContent, issue.AISuggestion}, "\n")
|
||||
if containsAnyText(rule.ProductKeywords, productText) {
|
||||
return 200, dispatchRuleLabel(rule, "产品关键词")
|
||||
}
|
||||
if containsAnyText(rule.IssueKeywords, strings.Join([]string{issue.IssueContent, issue.AISuggestion}, "\n")) {
|
||||
return 100, dispatchRuleLabel(rule, "问题关键词")
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
func dispatchRuleLabel(rule AfterSalesDispatchRule, kind string) string {
|
||||
name := strings.TrimSpace(rule.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(rule.ID)
|
||||
}
|
||||
if name == "" {
|
||||
return kind
|
||||
}
|
||||
return kind + ": " + name
|
||||
}
|
||||
|
||||
func containsAnyNormalized(items []string, value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item) == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsAnyText(needles []string, haystack string) bool {
|
||||
haystack = strings.ToLower(strings.TrimSpace(haystack))
|
||||
if haystack == "" {
|
||||
return false
|
||||
}
|
||||
for _, needle := range needles {
|
||||
needle = strings.ToLower(strings.TrimSpace(needle))
|
||||
if needle != "" && strings.Contains(haystack, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dispatchEngineerName(userID string, fallback string, cfg AfterSalesDispatchConfig) string {
|
||||
userID = strings.TrimSpace(userID)
|
||||
for _, engineer := range cfg.Engineers {
|
||||
if strings.TrimSpace(engineer.UserID) == userID && strings.TrimSpace(engineer.Name) != "" {
|
||||
return strings.TrimSpace(engineer.Name)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) assignEngineer(issueID string, engineerID string) error {
|
||||
cfg, err := readAfterSalesDispatchConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issueID = strings.TrimSpace(issueID)
|
||||
engineerID = strings.TrimSpace(engineerID)
|
||||
if issueID == "" {
|
||||
return fmt.Errorf("issueId为空")
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for i := range e.issues {
|
||||
if e.issues[i].ID != issueID {
|
||||
continue
|
||||
}
|
||||
e.issues[i].AssignedEngineerID = engineerID
|
||||
e.issues[i].AssignedEngineerName = dispatchEngineerName(engineerID, "", cfg)
|
||||
e.issues[i].DispatchRuleID = ""
|
||||
if engineerID == "" {
|
||||
e.issues[i].DispatchStatus = afterSalesDispatchUnassigned
|
||||
e.issues[i].DispatchReason = ""
|
||||
e.issues[i].DispatchConfidence = 0
|
||||
e.issues[i].DispatchSource = ""
|
||||
} else {
|
||||
e.issues[i].DispatchStatus = afterSalesDispatchAssigned
|
||||
e.issues[i].DispatchReason = "人工指定"
|
||||
e.issues[i].DispatchConfidence = 1
|
||||
e.issues[i].DispatchSource = dispatchSourceManual
|
||||
}
|
||||
e.issues[i].UpdatedAt = time.Now().Local().Format(time.RFC3339)
|
||||
normalizeAfterSalesDispatchFields(&e.issues[i])
|
||||
return e.saveIssuesLocked()
|
||||
}
|
||||
return fmt.Errorf("问题不存在")
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) notifyEngineer(issueID string) AfterSalesNotifyResult {
|
||||
cfg, err := readAfterSalesDispatchConfig()
|
||||
if err != nil {
|
||||
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: err.Error()}
|
||||
}
|
||||
issueID = strings.TrimSpace(issueID)
|
||||
if issueID == "" {
|
||||
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "issueId为空"}
|
||||
}
|
||||
var issue AfterSalesIssue
|
||||
e.mu.Lock()
|
||||
index := -1
|
||||
for i := range e.issues {
|
||||
if e.issues[i].ID == issueID {
|
||||
index = i
|
||||
issue = e.issues[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
e.mu.Unlock()
|
||||
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "问题不存在"}
|
||||
}
|
||||
normalizeAfterSalesDispatchFields(&issue)
|
||||
if issue.Status != afterSalesIssueStatusPending {
|
||||
e.mu.Unlock()
|
||||
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "问题已处理或非待处理,不能通知"}
|
||||
}
|
||||
if strings.TrimSpace(issue.AssignedEngineerID) == "" {
|
||||
e.issues[index].NotifyStatus = afterSalesNotifyFailed
|
||||
e.issues[index].NotifyError = "未分配工程师"
|
||||
e.issues[index].UpdatedAt = time.Now().Local().Format(time.RFC3339)
|
||||
_ = e.saveIssuesLocked()
|
||||
e.mu.Unlock()
|
||||
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "未分配工程师"}
|
||||
}
|
||||
if issue.NotifyStatus == afterSalesNotifySent && issue.LastNotifiedAt > 0 && cfg.NotifyCooldownSeconds > 0 {
|
||||
if time.Since(time.Unix(issue.LastNotifiedAt, 0)) < time.Duration(cfg.NotifyCooldownSeconds)*time.Second {
|
||||
e.mu.Unlock()
|
||||
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "已通知过,冷却时间内不重复推送"}
|
||||
}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
clientID, conversationID, err := resolveEngineerConversation(issue.AssignedEngineerID)
|
||||
if err == nil {
|
||||
err = sendAutoReplyText(clientID, conversationID, renderAfterSalesDispatchNotification(issue, cfg))
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for i := range e.issues {
|
||||
if e.issues[i].ID != issueID {
|
||||
continue
|
||||
}
|
||||
e.issues[i].NotifyCount++
|
||||
e.issues[i].UpdatedAt = time.Now().Local().Format(time.RFC3339)
|
||||
if err != nil {
|
||||
e.issues[i].NotifyStatus = afterSalesNotifyFailed
|
||||
e.issues[i].NotifyError = err.Error()
|
||||
_ = e.saveIssuesLocked()
|
||||
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: err.Error()}
|
||||
}
|
||||
e.issues[i].NotifyStatus = afterSalesNotifySent
|
||||
e.issues[i].NotifyError = ""
|
||||
e.issues[i].LastNotifiedAt = time.Now().Unix()
|
||||
_ = e.saveIssuesLocked()
|
||||
return AfterSalesNotifyResult{IssueID: issueID, Success: true, Message: "sent"}
|
||||
}
|
||||
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "问题不存在"}
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) batchNotifyEngineers(issueIDs []string) map[string]interface{} {
|
||||
if len(uniqueNonEmptyStrings(issueIDs)) == 0 {
|
||||
issueIDs = e.notifyAllAssignedPendingIDs()
|
||||
}
|
||||
results := make([]AfterSalesNotifyResult, 0, len(issueIDs))
|
||||
successCount := 0
|
||||
for _, issueID := range uniqueNonEmptyStrings(issueIDs) {
|
||||
result := e.notifyEngineer(issueID)
|
||||
if result.Success {
|
||||
successCount++
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"success": successCount == len(results),
|
||||
"message": fmt.Sprintf("已成功推送 %d/%d 条", successCount, len(results)),
|
||||
"data": map[string]interface{}{
|
||||
"successCount": successCount,
|
||||
"totalCount": len(results),
|
||||
"results": results,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) notifyAllAssignedPendingIDs() []string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
result := make([]string, 0)
|
||||
for _, issue := range e.issues {
|
||||
if issue.Status != afterSalesIssueStatusPending {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(issue.AssignedEngineerID) == "" {
|
||||
continue
|
||||
}
|
||||
if normalizeAfterSalesNotifyStatus(issue.NotifyStatus) != afterSalesNotifyNotSent {
|
||||
continue
|
||||
}
|
||||
result = append(result, issue.ID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveEngineerConversation(engineerID string) (uint32, string, error) {
|
||||
engineerID = strings.TrimSpace(engineerID)
|
||||
if engineerID == "" {
|
||||
return 0, "", fmt.Errorf("工程师 userId 为空")
|
||||
}
|
||||
clientIDs := identityRefreshClientIDs()
|
||||
if len(clientIDs) == 0 {
|
||||
return 0, "", fmt.Errorf("没有活跃企微账号,无法发送通知")
|
||||
}
|
||||
for _, clientID := range clientIDs {
|
||||
robotID := strings.TrimSpace(getClientUserID(clientID))
|
||||
if robotID == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(engineerID, "S:") {
|
||||
return clientID, engineerID, nil
|
||||
}
|
||||
return clientID, fmt.Sprintf("S:%s_%s", robotID, engineerID), nil
|
||||
}
|
||||
return 0, "", fmt.Errorf("无法推导工程师私信会话,缺少当前接管账号ID")
|
||||
}
|
||||
|
||||
func renderAfterSalesDispatchNotification(issue AfterSalesIssue, cfg AfterSalesDispatchConfig) string {
|
||||
template := strings.TrimSpace(cfg.NotifyTemplate)
|
||||
if template == "" {
|
||||
template = defaultAfterSalesDispatchTemplate()
|
||||
}
|
||||
replacements := map[string]string{
|
||||
"{issueId}": issue.ID,
|
||||
"{roomName}": fallbackString(issue.RoomName, issue.ConversationID),
|
||||
"{customerName}": normalizeAfterSalesDisplayName(issue.CustomerName),
|
||||
"{issueContent}": strings.TrimSpace(issue.IssueContent),
|
||||
"{aiSuggestion}": strings.TrimSpace(issue.AISuggestion),
|
||||
"{createdAt}": formatDispatchIssueTime(issue.CreatedAt),
|
||||
"{engineerName}": fallbackString(issue.AssignedEngineerName, issue.AssignedEngineerID),
|
||||
"{engineerUserId}": issue.AssignedEngineerID,
|
||||
"{imageCount}": fmt.Sprintf("%d", len(issue.ImagePaths)+len(issue.ImageRefs)),
|
||||
"{dispatchReason}": issue.DispatchReason,
|
||||
}
|
||||
for key, value := range replacements {
|
||||
template = strings.ReplaceAll(template, key, value)
|
||||
}
|
||||
return template
|
||||
}
|
||||
|
||||
func defaultAfterSalesDispatchTemplate() string {
|
||||
return "【售后问题待处理】\n" +
|
||||
"客户/群聊:{customerName} / {roomName}\n" +
|
||||
"提出时间:{createdAt}\n" +
|
||||
"问题ID:{issueId}\n" +
|
||||
"匹配原因:{dispatchReason}\n\n" +
|
||||
"问题描述:\n{issueContent}\n\n" +
|
||||
"AI建议:\n{aiSuggestion}\n\n" +
|
||||
"相关图片:{imageCount} 张"
|
||||
}
|
||||
|
||||
func formatDispatchIssueTime(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user