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