Files
ai-agent/internal/ai/runtime/graphs/analyze_conversation_graph.go
t 18c9354095 refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
2026-08-28 22:23:13 +08:00

248 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package graphs
import (
"context"
"encoding/json"
"fmt"
"strings"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
"code.tczkiot.com/wlw/ai-agent/internal/services"
)
type AnalyzeConversationInput struct {
Goal string `json:"goal"`
ObservedIssue string `json:"observed_issue"`
NeedHumanHandoff bool `json:"need_human_handoff"`
NeedQualityCheck bool `json:"need_quality_check"`
AdditionalContext string `json:"additional_context"`
}
type AnalyzeConversationResult struct {
Summary string `json:"summary"`
UserIntent string `json:"user_intent"`
RiskLevel string `json:"risk_level"`
RiskSignals []string `json:"risk_signals,omitempty"`
RecommendedNextAction string `json:"recommended_next_action"`
RecommendedQuestions []string `json:"recommended_questions,omitempty"`
ConversationFacts []string `json:"conversation_facts,omitempty"`
}
type AnalyzeConversationGraph struct {
conversation models.Conversation
}
func NewAnalyzeConversationGraph(conversation models.Conversation) *AnalyzeConversationGraph {
return &AnalyzeConversationGraph{conversation: conversation}
}
func (g *AnalyzeConversationGraph) Run(_ context.Context, argumentsInJSON string) (string, error) {
input, err := g.parseInput(argumentsInJSON)
if err != nil {
return "", err
}
messages, _, _ := services.MessageService.FindByConversationIDCursor(g.conversation.ID, 0, 8, "", "")
result := buildAnalyzeConversationResult(g.conversation, messages, input)
buf, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(buf), nil
}
func (g *AnalyzeConversationGraph) parseInput(argumentsInJSON string) (AnalyzeConversationInput, error) {
var input AnalyzeConversationInput
if strings.TrimSpace(argumentsInJSON) == "" {
return input, nil
}
if err := json.Unmarshal([]byte(argumentsInJSON), &input); err != nil {
return input, fmt.Errorf("invalid analyze conversation arguments: %w", err)
}
input.Goal = strings.TrimSpace(input.Goal)
input.ObservedIssue = strings.TrimSpace(input.ObservedIssue)
input.AdditionalContext = strings.TrimSpace(input.AdditionalContext)
return input, nil
}
func buildAnalyzeConversationResult(conversation models.Conversation, messages []models.Message, input AnalyzeConversationInput) AnalyzeConversationResult {
joined := strings.ToLower(buildConversationCorpus(conversation, messages, input))
signals := collectRiskSignals(joined, input)
intent := detectUserIntent(joined, input)
recommendedAction := recommendNextAction(intent, signals, input)
result := AnalyzeConversationResult{
Summary: buildConversationSummary(conversation, messages, input),
UserIntent: intent,
RiskLevel: deriveRiskLevel(signals),
RiskSignals: signals,
RecommendedNextAction: recommendedAction,
RecommendedQuestions: recommendQuestions(intent, signals, input),
ConversationFacts: buildConversationAnalysisFacts(conversation, messages),
}
return result
}
func buildConversationSummary(conversation models.Conversation, messages []models.Message, input AnalyzeConversationInput) string {
parts := make([]string, 0, 4)
if input.ObservedIssue != "" {
parts = append(parts, "当前问题:"+input.ObservedIssue)
} else if strings.TrimSpace(conversation.LastMessageSummary) != "" {
parts = append(parts, "当前问题:"+strings.TrimSpace(conversation.LastMessageSummary))
}
if digest := buildRecentMessageDigest(messages); digest != "" {
parts = append(parts, "最近对话:"+digest)
}
if input.AdditionalContext != "" {
parts = append(parts, "补充信息:"+input.AdditionalContext)
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
func buildConversationCorpus(conversation models.Conversation, messages []models.Message, input AnalyzeConversationInput) string {
parts := make([]string, 0, len(messages)+4)
parts = append(parts, strings.TrimSpace(conversation.LastMessageSummary))
parts = append(parts, input.Goal, input.ObservedIssue, input.AdditionalContext)
for i := range messages {
parts = append(parts, strings.TrimSpace(messages[i].Content))
}
return strings.Join(parts, "\n")
}
func collectRiskSignals(joined string, input AnalyzeConversationInput) []string {
signals := make([]string, 0, 6)
add := func(signal string) {
for _, item := range signals {
if item == signal {
return
}
}
signals = append(signals, signal)
}
if containsAny(joined, "投诉", "举报", "差评", "曝光", "媒体", "起诉", "律师", "12315") {
add("complaint_escalation")
}
if containsAny(joined, "退款", "赔偿", "损失", "扣款", "重复扣费", "金额") {
add("financial_risk")
}
if containsAny(joined, "生气", "愤怒", "垃圾", "太差", "一直没人", "再不处理") {
add("negative_sentiment")
}
if containsAny(joined, "人工", "转人工", "真人", "客服") || input.NeedHumanHandoff {
add("handoff_requested")
}
if input.NeedQualityCheck {
add("quality_review_requested")
}
return signals
}
func detectUserIntent(joined string, input AnalyzeConversationInput) string {
switch {
case input.NeedHumanHandoff || containsAny(joined, "人工", "转人工", "真人"):
return "handoff_request"
case containsAny(joined, "投诉", "举报", "差评", "赔偿"):
return "complaint"
default:
return "general_support"
}
}
func deriveRiskLevel(signals []string) string {
if len(signals) == 0 {
return "low"
}
if containsSignal(signals, "complaint_escalation") || containsSignal(signals, "financial_risk") {
return "high"
}
if containsSignal(signals, "handoff_requested") || containsSignal(signals, "negative_sentiment") {
return "medium"
}
return "low"
}
func recommendNextAction(intent string, signals []string, input AnalyzeConversationInput) string {
switch {
case input.NeedQualityCheck:
return "quality_review"
case containsSignal(signals, "handoff_requested") || intent == "handoff_request":
return "handoff_to_human"
case containsSignal(signals, "complaint_escalation"):
return "handoff_to_human"
default:
return "continue_answering"
}
}
func recommendQuestions(intent string, signals []string, input AnalyzeConversationInput) []string {
questions := make([]string, 0, 3)
if containsSignal(signals, "handoff_requested") {
questions = append(questions, "Please confirm whether the user explicitly requested human support and why the issue needs human handling.")
}
if intent == "complaint" {
questions = append(questions, "Please confirm the complaint, impact, and the user's most important desired outcome.")
}
return questions
}
func buildConversationAnalysisFacts(conversation models.Conversation, messages []models.Message) []string {
facts := make([]string, 0, 4)
if strings.TrimSpace(conversation.LastMessageSummary) != "" {
facts = append(facts, "最近摘要:"+strings.TrimSpace(conversation.LastMessageSummary))
}
if digest := buildRecentMessageDigest(messages); digest != "" {
facts = append(facts, "最近消息:"+digest)
}
return facts
}
func containsAny(value string, keywords ...string) bool {
for _, keyword := range keywords {
if keyword != "" && strings.Contains(value, strings.ToLower(keyword)) {
return true
}
}
return false
}
func containsSignal(signals []string, target string) bool {
for _, item := range signals {
if item == target {
return true
}
}
return false
}
func buildRecentMessageDigest(messages []models.Message) string {
parts := make([]string, 0, len(messages))
for i := range messages {
content := strings.TrimSpace(messages[i].Content)
if content == "" {
continue
}
parts = append(parts, messageSenderLabel(messages[i].SenderType)+""+limitAnalysisText(content, 60))
}
return strings.Join(parts, " | ")
}
func messageSenderLabel(senderType enums.IMSenderType) string {
switch senderType {
case enums.IMSenderTypeCustomer:
return "Customer"
case enums.IMSenderTypeAgent:
return "Agent"
case enums.IMSenderTypeAI:
return "AI"
default:
return "Message"
}
}
func limitAnalysisText(value string, max int) string {
runes := []rune(strings.TrimSpace(value))
if max <= 0 || len(runes) <= max {
return string(runes)
}
return strings.TrimSpace(string(runes[:max])) + "..."
}