feat: implement Handoff Graph Tool for managing AI to human transitions
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ func newService() *service {
|
||||
runtime: engine.NewService(),
|
||||
registry: registry.NewRegistry(
|
||||
tools.NewCreateTicketGraphTool(),
|
||||
tools.NewHandoffGraphTool(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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 = "创建工单并发起确认"
|
||||
|
||||
@@ -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("未登录或登录已过期")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user