diff --git a/internal/ai/runtime/graphs/triage_service_request_graph.go b/internal/ai/runtime/graphs/triage_service_request_graph.go new file mode 100644 index 0000000..ba076b9 --- /dev/null +++ b/internal/ai/runtime/graphs/triage_service_request_graph.go @@ -0,0 +1,83 @@ +package graphs + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "cs-agent/internal/models" + "cs-agent/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) { + if g == nil || g.conversation == nil { + return "", fmt.Errorf("triage service request graph not initialized") + } + 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 +} diff --git a/internal/ai/runtime/graphs/triage_service_request_graph_test.go b/internal/ai/runtime/graphs/triage_service_request_graph_test.go new file mode 100644 index 0000000..b27d8dd --- /dev/null +++ b/internal/ai/runtime/graphs/triage_service_request_graph_test.go @@ -0,0 +1,47 @@ +package graphs + +import ( + "testing" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" +) + +func TestTriageServiceRequestResult_PrepareTicket(t *testing.T) { + conversation := &models.Conversation{ + Subject: "支付失败需要登记工单", + 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{ + Subject: "投诉转人工", + 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) + } +} diff --git a/internal/ai/runtime/internal/impl/factory/agent_factory.go b/internal/ai/runtime/internal/impl/factory/agent_factory.go index 5ba1ce9..dba5107 100644 --- a/internal/ai/runtime/internal/impl/factory/agent_factory.go +++ b/internal/ai/runtime/internal/impl/factory/agent_factory.go @@ -113,6 +113,9 @@ func (f *AgentFactory) BuildCustomerServiceAgent(ctx context.Context, input Buil case toolx.BuiltinToolSearchToolCode: serverCode = toolx.BuiltinToolCatalogServerCode toolName = toolx.BuiltinToolSearchToolName + case toolx.GraphTriageServiceRequestToolCode: + serverCode = toolx.GraphToolCatalogServerCode + toolName = toolx.GraphTriageServiceRequestToolName case toolx.GraphAnalyzeConversationToolCode: serverCode = toolx.GraphToolCatalogServerCode toolName = toolx.GraphAnalyzeConversationToolName @@ -217,6 +220,16 @@ func assembleAgentInstruction(aiAgent *models.AIAgent, selectedSkill *models.Ski 1. 先调用 tool_search 搜索需要的动态工具,再继续使用已选中的真实工具。 2. 不要假设所有长尾工具一开始就可见;只有被 tool_search 选中的工具,后续模型调用才会暴露出来。 3. 如果当前已有固定内置工具可以完成任务,优先使用固定工具,不要滥用 tool_search。 +`)) + } + if hasToolCode(extraToolCodes, toolx.GraphTriageServiceRequestToolCode) { + appendixParts = append(appendixParts, strings.TrimSpace(` +当你需要判断“继续解答 / 建单 / 转人工”这类复杂升级路径时,优先先调用 triage_service_request 这个 Graph Tool,并遵守以下规则: +1. 该工具会综合当前对话输出 recommendedAction,并在需要建单时附带 ticketDraft。 +2. 如果 recommendedAction=continue_answering,则优先继续澄清或解答,不要直接升级。 +3. 如果 recommendedAction=prepare_ticket,则优先使用 ticketDraft 或继续补充缺失字段,再调用 create_ticket_with_confirmation。 +4. 如果 recommendedAction=handoff_to_human,则确认理由充分后再调用 handoff_to_human。 +5. 当升级路径不明确时,优先使用该工具,而不是直接凭主 prompt 做复杂分流判断。 `)) } if hasToolCode(extraToolCodes, toolx.GraphPrepareTicketDraftToolCode) { diff --git a/internal/ai/runtime/registry/registry.go b/internal/ai/runtime/registry/registry.go index 3e13fda..3d283fe 100644 --- a/internal/ai/runtime/registry/registry.go +++ b/internal/ai/runtime/registry/registry.go @@ -59,6 +59,14 @@ func isAllowedToolCode(toolCode string, allowedToolCodes map[string]struct{}) bo if isAlwaysAllowedToolCode(toolCode) { return true } + if strings.TrimSpace(toolCode) == toolx.GraphTriageServiceRequestToolCode { + if _, ok := allowedToolCodes[toolx.GraphCreateTicketConfirmToolCode]; ok { + return true + } + if _, ok := allowedToolCodes[toolx.GraphHandoffConversationToolCode]; ok { + return true + } + } if strings.TrimSpace(toolCode) == toolx.GraphAnalyzeConversationToolCode { if _, ok := allowedToolCodes[toolx.GraphCreateTicketConfirmToolCode]; ok { return true diff --git a/internal/ai/runtime/service.go b/internal/ai/runtime/service.go index 738df19..60d2bb9 100644 --- a/internal/ai/runtime/service.go +++ b/internal/ai/runtime/service.go @@ -19,6 +19,7 @@ func newService() *service { return &service{ runtime: engine.NewService(), registry: registry.NewRegistry( + tools.NewTriageServiceRequestTool(), tools.NewAnalyzeConversationTool(), tools.NewPrepareTicketDraftTool(), tools.NewCreateTicketGraphTool(), diff --git a/internal/ai/runtime/tools/triage_service_request_tool.go b/internal/ai/runtime/tools/triage_service_request_tool.go new file mode 100644 index 0000000..d0118be --- /dev/null +++ b/internal/ai/runtime/tools/triage_service_request_tool.go @@ -0,0 +1,107 @@ +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 ( + TriageServiceRequestToolCode = toolx.GraphTriageServiceRequestToolCode + TriageServiceRequestToolName = toolx.GraphTriageServiceRequestToolName +) + +type TriageServiceRequestTool struct { + conversation *models.Conversation +} + +func NewTriageServiceRequestTool() *TriageServiceRequestTool { + return &TriageServiceRequestTool{} +} + +func (t *TriageServiceRequestTool) Name() string { + return TriageServiceRequestToolName +} + +func (t *TriageServiceRequestTool) Code() string { + return TriageServiceRequestToolCode +} + +func (t *TriageServiceRequestTool) Enabled(ctx registry.Context) bool { + return ctx.Conversation != nil +} + +func (t *TriageServiceRequestTool) Build(ctx registry.Context) (einotool.BaseTool, error) { + if !t.Enabled(ctx) { + return nil, nil + } + return &TriageServiceRequestTool{conversation: ctx.Conversation}, nil +} + +func (t *TriageServiceRequestTool) Info(ctx context.Context) (*schema.ToolInfo, error) { + return &schema.ToolInfo{ + Name: TriageServiceRequestToolName, + 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: "goal", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "当前分析目标,例如判断是否需要升级、是否要建单或转人工。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "observedIssue", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "当前观察到的主要问题或争议点。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "needTicket", + Value: &einojsonschema.Schema{ + Type: "boolean", + Description: "是否重点评估建单必要性。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "needHumanHandoff", + Value: &einojsonschema.Schema{ + Type: "boolean", + Description: "是否重点评估转人工必要性。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "additionalContext", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "补充上下文,例如你已发现的风险点或限制条件。", + }, + }, + )), + }), + Extra: map[string]any{ + "toolCode": TriageServiceRequestToolCode, + "sourceType": "graph", + }, + }, nil +} + +func (t *TriageServiceRequestTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) { + if t == nil || t.conversation == nil { + return "", fmt.Errorf("triage service request tool not initialized") + } + return graphs.NewTriageServiceRequestGraph(t.conversation).Run(ctx, argumentsInJSON) +} diff --git a/internal/pkg/toolx/builtin_tools.go b/internal/pkg/toolx/builtin_tools.go index 8c7fe44..26ea0a3 100644 --- a/internal/pkg/toolx/builtin_tools.go +++ b/internal/pkg/toolx/builtin_tools.go @@ -11,6 +11,10 @@ const ( BuiltinSkillToolTitle = "加载专项技能说明" BuiltinSkillToolDescription = "用于加载当前命中的专项技能说明文档。仅在本轮已命中 Skill 时可用,适合将专项处理规则按需注入上下文。" GraphToolCatalogServerCode = "graph" + GraphTriageServiceRequestToolCode = "graph/triage_service_request" + GraphTriageServiceRequestToolName = "triage_service_request" + GraphTriageServiceRequestToolTitle = "升级分流判断" + GraphTriageServiceRequestToolDescription = "Graph Tool。用于综合分析当前对话,判断应继续解答、整理工单草稿还是转人工,并在需要建单时一并整理工单草稿。" GraphAnalyzeConversationToolCode = "graph/analyze_conversation" GraphAnalyzeConversationToolName = "analyze_conversation" GraphAnalyzeConversationToolTitle = "分析对话风险与摘要"