150 lines
5.2 KiB
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
|
|
}
|