chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
224
internal/ai/runtime/graphs/analyze_conversation_graph.go
Normal file
224
internal/ai/runtime/graphs/analyze_conversation_graph.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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 containsAny(joined, "工单", "报障", "售后", "登记", "记录问题") || input.NeedTicket {
|
||||
add("ticket_expected")
|
||||
}
|
||||
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 input.NeedTicket || containsAny(joined, "工单", "报障", "售后", "登记问题"):
|
||||
return "ticket_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, "ticket_expected") || intent == "ticket_request":
|
||||
return "prepare_ticket"
|
||||
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, "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.")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
func TestBuildAnalyzeConversationResult_RecommendsHandoffForComplaint(t *testing.T) {
|
||||
conversation := models.Conversation{
|
||||
LastMessageSummary: "用户反馈被重复扣费,并要求人工处理",
|
||||
}
|
||||
messages := []models.Message{
|
||||
{SenderType: enums.IMSenderTypeCustomer, Content: "你们重复扣费了,我要投诉并转人工"},
|
||||
}
|
||||
|
||||
got := buildAnalyzeConversationResult(conversation, messages, AnalyzeConversationInput{
|
||||
NeedHumanHandoff: true,
|
||||
})
|
||||
|
||||
if got.UserIntent != "handoff_request" {
|
||||
t.Fatalf("expected handoff_request, got %q", got.UserIntent)
|
||||
}
|
||||
if got.RiskLevel != "high" {
|
||||
t.Fatalf("expected high risk, got %q", got.RiskLevel)
|
||||
}
|
||||
if got.RecommendedNextAction != "handoff_to_human" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
153
internal/ai/runtime/graphs/create_ticket_graph.go
Normal file
153
internal/ai/runtime/graphs/create_ticket_graph.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/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) {
|
||||
locale := services.ConversationHumanDispatchService.ResolveConversationLocale(g.conversation.ID)
|
||||
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, locale),
|
||||
}
|
||||
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, locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
if !hasData {
|
||||
info := CreateTicketGraphInterruptInfo{
|
||||
Type: InterruptTypeTicketCreationConfirmation,
|
||||
Message: ConfirmOrCancelPromptForLocale(locale),
|
||||
}
|
||||
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(locale, "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: CancelCreateTicketReplyForLocale(locale),
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
default:
|
||||
info := CreateTicketGraphInterruptInfo{
|
||||
Type: InterruptTypeTicketCreationConfirmation,
|
||||
Message: NeedExplicitConfirmationPromptForLocale(locale),
|
||||
}
|
||||
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, locale string) string {
|
||||
return i18nx.Getf(locale, "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,
|
||||
}
|
||||
}
|
||||
150
internal/ai/runtime/graphs/handoff_graph.go
Normal file
150
internal/ai/runtime/graphs/handoff_graph.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/tracex"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
componenttool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type HandoffGraphState struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type HandoffGraphInterruptInfo struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type handoffGraphArgs struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
schema.RegisterName[HandoffGraphState]("cs_ai_agent_handoff_graph_state")
|
||||
schema.RegisterName[HandoffGraphInterruptInfo]("cs_ai_agent_handoff_graph_interrupt_info")
|
||||
}
|
||||
|
||||
type HandoffGraph struct {
|
||||
conversation models.Conversation
|
||||
aiAgent models.AIAgent
|
||||
}
|
||||
|
||||
func NewHandoffGraph(conversation models.Conversation, aiAgent models.AIAgent) *HandoffGraph {
|
||||
return &HandoffGraph{
|
||||
conversation: conversation,
|
||||
aiAgent: aiAgent,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *HandoffGraph) Run(ctx context.Context, argumentsInJSON string) (string, error) {
|
||||
locale := services.ConversationHumanDispatchService.ResolveConversationLocale(g.conversation.ID)
|
||||
wasInterrupted, hasState, state := componenttool.GetInterruptState[HandoffGraphState](ctx)
|
||||
if !wasInterrupted {
|
||||
reason, err := g.buildReason(argumentsInJSON, locale)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
requestID := tracex.RequestIDFromContext(ctx)
|
||||
handled, err := services.ConversationService.TryOffHoursHandoffByAIWithRequestID(g.conversation.ID, g.aiAgent, reason, requestID)
|
||||
if err != nil || handled {
|
||||
if handled && err == nil {
|
||||
offHoursMsg := strings.TrimSpace(g.aiAgent.OffHoursMessage)
|
||||
if offHoursMsg == "" {
|
||||
offHoursMsg = services.HandoffOffHoursMessageForLocale(locale)
|
||||
}
|
||||
return tooling.MarshalToolResult(tooling.ToolResult{
|
||||
Handled: true,
|
||||
Terminal: true,
|
||||
Action: "off_hours_handoff",
|
||||
ReplyText: offHoursMsg,
|
||||
ReplySent: true,
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
info := HandoffGraphInterruptInfo{
|
||||
Type: InterruptTypeHandoffConfirmation,
|
||||
Message: g.buildConfirmationPrompt(reason, locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, HandoffGraphState{Reason: reason})
|
||||
}
|
||||
if !hasState {
|
||||
return "", fmt.Errorf("handoff graph state missing")
|
||||
}
|
||||
isResumeTarget, hasData, resumeText := componenttool.GetResumeContext[string](ctx)
|
||||
if !isResumeTarget {
|
||||
info := HandoffGraphInterruptInfo{
|
||||
Type: InterruptTypeHandoffConfirmation,
|
||||
Message: g.buildConfirmationPrompt(state.Reason, locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
if !hasData {
|
||||
info := HandoffGraphInterruptInfo{
|
||||
Type: InterruptTypeHandoffConfirmation,
|
||||
Message: ConfirmOrCancelPromptForLocale(locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
switch parseHandoffDecision(resumeText) {
|
||||
case ConfirmationDecisionConfirm:
|
||||
if err := services.ConversationService.HandoffByAIWithRequestID(g.conversation.ID, g.aiAgent, state.Reason, tracex.RequestIDFromContext(ctx)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// ConversationService sends the customer-visible handoff notice according to the dispatch decision.
|
||||
return tooling.MarshalToolResult(tooling.ToolResult{
|
||||
Handled: true,
|
||||
Terminal: true,
|
||||
Action: "handoff_confirmed",
|
||||
ReplySent: true,
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
case ConfirmationDecisionCancel:
|
||||
return tooling.MarshalToolResult(tooling.ToolResult{
|
||||
Handled: true,
|
||||
Terminal: true,
|
||||
Action: "handoff_cancelled",
|
||||
ReplyText: CancelHandoffReplyForLocale(locale),
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
default:
|
||||
info := HandoffGraphInterruptInfo{
|
||||
Type: InterruptTypeHandoffConfirmation,
|
||||
Message: NeedExplicitConfirmationPromptForLocale(locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *HandoffGraph) buildReason(argumentsInJSON string, locale string) (string, error) {
|
||||
reason := i18nx.Getf(locale, "graph.defaultHandoffReason")
|
||||
var args handoffGraphArgs
|
||||
if strings.TrimSpace(argumentsInJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid handoff arguments: %w", err)
|
||||
}
|
||||
}
|
||||
if parsed := strings.TrimSpace(args.Reason); parsed != "" {
|
||||
reason = parsed
|
||||
}
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
func (g *HandoffGraph) buildConfirmationPrompt(reason string, locale string) string {
|
||||
return i18nx.Getf(locale, "graph.handoffConfirmPrompt", strings.TrimSpace(reason))
|
||||
}
|
||||
|
||||
func parseHandoffDecision(value string) ConfirmationDecision {
|
||||
return ParseConfirmationDecision(value)
|
||||
}
|
||||
247
internal/ai/runtime/graphs/handoff_graph_test.go
Normal file
247
internal/ai/runtime/graphs/handoff_graph_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestHandoffGraphOffHoursSendsNoticeWithoutConfirmation(t *testing.T) {
|
||||
db := setupHandoffGraphTestDB(t)
|
||||
aiAgent := createHandoffGraphAIAgent(t, db, "1")
|
||||
conversation := createHandoffGraphConversation(t, db, aiAgent.ID)
|
||||
|
||||
reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"用户要求转人工"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var result tooling.ToolResult
|
||||
if err := json.Unmarshal([]byte(reply), &result); err != nil {
|
||||
t.Fatalf("expected graph tool result JSON, got %q: %v", reply, err)
|
||||
}
|
||||
if !result.Handled || !result.Terminal || result.ShouldRetry {
|
||||
t.Fatalf("unexpected graph result flags: %+v", result)
|
||||
}
|
||||
if !result.ReplySent {
|
||||
t.Fatalf("expected graph result to mark replySent, got %+v", result)
|
||||
}
|
||||
expectedMsg := services.HandoffOffHoursMessageForLocale(services.ResolveConversationLocale(conversation.ID))
|
||||
if result.Action != "off_hours_handoff" || result.ReplyText != expectedMsg {
|
||||
t.Fatalf("unexpected off-hours graph result: %+v, want %q", result, expectedMsg)
|
||||
}
|
||||
|
||||
message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id"))
|
||||
if message == nil {
|
||||
t.Fatalf("expected off-hours notice message")
|
||||
}
|
||||
if message.Content != expectedMsg {
|
||||
t.Fatalf("expected off-hours notice %q, got %q", expectedMsg, message.Content)
|
||||
}
|
||||
|
||||
current := services.ConversationService.Get(conversation.ID)
|
||||
if current == nil {
|
||||
t.Fatalf("expected conversation")
|
||||
}
|
||||
if current.Status != enums.IMConversationStatusPending {
|
||||
t.Fatalf("expected conversation to be queued in pending status, got %d", current.Status)
|
||||
}
|
||||
if current.HandoffAt == nil {
|
||||
t.Fatalf("expected handoff_at to be set, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffGraphOffHoursSendsNoticeInRussianForRussianConversation(t *testing.T) {
|
||||
db := setupHandoffGraphTestDB(t)
|
||||
aiAgent := createHandoffGraphAIAgent(t, db, "1")
|
||||
conversation := createHandoffGraphConversation(t, db, aiAgent.ID)
|
||||
|
||||
now := time.Now()
|
||||
if err := db.Create(&models.Message{
|
||||
ConversationID: conversation.ID,
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
SenderID: 1,
|
||||
Content: "Привет хочу возрат денег",
|
||||
SentAt: &now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create message error = %v", err)
|
||||
}
|
||||
|
||||
reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"перенаправляю оператору"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var result tooling.ToolResult
|
||||
if err := json.Unmarshal([]byte(reply), &result); err != nil {
|
||||
t.Fatalf("expected graph tool result JSON, got %q: %v", reply, err)
|
||||
}
|
||||
expectedRuMsg := services.HandoffOffHoursMessageForLocale("ru-RU")
|
||||
if result.Action != "off_hours_handoff" || result.ReplyText != expectedRuMsg {
|
||||
t.Fatalf("unexpected off-hours graph result for ru-RU: %+v, want %q", result, expectedRuMsg)
|
||||
}
|
||||
|
||||
message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Eq("sender_type", enums.IMSenderTypeAI).Desc("id"))
|
||||
if message == nil {
|
||||
t.Fatalf("expected off-hours notice message")
|
||||
}
|
||||
if message.Content != expectedRuMsg {
|
||||
t.Fatalf("expected Russian off-hours notice %q, got %q", expectedRuMsg, message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffGraphOffHoursUsesCustomAgentNotice(t *testing.T) {
|
||||
db := setupHandoffGraphTestDB(t)
|
||||
aiAgent := createHandoffGraphAIAgent(t, db, "1")
|
||||
aiAgent.OffHoursMessage = "Операторы сейчас оффлайн. Мы ответим позже!"
|
||||
conversation := createHandoffGraphConversation(t, db, aiAgent.ID)
|
||||
|
||||
reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"позови человека"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var result tooling.ToolResult
|
||||
if err := json.Unmarshal([]byte(reply), &result); err != nil {
|
||||
t.Fatalf("expected graph tool result JSON, got %q: %v", reply, err)
|
||||
}
|
||||
if result.Action != "off_hours_handoff" || result.ReplyText != aiAgent.OffHoursMessage {
|
||||
t.Fatalf("expected custom notice %q, got %+v", aiAgent.OffHoursMessage, result)
|
||||
}
|
||||
|
||||
current := services.ConversationService.Get(conversation.ID)
|
||||
if current == nil || current.Status != enums.IMConversationStatusPending {
|
||||
t.Fatalf("expected conversation to be queued in pending status, got %v", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffGraphWithActiveScheduleStillRequestsConfirmation(t *testing.T) {
|
||||
db := setupHandoffGraphTestDB(t)
|
||||
aiAgent := createHandoffGraphAIAgent(t, db, "1")
|
||||
createHandoffGraphTeam(t, db, 1)
|
||||
createHandoffGraphActiveSchedule(t, db, 1)
|
||||
conversation := createHandoffGraphConversation(t, db, aiAgent.ID)
|
||||
|
||||
reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"用户要求转人工"}`)
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirmation interrupt")
|
||||
}
|
||||
if !strings.Contains(err.Error(), InterruptTypeHandoffConfirmation) {
|
||||
t.Fatalf("expected handoff confirmation interrupt, got %v", err)
|
||||
}
|
||||
if reply != "" {
|
||||
t.Fatalf("expected no reply before confirmation, got %q", reply)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count messages error = %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected no notice before confirmation, got %d messages", count)
|
||||
}
|
||||
}
|
||||
|
||||
func setupHandoffGraphTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(
|
||||
&models.Channel{},
|
||||
&models.CustomerIdentity{},
|
||||
&models.AIAgent{},
|
||||
&models.AgentTeam{},
|
||||
&models.AgentTeamSchedule{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationAssignment{},
|
||||
&models.ConversationEventLog{},
|
||||
&models.ConversationReadState{},
|
||||
&models.Message{},
|
||||
&models.ChannelMessageOutbox{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate error = %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
|
||||
func createHandoffGraphAIAgent(t *testing.T, db *gorm.DB, teamIDs string) models.AIAgent {
|
||||
t.Helper()
|
||||
|
||||
item := models.AIAgent{
|
||||
Name: "测试AI",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
TeamIDs: teamIDs,
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create ai agent error = %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func createHandoffGraphTeam(t *testing.T, db *gorm.DB, id int64) {
|
||||
t.Helper()
|
||||
|
||||
if err := db.Create(&models.AgentTeam{ID: id, Name: "售后支持组", Status: enums.StatusOk}).Error; err != nil {
|
||||
t.Fatalf("create team error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createHandoffGraphActiveSchedule(t *testing.T, db *gorm.DB, teamID int64) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
if err := db.Create(&models.AgentTeamSchedule{
|
||||
TeamID: teamID,
|
||||
StartAt: now.Add(-time.Hour),
|
||||
EndAt: now.Add(time.Hour),
|
||||
Status: enums.StatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create schedule error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createHandoffGraphConversation(t *testing.T, db *gorm.DB, aiAgentID int64) models.Conversation {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
item := models.Conversation{
|
||||
AIAgentID: aiAgentID,
|
||||
ChannelID: 1,
|
||||
CustomerID: 1,
|
||||
CustomerName: "测试访客",
|
||||
Status: enums.IMConversationStatusAIServing,
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
LastMessageAt: now,
|
||||
LastActiveAt: now,
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create conversation error = %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
101
internal/ai/runtime/graphs/hitl.go
Normal file
101
internal/ai/runtime/graphs/hitl.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
)
|
||||
|
||||
const (
|
||||
InterruptTypeTicketCreationConfirmation = "ticket_creation_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")
|
||||
)
|
||||
|
||||
func ConfirmOrCancelPromptForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.confirmOrCancel")
|
||||
}
|
||||
|
||||
func NeedExplicitConfirmationPromptForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.needExplicitConfirmation")
|
||||
}
|
||||
|
||||
func ConfirmationExpiredReplyForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.confirmationExpired")
|
||||
}
|
||||
|
||||
func CancelCreateTicketReplyForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.cancelCreateTicket")
|
||||
}
|
||||
|
||||
func CancelHandoffReplyForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.cancelHandoff")
|
||||
}
|
||||
|
||||
type ConfirmationDecision string
|
||||
|
||||
const (
|
||||
ConfirmationDecisionConfirm ConfirmationDecision = "confirm"
|
||||
ConfirmationDecisionCancel ConfirmationDecision = "cancel"
|
||||
)
|
||||
|
||||
func ParseConfirmationDecision(value string) ConfirmationDecision {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
cancelWords := []string{
|
||||
"取消", "不用", "不需要", "算了", "no", "cancel", "stop",
|
||||
"нет", "отмена", "отменить", "не надо", "не нужно", "стоп", "хватит", "отбой",
|
||||
}
|
||||
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, "已取消本次转人工。") ||
|
||||
strings.Contains(replyText, "Создание обращения отменено.") ||
|
||||
strings.Contains(replyText, "Передача человеку отменена.") ||
|
||||
strings.Contains(replyText, "Ticket creation cancelled.") ||
|
||||
strings.Contains(replyText, "Human handoff cancelled.")
|
||||
}
|
||||
186
internal/ai/runtime/graphs/prepare_ticket_draft_graph.go
Normal file
186
internal/ai/runtime/graphs/prepare_ticket_draft_graph.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/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])) + "..."
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/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)
|
||||
}
|
||||
}
|
||||
80
internal/ai/runtime/graphs/triage_service_request_graph.go
Normal file
80
internal/ai/runtime/graphs/triage_service_request_graph.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/services"
|
||||
)
|
||||
|
||||
type TriageServiceRequestInput struct {
|
||||
Goal string `json:"goal"`
|
||||
ObservedIssue string `json:"observedIssue"`
|
||||
NeedTicket bool `json:"needTicket"`
|
||||
NeedHumanHandoff bool `json:"needHumanHandoff"`
|
||||
AdditionalContext string `json:"additionalContext"`
|
||||
}
|
||||
|
||||
type TriageServiceRequestResult struct {
|
||||
Analysis AnalyzeConversationResult `json:"analysis"`
|
||||
TicketDraft *PrepareTicketDraftResult `json:"ticketDraft,omitempty"`
|
||||
RecommendedAction string `json:"recommendedAction"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
type TriageServiceRequestGraph struct {
|
||||
conversation models.Conversation
|
||||
}
|
||||
|
||||
func NewTriageServiceRequestGraph(conversation models.Conversation) *TriageServiceRequestGraph {
|
||||
return &TriageServiceRequestGraph{conversation: conversation}
|
||||
}
|
||||
|
||||
func (g *TriageServiceRequestGraph) 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, "", "")
|
||||
analysis := buildAnalyzeConversationResult(g.conversation, messages, AnalyzeConversationInput{
|
||||
Goal: input.Goal,
|
||||
ObservedIssue: input.ObservedIssue,
|
||||
NeedTicket: input.NeedTicket,
|
||||
NeedHumanHandoff: input.NeedHumanHandoff,
|
||||
AdditionalContext: input.AdditionalContext,
|
||||
})
|
||||
result := TriageServiceRequestResult{
|
||||
Analysis: analysis,
|
||||
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
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func (g *TriageServiceRequestGraph) parseInput(argumentsInJSON string) (TriageServiceRequestInput, error) {
|
||||
var input TriageServiceRequestInput
|
||||
if strings.TrimSpace(argumentsInJSON) == "" {
|
||||
return input, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &input); err != nil {
|
||||
return input, fmt.Errorf("invalid triage service request arguments: %w", err)
|
||||
}
|
||||
input.Goal = strings.TrimSpace(input.Goal)
|
||||
input.ObservedIssue = strings.TrimSpace(input.ObservedIssue)
|
||||
input.AdditionalContext = strings.TrimSpace(input.AdditionalContext)
|
||||
return input, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/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: "用户要求人工处理扣费投诉",
|
||||
}
|
||||
messages := []models.Message{
|
||||
{SenderType: enums.IMSenderTypeCustomer, Content: "我要投诉并转人工,你们重复扣费了"},
|
||||
}
|
||||
|
||||
analysis := buildAnalyzeConversationResult(conversation, messages, AnalyzeConversationInput{
|
||||
NeedHumanHandoff: true,
|
||||
})
|
||||
if analysis.RecommendedNextAction != "handoff_to_human" {
|
||||
t.Fatalf("expected handoff_to_human, got %q", analysis.RecommendedNextAction)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user