Files
qiweimanager-master/helper/after_sales_ai.go

150 lines
5.2 KiB
Go

package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"qiweimanager/config"
)
func callAfterSalesAI(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) {
if len(messages) == 0 {
return nil, nil
}
aiCfg.MaxTokens = maxInt(aiCfg.MaxTokens, 1200)
aiCfg.TimeoutSeconds = maxInt(aiCfg.TimeoutSeconds, 30)
systemPrompt := buildAfterSalesSystemPrompt()
userPrompt := buildAfterSalesUserPrompt(messages)
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 nil, err
}
return parseAfterSalesAIResponse(result.Answer)
}
func buildAfterSalesSystemPrompt() string {
return strings.Join([]string{
"You are an after-sales issue extraction assistant.",
"Find unresolved customer issues from WeCom group chat records.",
"Ignore greetings, small talk, resolved questions, and internal staff messages that are not customer issues.",
"Customers are usually external or unknown; internal messages are usually staff.",
"If text plus later images or files describe one issue together, include the related source_message_ids.",
"Split unrelated devices, failures, requests, or time periods into separate JSON objects.",
"Return only a JSON array. Do not return markdown or explanations.",
"Each object must contain room_name, customer_user_id, customer_name, issue_content, image_paths, image_refs, ai_suggestion, source_message_ids, confidence.",
}, "\n")
}
func buildAfterSalesUserPrompt(messages []AfterSalesMessage) string {
items := append([]AfterSalesMessage(nil), messages...)
sort.Slice(items, func(i, j int) bool { return items[i].SendTime < items[j].SendTime })
var b strings.Builder
roomName := ""
if len(items) > 0 {
roomName = items[0].RoomName
}
b.WriteString("Group: ")
b.WriteString(firstNonEmpty(roomName, "unknown group"))
b.WriteString("\nChat records:\n")
for _, msg := range items {
tm := time.Unix(msg.SendTime, 0).Local().Format("2006-01-02 15:04")
b.WriteString("- id=")
b.WriteString(msg.MessageID)
b.WriteString(" time=")
b.WriteString(tm)
b.WriteString(" role=")
b.WriteString(firstNonEmpty(msg.SenderIdentity, senderIdentityUnknown))
b.WriteString(" user_id=")
b.WriteString(msg.SenderUserID)
b.WriteString(" name=")
b.WriteString(firstNonEmpty(msg.SenderName, "unknown"))
b.WriteString(": ")
content := strings.TrimSpace(msg.Content)
if content != "" {
b.WriteString(content)
}
if msg.ImagePath != "" {
b.WriteString(" [image:")
b.WriteString(msg.ImagePath)
b.WriteString("]")
}
if msg.ImageRef != "" {
b.WriteString(" [image_ref:")
b.WriteString(msg.ImageRef)
b.WriteString("]")
}
if msg.FilePath != "" || msg.FileRef != "" || msg.FileName != "" || msg.FileContent != "" {
b.WriteString(" [file:")
b.WriteString(firstNonEmpty(msg.FileName, msg.FilePath, msg.FileRef))
if msg.FileExtractStatus != "" {
b.WriteString(" status=")
b.WriteString(msg.FileExtractStatus)
}
b.WriteString("]")
}
b.WriteString("\n")
if msg.FileContent != "" {
b.WriteString(" file_content: ")
b.WriteString(truncateText(msg.FileContent, afterSalesFilePromptLimit))
b.WriteString("\n")
}
}
b.WriteString("\nExtract unresolved after-sales issues. source_message_ids must reference ids above. If there is no unresolved issue, return [].")
return b.String()
}
func parseAfterSalesAIResponse(text string) ([]afterSalesAIIssueCandidate, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, fmt.Errorf("AI returned empty response")
}
text = stripJSONMarkdownFence(text)
start := strings.Index(text, "[")
end := strings.LastIndex(text, "]")
if start < 0 || end < start {
return nil, fmt.Errorf("AI did not return a JSON array: %s", truncateText(text, 200))
}
payload := text[start : end+1]
var result []afterSalesAIIssueCandidate
if err := json.Unmarshal([]byte(payload), &result); err != nil {
return nil, fmt.Errorf("parse AI JSON failed: %w", err)
}
for i := range result {
result[i].IssueContent = strings.TrimSpace(result[i].IssueContent)
result[i].CustomerName = strings.TrimSpace(result[i].CustomerName)
result[i].CustomerUserID = strings.TrimSpace(result[i].CustomerUserID)
result[i].RoomName = strings.TrimSpace(result[i].RoomName)
result[i].AISuggestion = strings.TrimSpace(result[i].AISuggestion)
result[i].ImagePaths = uniqueNonEmptyStrings(result[i].ImagePaths)
result[i].ImageRefs = uniqueNonEmptyStrings(result[i].ImageRefs)
result[i].SourceMessageIDs = uniqueNonEmptyStrings(result[i].SourceMessageIDs)
}
return result, nil
}
func stripJSONMarkdownFence(text string) string {
text = strings.TrimSpace(text)
fence := string(rune(96)) + string(rune(96)) + string(rune(96))
if !strings.HasPrefix(text, fence) {
return text
}
lines := strings.Split(text, "\n")
if len(lines) <= 2 {
return text
}
if strings.HasPrefix(strings.TrimSpace(lines[0]), fence) && strings.HasPrefix(strings.TrimSpace(lines[len(lines)-1]), fence) {
return strings.TrimSpace(strings.Join(lines[1:len(lines)-1], "\n"))
}
return text
}