refactor: 将客服后端重构为宿主可嵌入模块

- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

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

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

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
t
2026-08-28 22:23:13 +08:00
parent 6845c728f8
commit 18c9354095
377 changed files with 13199 additions and 22881 deletions
@@ -7,26 +7,26 @@ import (
"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:"observedIssue"`
NeedTicket bool `json:"needTicket"`
NeedHumanHandoff bool `json:"needHumanHandoff"`
NeedQualityCheck bool `json:"needQualityCheck"`
AdditionalContext string `json:"additionalContext"`
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:"userIntent"`
RiskLevel string `json:"riskLevel"`
RiskSignals []string `json:"riskSignals,omitempty"`
RecommendedNextAction string `json:"recommendedNextAction"`
RecommendedQuestions []string `json:"recommendedQuestions,omitempty"`
ConversationFacts []string `json:"conversationFacts,omitempty"`
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 {
@@ -130,9 +130,6 @@ func collectRiskSignals(joined string, input AnalyzeConversationInput) []string
if containsAny(joined, "人工", "转人工", "真人", "客服") || input.NeedHumanHandoff {
add("handoff_requested")
}
if containsAny(joined, "工单", "报障", "售后", "登记", "记录问题") || input.NeedTicket {
add("ticket_expected")
}
if input.NeedQualityCheck {
add("quality_review_requested")
}
@@ -143,8 +140,6 @@ func detectUserIntent(joined string, input AnalyzeConversationInput) string {
switch {
case input.NeedHumanHandoff || containsAny(joined, "人工", "转人工", "真人"):
return "handoff_request"
case input.NeedTicket || containsAny(joined, "工单", "报障", "售后", "登记问题"):
return "ticket_request"
case containsAny(joined, "投诉", "举报", "差评", "赔偿"):
return "complaint"
default:
@@ -171,8 +166,6 @@ func recommendNextAction(intent string, signals []string, input AnalyzeConversat
return "quality_review"
case containsSignal(signals, "handoff_requested") || intent == "handoff_request":
return "handoff_to_human"
case containsSignal(signals, "ticket_expected") || intent == "ticket_request":
return "prepare_ticket"
case containsSignal(signals, "complaint_escalation"):
return "handoff_to_human"
default:
@@ -182,9 +175,6 @@ func recommendNextAction(intent string, signals []string, input AnalyzeConversat
func recommendQuestions(intent string, signals []string, input AnalyzeConversationInput) []string {
questions := make([]string, 0, 3)
if containsSignal(signals, "ticket_expected") && strings.TrimSpace(input.ObservedIssue) == "" {
questions = append(questions, "Please confirm the specific issue, error message, and expected outcome.")
}
if containsSignal(signals, "handoff_requested") {
questions = append(questions, "Please confirm whether the user explicitly requested human support and why the issue needs human handling.")
}
@@ -222,3 +212,36 @@ func containsSignal(signals []string, target string) bool {
}
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])) + "..."
}
@@ -29,23 +29,3 @@ func TestBuildAnalyzeConversationResult_RecommendsHandoffForComplaint(t *testing
t.Fatalf("expected handoff_to_human, got %q", got.RecommendedNextAction)
}
}
func TestBuildAnalyzeConversationResult_RecommendsPrepareTicket(t *testing.T) {
conversation := models.Conversation{
LastMessageSummary: "用户要求登记问题并尽快处理",
}
messages := []models.Message{
{SenderType: enums.IMSenderTypeCustomer, Content: "麻烦帮我建个工单,订单一直支付失败"},
}
got := buildAnalyzeConversationResult(conversation, messages, AnalyzeConversationInput{
NeedTicket: true,
})
if got.UserIntent != "ticket_request" {
t.Fatalf("expected ticket_request, got %q", got.UserIntent)
}
if got.RecommendedNextAction != "prepare_ticket" {
t.Fatalf("expected prepare_ticket, got %q", got.RecommendedNextAction)
}
}
@@ -1,152 +0,0 @@
package graphs
import (
"context"
"encoding/json"
"fmt"
"strings"
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/tooling"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
"code.tczkiot.com/wlw/ai-agent/internal/services"
componenttool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type CreateTicketGraphState struct {
Request request.CreateTicketFromConversationRequest
}
type CreateTicketGraphInterruptInfo struct {
Type string `json:"type"`
Message string `json:"message"`
}
type createTicketGraphArgs struct {
Title string `json:"title"`
Description string `json:"description"`
}
func init() {
schema.RegisterName[CreateTicketGraphState]("cs_ai_agent_create_ticket_graph_state")
schema.RegisterName[CreateTicketGraphInterruptInfo]("cs_ai_agent_create_ticket_graph_interrupt_info")
}
type CreateTicketGraph struct {
conversation models.Conversation
aiAgent models.AIAgent
}
func NewCreateTicketGraph(conversation models.Conversation, aiAgent models.AIAgent) *CreateTicketGraph {
return &CreateTicketGraph{
conversation: conversation,
aiAgent: aiAgent,
}
}
func (g *CreateTicketGraph) Run(ctx context.Context, argumentsInJSON string) (string, error) {
wasInterrupted, hasState, state := componenttool.GetInterruptState[CreateTicketGraphState](ctx)
if !wasInterrupted {
req, err := g.buildCreateRequest(argumentsInJSON)
if err != nil {
return "", err
}
info := CreateTicketGraphInterruptInfo{
Type: InterruptTypeTicketCreationConfirmation,
Message: g.buildConfirmationPrompt(req),
}
return "", componenttool.StatefulInterrupt(ctx, info, CreateTicketGraphState{Request: req})
}
if !hasState {
return "", fmt.Errorf("create ticket graph state missing")
}
isResumeTarget, hasData, resumeText := componenttool.GetResumeContext[string](ctx)
if !isResumeTarget {
info := CreateTicketGraphInterruptInfo{
Type: InterruptTypeTicketCreationConfirmation,
Message: g.buildConfirmationPrompt(state.Request),
}
return "", componenttool.StatefulInterrupt(ctx, info, state)
}
if !hasData {
info := CreateTicketGraphInterruptInfo{
Type: InterruptTypeTicketCreationConfirmation,
Message: ConfirmOrCancelPrompt,
}
return "", componenttool.StatefulInterrupt(ctx, info, state)
}
decision := ParseConfirmationDecision(resumeText)
switch decision {
case ConfirmationDecisionConfirm:
item, err := services.TicketService.CreateFromConversation(state.Request, g.buildAIPrincipal())
if err != nil {
return "", err
}
return tooling.MarshalToolResult(tooling.ToolResult{
Handled: true,
Terminal: true,
Action: "ticket_created",
ReplyText: i18nx.Getf(i18nx.DefaultLocale, "graph.ticketCreated", strings.TrimSpace(item.TicketNo), strings.TrimSpace(item.Title)),
ShouldRetry: false,
}), nil
case ConfirmationDecisionCancel:
return tooling.MarshalToolResult(tooling.ToolResult{
Handled: true,
Terminal: true,
Action: "ticket_cancelled",
ReplyText: CancelCreateTicketReply,
ShouldRetry: false,
}), nil
default:
info := CreateTicketGraphInterruptInfo{
Type: InterruptTypeTicketCreationConfirmation,
Message: NeedExplicitConfirmationPrompt,
}
return "", componenttool.StatefulInterrupt(ctx, info, state)
}
}
func (g *CreateTicketGraph) buildCreateRequest(argumentsInJSON string) (request.CreateTicketFromConversationRequest, error) {
req := request.CreateTicketFromConversationRequest{
ConversationID: g.conversation.ID,
}
var args createTicketGraphArgs
if strings.TrimSpace(argumentsInJSON) != "" {
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return req, fmt.Errorf("invalid create ticket arguments: %w", err)
}
}
req.Title = strings.TrimSpace(args.Title)
req.Description = strings.TrimSpace(args.Description)
if req.Title == "" {
req.Title = strings.TrimSpace(g.conversation.LastMessageSummary)
}
if req.Description == "" {
req.Description = strings.TrimSpace(g.conversation.LastMessageSummary)
}
if strings.TrimSpace(req.Title) == "" {
return req, fmt.Errorf("ticket title is required")
}
return req, nil
}
func (g *CreateTicketGraph) buildConfirmationPrompt(req request.CreateTicketFromConversationRequest) string {
return i18nx.Getf(i18nx.DefaultLocale, "graph.createTicketConfirmPrompt",
strings.TrimSpace(req.Title), strings.TrimSpace(req.Description))
}
func (g *CreateTicketGraph) buildAIPrincipal() *dto.AuthPrincipal {
username := "AI"
if strings.TrimSpace(g.aiAgent.Name) != "" {
username = strings.TrimSpace(g.aiAgent.Name)
}
return &dto.AuthPrincipal{
UserID: 0,
Username: username,
Nickname: username,
}
}
+16 -13
View File
@@ -7,15 +7,13 @@ import (
)
const (
InterruptTypeTicketCreationConfirmation = "ticket_creation_confirmation"
InterruptTypeHandoffConfirmation = "handoff_confirmation"
InterruptTypeHandoffConfirmation = "handoff_confirmation"
)
var (
ConfirmOrCancelPrompt = i18nx.Get("graph.confirmOrCancel")
NeedExplicitConfirmationPrompt = i18nx.Get("graph.needExplicitConfirmation")
ConfirmationExpiredReply = i18nx.Get("graph.confirmationExpired")
CancelCreateTicketReply = i18nx.Get("graph.cancelCreateTicket")
CancelHandoffReply = i18nx.Get("graph.cancelHandoff")
)
@@ -31,25 +29,30 @@ func ParseConfirmationDecision(value string) ConfirmationDecision {
if value == "" {
return ""
}
confirmWords := []string{"确认", "是", "好的", "可以", "ok", "yes", "continue", "confirm", "继续", "同意"}
for _, item := range confirmWords {
if strings.Contains(value, item) {
return ConfirmationDecisionConfirm
}
cancelWords := []string{
"不确认", "取消", "不用", "不需要", "算了", "no", "cancel",
"不提交", "不要提交", "暂不提交", "不办理", "不要办理", "不执行", "不要执行",
}
cancelWords := []string{"取消", "不用", "不需要", "算了", "no", "cancel"}
for _, item := range cancelWords {
if strings.Contains(value, item) {
return ConfirmationDecisionCancel
}
}
confirmWords := []string{
"确认", "是", "好的", "可以", "ok", "yes", "continue", "confirm", "继续", "同意",
"提交", "确定", "办理", "执行",
}
for _, item := range confirmWords {
if strings.Contains(value, item) {
return ConfirmationDecisionConfirm
}
}
return ""
}
func IsCancellationReply(replyText string) bool {
replyText = strings.TrimSpace(replyText)
return strings.Contains(replyText, CancelCreateTicketReply) ||
strings.Contains(replyText, CancelHandoffReply) ||
strings.Contains(replyText, "已取消本次工单创建。") ||
strings.Contains(replyText, "已取消本次转人工。")
return strings.Contains(replyText, CancelHandoffReply) ||
strings.Contains(replyText, "已取消本次转人工。") ||
strings.Contains(replyText, "操作已取消。")
}
+16
View File
@@ -0,0 +1,16 @@
package graphs
import "testing"
func TestParseConfirmationDecisionPrefersCancellation(t *testing.T) {
for _, input := range []string{"不确认", "好的,取消", "cancel", "不提交", "不要办理", "暂不执行"} {
if got := ParseConfirmationDecision(input); got != ConfirmationDecisionCancel {
t.Fatalf("ParseConfirmationDecision(%q) = %q", input, got)
}
}
for _, input := range []string{"确认", "提交", "确定办理", "执行"} {
if got := ParseConfirmationDecision(input); got != ConfirmationDecisionConfirm {
t.Fatalf("ParseConfirmationDecision(%q) = %q", input, got)
}
}
}
@@ -1,186 +0,0 @@
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 PrepareTicketDraftInput struct {
Title string `json:"title"`
Description string `json:"description"`
Issue string `json:"issue"`
Impact string `json:"impact"`
ExpectedOutcome string `json:"expectedOutcome"`
CurrentAttempt string `json:"currentAttempt"`
}
type PrepareTicketDraftResult struct {
Ready bool `json:"ready"`
Title string `json:"title"`
Description string `json:"description"`
MissingFields []string `json:"missingFields,omitempty"`
FollowUpQuestions []string `json:"followUpQuestions,omitempty"`
ConversationFacts []string `json:"conversationFacts,omitempty"`
}
type PrepareTicketDraftGraph struct {
conversation models.Conversation
}
func NewPrepareTicketDraftGraph(conversation models.Conversation) *PrepareTicketDraftGraph {
return &PrepareTicketDraftGraph{conversation: conversation}
}
func (g *PrepareTicketDraftGraph) 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, 6, "", "")
result := buildPrepareTicketDraftResult(g.conversation, messages, input)
buf, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(buf), nil
}
func (g *PrepareTicketDraftGraph) parseInput(argumentsInJSON string) (PrepareTicketDraftInput, error) {
var input PrepareTicketDraftInput
if strings.TrimSpace(argumentsInJSON) == "" {
return input, nil
}
if err := json.Unmarshal([]byte(argumentsInJSON), &input); err != nil {
return input, fmt.Errorf("invalid prepare ticket draft arguments: %w", err)
}
input.Title = strings.TrimSpace(input.Title)
input.Description = strings.TrimSpace(input.Description)
input.Issue = strings.TrimSpace(input.Issue)
input.Impact = strings.TrimSpace(input.Impact)
input.ExpectedOutcome = strings.TrimSpace(input.ExpectedOutcome)
input.CurrentAttempt = strings.TrimSpace(input.CurrentAttempt)
return input, nil
}
func buildPrepareTicketDraftResult(conversation models.Conversation, messages []models.Message, input PrepareTicketDraftInput) PrepareTicketDraftResult {
result := PrepareTicketDraftResult{
MissingFields: make([]string, 0, 2),
FollowUpQuestions: make([]string, 0, 2),
ConversationFacts: buildConversationFacts(conversation, messages),
}
result.Title = buildDraftTitle(conversation, input)
result.Description = buildDraftDescription(conversation, messages, input)
if strings.TrimSpace(result.Title) == "" {
result.MissingFields = append(result.MissingFields, "title")
result.FollowUpQuestions = append(result.FollowUpQuestions, "Please provide a concise ticket title that clearly summarizes the issue.")
}
if !hasSufficientIssueContext(input, result.Description) {
result.MissingFields = append(result.MissingFields, "issue")
result.FollowUpQuestions = append(result.FollowUpQuestions, "Please provide the specific issue, error message, or request so I can prepare the ticket.")
}
result.Ready = result.Title != "" && result.Description != "" && len(result.MissingFields) == 0
return result
}
func buildDraftTitle(conversation models.Conversation, input PrepareTicketDraftInput) string {
switch {
case input.Title != "":
return limitText(input.Title, 80)
case input.Issue != "":
return limitText(input.Issue, 80)
case strings.TrimSpace(conversation.LastMessageSummary) != "":
return limitText(conversation.LastMessageSummary, 80)
default:
return ""
}
}
func buildDraftDescription(conversation models.Conversation, messages []models.Message, input PrepareTicketDraftInput) string {
if input.Description != "" {
return input.Description
}
parts := make([]string, 0, 6)
if input.Issue != "" {
parts = append(parts, "Issue: "+input.Issue)
}
if input.Impact != "" {
parts = append(parts, "Impact: "+input.Impact)
}
if input.ExpectedOutcome != "" {
parts = append(parts, "Requested outcome: "+input.ExpectedOutcome)
}
if input.CurrentAttempt != "" {
parts = append(parts, "Attempts so far: "+input.CurrentAttempt)
}
if strings.TrimSpace(conversation.LastMessageSummary) != "" {
parts = append(parts, "Conversation summary: "+strings.TrimSpace(conversation.LastMessageSummary))
}
if recent := buildRecentMessageDigest(messages); recent != "" {
parts = append(parts, "Recent messages: "+recent)
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
func hasSufficientIssueContext(input PrepareTicketDraftInput, description string) bool {
if input.Issue != "" || input.Description != "" {
return true
}
return len([]rune(strings.TrimSpace(description))) >= 30
}
func buildConversationFacts(conversation models.Conversation, messages []models.Message) []string {
facts := make([]string, 0, 4)
if strings.TrimSpace(conversation.LastMessageSummary) != "" {
facts = append(facts, "Recent summary: "+strings.TrimSpace(conversation.LastMessageSummary))
}
if digest := buildRecentMessageDigest(messages); digest != "" {
facts = append(facts, "Recent messages: "+digest)
}
return facts
}
func buildRecentMessageDigest(messages []models.Message) string {
if len(messages) == 0 {
return ""
}
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)+""+limitText(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 limitText(value string, max int) string {
value = strings.TrimSpace(value)
if max <= 0 {
return value
}
runes := []rune(value)
if len(runes) <= max {
return value
}
return strings.TrimSpace(string(runes[:max])) + "..."
}
@@ -1,55 +0,0 @@
package graphs
import (
"testing"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
)
func TestBuildPrepareTicketDraftResult_UsesConversationFallbacks(t *testing.T) {
conversation := models.Conversation{
LastMessageSummary: "用户反馈企业微信扫码后页面空白,无法进入工作台",
}
messages := []models.Message{
{SenderType: enums.IMSenderTypeCustomer, Content: "扫码登录后一直白屏"},
{SenderType: enums.IMSenderTypeAI, Content: "请问是否有报错提示"},
}
got := buildPrepareTicketDraftResult(conversation, messages, PrepareTicketDraftInput{
Impact: "无法进入后台处理客户消息",
ExpectedOutcome: "恢复正常登录",
})
if got.Title == "" {
t.Fatalf("expected draft title to be generated")
}
if got.Description == "" {
t.Fatalf("expected draft description to be generated")
}
if !got.Ready {
t.Fatalf("expected conversation summary and recent messages to be enough, got %#v", got)
}
if len(got.ConversationFacts) == 0 {
t.Fatalf("expected conversation facts to be populated")
}
}
func TestBuildPrepareTicketDraftResult_ReadyWithExplicitIssue(t *testing.T) {
conversation := models.Conversation{
LastMessageSummary: "用户反馈连续支付失败",
}
got := buildPrepareTicketDraftResult(conversation, nil, PrepareTicketDraftInput{
Issue: "用户连续三次支付订单失败,页面提示网络异常。",
ExpectedOutcome: "希望尽快恢复支付并完成下单。",
CurrentAttempt: "已尝试切换网络和刷新页面,问题仍存在。",
})
if !got.Ready {
t.Fatalf("expected draft to be ready, got %#v", got)
}
if got.Title == "" || got.Description == "" {
t.Fatalf("expected title and description to be populated, got %#v", got)
}
}
@@ -12,16 +12,14 @@ import (
type TriageServiceRequestInput struct {
Goal string `json:"goal"`
ObservedIssue string `json:"observedIssue"`
NeedTicket bool `json:"needTicket"`
NeedHumanHandoff bool `json:"needHumanHandoff"`
AdditionalContext string `json:"additionalContext"`
ObservedIssue string `json:"observed_issue"`
NeedHumanHandoff bool `json:"need_human_handoff"`
AdditionalContext string `json:"additional_context"`
}
type TriageServiceRequestResult struct {
Analysis AnalyzeConversationResult `json:"analysis"`
TicketDraft *PrepareTicketDraftResult `json:"ticketDraft,omitempty"`
RecommendedAction string `json:"recommendedAction"`
RecommendedAction string `json:"recommended_action"`
Ready bool `json:"ready"`
}
@@ -42,7 +40,6 @@ func (g *TriageServiceRequestGraph) Run(_ context.Context, argumentsInJSON strin
analysis := buildAnalyzeConversationResult(g.conversation, messages, AnalyzeConversationInput{
Goal: input.Goal,
ObservedIssue: input.ObservedIssue,
NeedTicket: input.NeedTicket,
NeedHumanHandoff: input.NeedHumanHandoff,
AdditionalContext: input.AdditionalContext,
})
@@ -51,13 +48,6 @@ func (g *TriageServiceRequestGraph) Run(_ context.Context, argumentsInJSON strin
RecommendedAction: analysis.RecommendedNextAction,
Ready: analysis.RecommendedNextAction == "continue_answering" || analysis.RecommendedNextAction == "handoff_to_human",
}
if analysis.RecommendedNextAction == "prepare_ticket" {
draft := buildPrepareTicketDraftResult(g.conversation, messages, PrepareTicketDraftInput{
Issue: input.ObservedIssue,
})
result.TicketDraft = &draft
result.Ready = draft.Ready
}
buf, err := json.Marshal(result)
if err != nil {
return "", err
@@ -7,27 +7,6 @@ import (
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
)
func TestTriageServiceRequestResult_PrepareTicket(t *testing.T) {
conversation := models.Conversation{
LastMessageSummary: "用户要求建单跟进支付失败问题",
}
messages := []models.Message{
{SenderType: enums.IMSenderTypeCustomer, Content: "帮我建个工单,支付一直失败"},
}
analysis := buildAnalyzeConversationResult(conversation, messages, AnalyzeConversationInput{
NeedTicket: true,
})
if analysis.RecommendedNextAction != "prepare_ticket" {
t.Fatalf("expected prepare_ticket, got %q", analysis.RecommendedNextAction)
}
draft := buildPrepareTicketDraftResult(conversation, messages, PrepareTicketDraftInput{})
if draft.Title == "" || draft.Description == "" {
t.Fatalf("expected draft to be populated, got %#v", draft)
}
}
func TestTriageServiceRequestResult_Handoff(t *testing.T) {
conversation := models.Conversation{
LastMessageSummary: "用户要求人工处理扣费投诉",