diff --git a/internal/ai/runtime/graphs/handoff_graph.go b/internal/ai/runtime/graphs/handoff_graph.go new file mode 100644 index 0000000..68ee490 --- /dev/null +++ b/internal/ai/runtime/graphs/handoff_graph.go @@ -0,0 +1,148 @@ +package graphs + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "cs-agent/internal/models" + "cs-agent/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"` +} + +func init() { + schema.RegisterName[HandoffGraphState]("cs_agent_handoff_graph_state") + schema.RegisterName[HandoffGraphInterruptInfo]("cs_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) { + if g == nil || g.conversation == nil || g.aiAgent == nil { + return "", fmt.Errorf("handoff graph not initialized") + } + wasInterrupted, hasState, state := componenttool.GetInterruptState[HandoffGraphState](ctx) + if !wasInterrupted { + reason, err := g.buildReason(argumentsInJSON) + if err != nil { + return "", err + } + info := HandoffGraphInterruptInfo{ + Type: "handoff_confirmation", + Message: g.buildConfirmationPrompt(reason), + } + 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: "handoff_confirmation", + Message: g.buildConfirmationPrompt(state.Reason), + } + return "", componenttool.StatefulInterrupt(ctx, info, state) + } + if !hasData { + info := HandoffGraphInterruptInfo{ + Type: "handoff_confirmation", + Message: "请回复“确认”或“取消”。", + } + return "", componenttool.StatefulInterrupt(ctx, info, state) + } + switch parseHandoffDecision(resumeText) { + case graphDecisionConfirm: + if err := services.ConversationService.HandoffByAI(g.conversation.ID, g.aiAgent, state.Reason); err != nil { + return "", err + } + return "已为你转接人工客服,请稍候。", nil + case graphDecisionCancel: + return "已取消本次转人工。", nil + default: + info := HandoffGraphInterruptInfo{ + Type: "handoff_confirmation", + Message: "我需要你的明确确认,请直接回复“确认”或“取消”。", + } + return "", componenttool.StatefulInterrupt(ctx, info, state) + } +} + +func (g *HandoffGraph) buildReason(argumentsInJSON string) (string, error) { + reason := "用户需要转人工支持" + raw := make(map[string]any) + if strings.TrimSpace(argumentsInJSON) != "" { + if err := json.Unmarshal([]byte(argumentsInJSON), &raw); err != nil { + return "", fmt.Errorf("invalid handoff arguments: %w", err) + } + } + if parsed := strings.TrimSpace(graphGetStringValue(raw, "reason")); parsed != "" { + reason = parsed + } + return reason, nil +} + +func (g *HandoffGraph) buildConfirmationPrompt(reason string) string { + return fmt.Sprintf("我准备为你转接人工客服。\n原因:%s\n请直接回复“确认”或“取消”。", strings.TrimSpace(reason)) +} + +type graphDecision string + +const ( + graphDecisionConfirm graphDecision = "confirm" + graphDecisionCancel graphDecision = "cancel" +) + +func parseHandoffDecision(value string) graphDecision { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + confirmWords := []string{"确认", "是", "好的", "可以", "ok", "yes", "继续", "同意"} + for _, item := range confirmWords { + if strings.Contains(value, item) { + return graphDecisionConfirm + } + } + cancelWords := []string{"取消", "不用", "不需要", "算了", "no"} + for _, item := range cancelWords { + if strings.Contains(value, item) { + return graphDecisionCancel + } + } + return "" +} + +func graphGetStringValue(data map[string]any, key string) string { + if len(data) == 0 { + return "" + } + value, ok := data[key] + if !ok || value == nil { + return "" + } + text, _ := value.(string) + return text +} diff --git a/internal/ai/runtime/internal/impl/factory/agent_factory.go b/internal/ai/runtime/internal/impl/factory/agent_factory.go index 721821f..b524948 100644 --- a/internal/ai/runtime/internal/impl/factory/agent_factory.go +++ b/internal/ai/runtime/internal/impl/factory/agent_factory.go @@ -83,6 +83,9 @@ func (f *AgentFactory) BuildCustomerServiceAgent(ctx context.Context, aiAgent *m } else if toolCode == toolx.GraphCreateTicketConfirmToolCode { serverCode = toolx.GraphToolCatalogServerCode toolName = toolx.GraphCreateTicketConfirmToolName + } else if toolCode == toolx.GraphHandoffConversationToolCode { + serverCode = toolx.GraphToolCatalogServerCode + toolName = toolx.GraphHandoffConversationToolName } toolMetadataBy[modelName] = einocallbacks.ToolMetadata{ ToolCode: toolCode, @@ -150,6 +153,16 @@ func buildAgentInstruction(aiAgent *models.AIAgent, selectedSkill *models.SkillD 3. 一旦准备创建工单,必须调用 create_ticket_with_confirmation 工具,禁止直接口头宣称“已经创建工单”。 4. 该 Graph Tool 会先向用户发起确认。用户确认后才会真正创建工单;用户取消则结束本次建单流程。 5. 如果用户只是咨询、抱怨或泛泛表达不满,但没有明确要求建单,优先继续澄清,不要主动创建工单。 +`)) + } + if hasToolCode(extraToolCodes, toolx.GraphHandoffConversationToolCode) { + appendixParts = append(appendixParts, strings.TrimSpace(` +你可以在确认需要人工介入后调用 handoff_to_human 这个 Graph Tool 来转人工,但必须遵守以下规则: +1. 只有在用户明确要求人工客服,或你已经判断该问题必须由人工继续处理时,才调用该工具。 +2. 调用前先尽量整理清楚转人工原因;如果理由含糊,先追问或澄清,不要直接转人工。 +3. 一旦决定转人工,必须调用 handoff_to_human 工具,禁止只在回复里口头说“我帮你转人工了”。 +4. 该 Graph Tool 会先向用户发起确认。用户确认后才会真正转人工;用户取消则结束本次转人工流程。 +5. 如果问题仍可由当前对话继续解决,优先继续解答,不要过早转人工。 `)) } projectRoot, _ := os.Getwd() diff --git a/internal/ai/runtime/reply_service.go b/internal/ai/runtime/reply_service.go index 67d6931..7d58976 100644 --- a/internal/ai/runtime/reply_service.go +++ b/internal/ai/runtime/reply_service.go @@ -673,33 +673,12 @@ func isCheckpointMissingError(err error) bool { } func (s *aiReplyService) handoffConversation(conversation models.Conversation, aiAgent models.AIAgent, reason string) error { - now := time.Now() - if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, map[string]any{ - "handoff_at": now, - "handoff_reason": strings.TrimSpace(reason), - "status": enums.IMConversationStatusPending, - "current_team_id": 0, - "current_assignee_id": 0, - "update_user_id": 0, - "update_user_name": aiAgent.Name, - "updated_at": now, - }); err != nil { - return err - } - return svc.ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", strings.TrimSpace(reason)) - }); err != nil { + if err := svc.ConversationService.HandoffByAI(conversation.ID, &aiAgent, reason); err != nil { return err } if _, err := svc.MessageService.SendAIMessage(conversation.ID, aiAgent.ID, fmt.Sprintf("ai_handoff_%d", conversation.LastMessageID), enums.IMMessageTypeText, "已为你转接人工客服,请稍候。", "", s.buildAIPrincipal(aiAgent)); err != nil { return err } - if _, err := svc.ConversationDispatchService.DispatchConversation(conversation.ID); err != nil { - slog.Warn("auto dispatch conversation after ai handoff failed", - "conversation_id", conversation.ID, - "ai_agent_id", aiAgent.ID, - "error", err) - } return nil } diff --git a/internal/ai/runtime/service.go b/internal/ai/runtime/service.go index 76bf9b4..b6a0a09 100644 --- a/internal/ai/runtime/service.go +++ b/internal/ai/runtime/service.go @@ -20,6 +20,7 @@ func newService() *service { runtime: engine.NewService(), registry: registry.NewRegistry( tools.NewCreateTicketGraphTool(), + tools.NewHandoffGraphTool(), ), } } diff --git a/internal/ai/runtime/tools/handoff_graph_tool.go b/internal/ai/runtime/tools/handoff_graph_tool.go new file mode 100644 index 0000000..09bd5cf --- /dev/null +++ b/internal/ai/runtime/tools/handoff_graph_tool.go @@ -0,0 +1,83 @@ +package tools + +import ( + "context" + "fmt" + + "cs-agent/internal/ai/runtime/graphs" + "cs-agent/internal/ai/runtime/registry" + "cs-agent/internal/models" + "cs-agent/internal/pkg/toolx" + + einotool "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" + einojsonschema "github.com/eino-contrib/jsonschema" + orderedmap "github.com/wk8/go-ordered-map/v2" +) + +const ( + HandoffConversationToolCode = toolx.GraphHandoffConversationToolCode + HandoffConversationToolName = toolx.GraphHandoffConversationToolName +) + +type HandoffGraphTool struct { + conversation *models.Conversation + aiAgent *models.AIAgent +} + +func NewHandoffGraphTool() *HandoffGraphTool { + return &HandoffGraphTool{} +} + +func (t *HandoffGraphTool) Name() string { + return HandoffConversationToolName +} + +func (t *HandoffGraphTool) Code() string { + return HandoffConversationToolCode +} + +func (t *HandoffGraphTool) Enabled(ctx registry.Context) bool { + return ctx.Conversation != nil && ctx.AIAgent != nil +} + +func (t *HandoffGraphTool) Build(ctx registry.Context) (einotool.BaseTool, error) { + if !t.Enabled(ctx) { + return nil, nil + } + return &HandoffGraphTool{ + conversation: ctx.Conversation, + aiAgent: ctx.AIAgent, + }, nil +} + +func (t *HandoffGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) { + return &schema.ToolInfo{ + Name: HandoffConversationToolName, + Desc: "Graph Tool。用于封装转人工原因整理、用户确认、真正转人工和结果返回的确定性流程。仅在用户明确要求人工客服,或你已确认必须转人工处理时调用。", + ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{ + Version: einojsonschema.Version, + Type: "object", + Properties: orderedmap.New[string, *einojsonschema.Schema](orderedmap.WithInitialData( + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "reason", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "转人工原因,简洁说明为何需要人工介入,例如用户明确要求人工、问题需要人工核验、需要人工售后处理等。", + }, + }, + )), + }), + Extra: map[string]any{ + "toolCode": HandoffConversationToolCode, + "sourceType": "graph", + }, + }, nil +} + +func (t *HandoffGraphTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) { + if t == nil || t.conversation == nil || t.aiAgent == nil { + return "", fmt.Errorf("handoff graph tool not initialized") + } + return graphs.NewHandoffGraph(t.conversation, t.aiAgent).Run(ctx, argumentsInJSON) +} diff --git a/internal/controllers/console/ai_agent_controller.go b/internal/controllers/console/ai_agent_controller.go index bdaf5c0..5ab8229 100644 --- a/internal/controllers/console/ai_agent_controller.go +++ b/internal/controllers/console/ai_agent_controller.go @@ -186,6 +186,9 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse { } else if toolCode == toolx.GraphCreateTicketConfirmToolCode { serverCode = toolx.GraphToolCatalogServerCode toolName = toolx.GraphCreateTicketConfirmToolName + } else if toolCode == toolx.GraphHandoffConversationToolCode { + serverCode = toolx.GraphToolCatalogServerCode + toolName = toolx.GraphHandoffConversationToolName } else if parsedServerCode, parsedToolName := toolx.SplitMCPToolCode(toolCode); parsedServerCode != "" && parsedToolName != "" { serverCode = parsedServerCode toolName = parsedToolName @@ -197,6 +200,8 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse { title = toolx.BuiltinToolSearchToolTitle case toolx.GraphCreateTicketConfirmToolCode: title = toolx.GraphCreateTicketConfirmToolTitle + case toolx.GraphHandoffConversationToolCode: + title = toolx.GraphHandoffConversationToolTitle } } description := strings.TrimSpace(tool.Description) @@ -206,6 +211,8 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse { description = toolx.BuiltinToolSearchToolDescription case toolx.GraphCreateTicketConfirmToolCode: description = toolx.GraphCreateTicketConfirmToolDescription + case toolx.GraphHandoffConversationToolCode: + description = toolx.GraphHandoffConversationToolDescription } } ret.DirectTools = append(ret.DirectTools, response.AIAgentMCPToolResponse{ diff --git a/internal/pkg/toolx/builtin_tools.go b/internal/pkg/toolx/builtin_tools.go index 2d1d4a5..4b51385 100644 --- a/internal/pkg/toolx/builtin_tools.go +++ b/internal/pkg/toolx/builtin_tools.go @@ -11,6 +11,10 @@ const ( GraphCreateTicketConfirmToolName = "create_ticket_with_confirmation" GraphCreateTicketConfirmToolTitle = "创建工单确认流程" GraphCreateTicketConfirmToolDescription = "Graph Tool。用于封装建单参数整理、用户确认、真正建单和结果返回的确定性流程。" + GraphHandoffConversationToolCode = "graph/handoff_to_human" + GraphHandoffConversationToolName = "handoff_to_human" + GraphHandoffConversationToolTitle = "转人工确认流程" + GraphHandoffConversationToolDescription = "Graph Tool。用于封装转人工原因整理、用户确认、真正转人工和结果返回的确定性流程。" BuiltinCreateTicketConfirmToolCode = "builtin/create_ticket_with_confirmation" BuiltinCreateTicketConfirmToolName = "create_ticket_with_confirmation" BuiltinCreateTicketConfirmToolTitle = "创建工单并发起确认" diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index a100c41..98f05f4 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log/slog" "cs-agent/internal/models" "cs-agent/internal/pkg/constants" @@ -284,6 +285,44 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6 return nil } +func (s *conversationService) HandoffByAI(conversationID int64, aiAgent *models.AIAgent, reason string) error { + if conversationID <= 0 { + return errorsx.InvalidParam("会话不存在") + } + if aiAgent == nil { + return errorsx.InvalidParam("AI Agent 不存在") + } + now := time.Now() + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if conversation == nil { + return errorsx.InvalidParam("会话不存在") + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "handoff_at": now, + "handoff_reason": strings.TrimSpace(reason), + "status": enums.IMConversationStatusPending, + "current_team_id": 0, + "current_assignee_id": 0, + "update_user_id": 0, + "update_user_name": aiAgent.Name, + "updated_at": now, + }); err != nil { + return err + } + return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", strings.TrimSpace(reason)) + }); err != nil { + return err + } + if _, err := ConversationDispatchService.DispatchConversation(conversationID); err != nil { + slog.Warn("auto dispatch conversation after ai handoff failed", + "conversation_id", conversationID, + "ai_agent_id", aiAgent.ID, + "error", err) + } + return nil +} + func (s *conversationService) CloseConversation(conversationID int64, closeReason string, operator *dto.AuthPrincipal) error { if operator == nil { return errorsx.Unauthorized("未登录或登录已过期") diff --git a/internal/services/tool_catalog_service.go b/internal/services/tool_catalog_service.go index 7da344a..251c298 100644 --- a/internal/services/tool_catalog_service.go +++ b/internal/services/tool_catalog_service.go @@ -33,7 +33,7 @@ type MCPToolCatalogItem struct { func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalogItem, error) { cfg := config.Current() - ret := make([]MCPToolCatalogItem, 0, 2) + ret := make([]MCPToolCatalogItem, 0, 3) ret = append(ret, MCPToolCatalogItem{ ToolCode: toolx.GraphCreateTicketConfirmToolCode, ServerCode: toolx.GraphToolCatalogServerCode, @@ -43,6 +43,15 @@ func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalog Title: toolx.GraphCreateTicketConfirmToolTitle, Description: toolx.GraphCreateTicketConfirmToolDescription, }) + ret = append(ret, MCPToolCatalogItem{ + ToolCode: toolx.GraphHandoffConversationToolCode, + ServerCode: toolx.GraphToolCatalogServerCode, + ToolName: toolx.GraphHandoffConversationToolName, + SourceType: toolx.GraphToolCatalogServerCode, + AutoInjected: false, + Title: toolx.GraphHandoffConversationToolTitle, + Description: toolx.GraphHandoffConversationToolDescription, + }) if !cfg.MCP.Enabled { return ret, nil } @@ -96,7 +105,7 @@ func (s *toolCatalogService) ValidateToolCode(toolCode string) error { return errorsx.InvalidParam("toolCode不能为空") } switch toolCode { - case toolx.BuiltinToolSearchToolCode, toolx.BuiltinCreateTicketConfirmToolCode, toolx.GraphCreateTicketConfirmToolCode: + case toolx.BuiltinToolSearchToolCode, toolx.BuiltinCreateTicketConfirmToolCode, toolx.GraphCreateTicketConfirmToolCode, toolx.GraphHandoffConversationToolCode: return nil } serverCode, toolName := toolx.SplitMCPToolCode(toolCode)