diff --git a/docs b/docs index d24b234..601709a 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit d24b234349de6e374498618ca94d1e8187b4bae1 +Subproject commit 601709ad1204a3fa82723a98e0f673b92f46537c diff --git a/internal/ai/runtime/graphs/prepare_ticket_draft_graph.go b/internal/ai/runtime/graphs/prepare_ticket_draft_graph.go new file mode 100644 index 0000000..c989b7b --- /dev/null +++ b/internal/ai/runtime/graphs/prepare_ticket_draft_graph.go @@ -0,0 +1,200 @@ +package graphs + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/services" +) + +type PrepareTicketDraftInput struct { + Title string `json:"title"` + Description string `json:"description"` + Issue string `json:"issue"` + Impact string `json:"impact"` + ExpectedOutcome string `json:"expectedOutcome"` + CurrentAttempt string `json:"currentAttempt"` + Priority int64 `json:"priority"` + Severity int `json:"severity"` +} + +type PrepareTicketDraftResult struct { + Ready bool `json:"ready"` + Title string `json:"title"` + Description string `json:"description"` + Priority int64 `json:"priority,omitempty"` + Severity int `json:"severity,omitempty"` + 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) { + if g == nil || g.conversation == nil { + return "", fmt.Errorf("prepare ticket draft graph not initialized") + } + 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{ + Priority: input.Priority, + Severity: input.Severity, + 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, "请补充一个简洁的工单标题,明确概括用户遇到的问题。") + } + if !hasSufficientIssueContext(input, result.Description) { + result.MissingFields = append(result.MissingFields, "issue") + result.FollowUpQuestions = append(result.FollowUpQuestions, "请补充具体问题现象、报错信息或用户诉求,以便整理成工单。") + } + 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 conversation != nil && strings.TrimSpace(conversation.Subject) != "": + return limitText(conversation.Subject, 80) + case conversation != nil: + 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, "问题现象:"+input.Issue) + } + if input.Impact != "" { + parts = append(parts, "影响范围:"+input.Impact) + } + if input.ExpectedOutcome != "" { + parts = append(parts, "用户诉求:"+input.ExpectedOutcome) + } + if input.CurrentAttempt != "" { + parts = append(parts, "已尝试处理:"+input.CurrentAttempt) + } + if conversation != nil && strings.TrimSpace(conversation.LastMessageSummary) != "" { + parts = append(parts, "会话摘要:"+strings.TrimSpace(conversation.LastMessageSummary)) + } + if recent := buildRecentMessageDigest(messages); recent != "" { + parts = append(parts, "最近消息:"+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 conversation != nil && strings.TrimSpace(conversation.Subject) != "" { + facts = append(facts, "会话主题:"+strings.TrimSpace(conversation.Subject)) + } + if conversation != nil && strings.TrimSpace(conversation.LastMessageSummary) != "" { + facts = append(facts, "最近摘要:"+strings.TrimSpace(conversation.LastMessageSummary)) + } + if digest := buildRecentMessageDigest(messages); digest != "" { + facts = append(facts, "最近消息:"+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 "用户" + case enums.IMSenderTypeAgent: + return "客服" + case enums.IMSenderTypeAI: + return "AI" + default: + return "消息" + } +} + +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])) + "..." +} diff --git a/internal/ai/runtime/graphs/prepare_ticket_draft_graph_test.go b/internal/ai/runtime/graphs/prepare_ticket_draft_graph_test.go new file mode 100644 index 0000000..3166610 --- /dev/null +++ b/internal/ai/runtime/graphs/prepare_ticket_draft_graph_test.go @@ -0,0 +1,57 @@ +package graphs + +import ( + "testing" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" +) + +func TestBuildPrepareTicketDraftResult_UsesConversationFallbacks(t *testing.T) { + conversation := &models.Conversation{ + Subject: "企业微信登录异常", + 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{ + Subject: "订单支付失败", + 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) + } +} diff --git a/internal/ai/runtime/internal/impl/factory/agent_factory.go b/internal/ai/runtime/internal/impl/factory/agent_factory.go index d6d5bf3..11f1505 100644 --- a/internal/ai/runtime/internal/impl/factory/agent_factory.go +++ b/internal/ai/runtime/internal/impl/factory/agent_factory.go @@ -112,6 +112,9 @@ func (f *AgentFactory) BuildCustomerServiceAgent(ctx context.Context, input Buil if toolCode == toolx.BuiltinToolSearchToolCode { serverCode = toolx.BuiltinToolCatalogServerCode toolName = toolx.BuiltinToolSearchToolName + } else if toolCode == toolx.GraphPrepareTicketDraftToolCode { + serverCode = toolx.GraphToolCatalogServerCode + toolName = toolx.GraphPrepareTicketDraftToolName } else if toolCode == toolx.GraphCreateTicketConfirmToolCode { serverCode = toolx.GraphToolCatalogServerCode toolName = toolx.GraphCreateTicketConfirmToolName @@ -210,13 +213,22 @@ func assembleAgentInstruction(aiAgent *models.AIAgent, selectedSkill *models.Ski 1. 先调用 tool_search 搜索需要的动态工具,再继续使用已选中的真实工具。 2. 不要假设所有长尾工具一开始就可见;只有被 tool_search 选中的工具,后续模型调用才会暴露出来。 3. 如果当前已有固定内置工具可以完成任务,优先使用固定工具,不要滥用 tool_search。 +`)) + } + if hasToolCode(extraToolCodes, toolx.GraphPrepareTicketDraftToolCode) { + appendixParts = append(appendixParts, strings.TrimSpace(` +当用户已经表达了建单、投诉、报障、售后处理等诉求,但工单标题、描述或问题整理还比较散乱时,优先调用 prepare_ticket_draft 这个 Graph Tool,并遵守以下规则: +1. 该工具用于整理工单草稿,会返回建议标题、建议描述、缺失字段和追问建议。 +2. 如果工具返回 ready=false,优先根据 missingFields 和 followUpQuestions 继续追问,不要直接创建工单。 +3. 如果工具返回 ready=true,再结合结果考虑调用 create_ticket_with_confirmation。 +4. 该工具用于“整理草稿”,不代表已经创建工单。 `)) } if hasToolCode(extraToolCodes, toolx.GraphCreateTicketConfirmToolCode) { appendixParts = append(appendixParts, strings.TrimSpace(` 你可以在确认信息充分后调用 create_ticket_with_confirmation 这个 Graph Tool 来创建工单,但必须遵守以下规则: 1. 只有在用户明确表达希望提交工单、投诉、报障、售后处理等诉求时,才考虑调用该工具。 -2. 调用前你必须已经整理出清晰的工单标题和问题描述;如果信息不足,先继续追问,不要过早调用。 +2. 调用前你必须已经整理出清晰的工单标题和问题描述;如果信息还比较散乱,优先先调用 prepare_ticket_draft 或继续追问,不要过早调用。 3. 一旦准备创建工单,必须调用 create_ticket_with_confirmation 工具,禁止直接口头宣称“已经创建工单”。 4. 该 Graph Tool 会先向用户发起确认。用户确认后才会真正创建工单;用户取消则结束本次建单流程。 5. 如果用户只是咨询、抱怨或泛泛表达不满,但没有明确要求建单,优先继续澄清,不要主动创建工单。 diff --git a/internal/ai/runtime/registry/registry.go b/internal/ai/runtime/registry/registry.go index 4f6c20e..79bff08 100644 --- a/internal/ai/runtime/registry/registry.go +++ b/internal/ai/runtime/registry/registry.go @@ -29,10 +29,8 @@ func (r *Registry) Resolve(ctx Context) (*ToolSet, error) { continue } toolCode := strings.TrimSpace(toolDef.Code()) - if len(allowedToolCodes) > 0 { - if _, ok := allowedToolCodes[toolCode]; !ok && !isAlwaysAllowedToolCode(toolCode) { - continue - } + if len(allowedToolCodes) > 0 && !isAllowedToolCode(toolCode, allowedToolCodes) { + continue } tool, err := toolDef.Build(ctx) if err != nil { @@ -51,6 +49,23 @@ func (r *Registry) Resolve(ctx Context) (*ToolSet, error) { return ret, nil } +func isAllowedToolCode(toolCode string, allowedToolCodes map[string]struct{}) bool { + if len(allowedToolCodes) == 0 { + return true + } + if _, ok := allowedToolCodes[toolCode]; ok { + return true + } + if isAlwaysAllowedToolCode(toolCode) { + return true + } + if strings.TrimSpace(toolCode) == toolx.GraphPrepareTicketDraftToolCode { + _, ok := allowedToolCodes[toolx.GraphCreateTicketConfirmToolCode] + return ok + } + return false +} + func makeAllowedToolCodeSet(input []string) map[string]struct{} { if len(input) == 0 { return nil diff --git a/internal/ai/runtime/service.go b/internal/ai/runtime/service.go index 94c5a93..24182e6 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.NewPrepareTicketDraftTool(), tools.NewCreateTicketGraphTool(), tools.NewHandoffGraphTool(), ), diff --git a/internal/ai/runtime/tools/prepare_ticket_draft_tool.go b/internal/ai/runtime/tools/prepare_ticket_draft_tool.go new file mode 100644 index 0000000..5c71a84 --- /dev/null +++ b/internal/ai/runtime/tools/prepare_ticket_draft_tool.go @@ -0,0 +1,128 @@ +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 ( + PrepareTicketDraftToolCode = toolx.GraphPrepareTicketDraftToolCode + PrepareTicketDraftToolName = toolx.GraphPrepareTicketDraftToolName +) + +type PrepareTicketDraftTool struct { + conversation *models.Conversation +} + +func NewPrepareTicketDraftTool() *PrepareTicketDraftTool { + return &PrepareTicketDraftTool{} +} + +func (t *PrepareTicketDraftTool) Name() string { + return PrepareTicketDraftToolName +} + +func (t *PrepareTicketDraftTool) Code() string { + return PrepareTicketDraftToolCode +} + +func (t *PrepareTicketDraftTool) Enabled(ctx registry.Context) bool { + return ctx.Conversation != nil +} + +func (t *PrepareTicketDraftTool) Build(ctx registry.Context) (einotool.BaseTool, error) { + if !t.Enabled(ctx) { + return nil, nil + } + return &PrepareTicketDraftTool{conversation: ctx.Conversation}, nil +} + +func (t *PrepareTicketDraftTool) Info(ctx context.Context) (*schema.ToolInfo, error) { + return &schema.ToolInfo{ + Name: PrepareTicketDraftToolName, + Desc: "Graph Tool。用于根据当前会话和已收集信息整理工单草稿,输出建议标题、建议描述、缺失字段和追问建议。适合在真正调用 create_ticket_with_confirmation 前先整理工单内容。", + ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{ + Version: einojsonschema.Version, + Type: "object", + Properties: orderedmap.New[string, *einojsonschema.Schema](orderedmap.WithInitialData( + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "title", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "已整理出的工单标题,可选。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "description", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "已整理出的工单描述,可选。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "issue", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "用户当前遇到的问题现象或报错信息。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "impact", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "问题影响范围,例如无法登录、无法下单、业务中断等。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "expectedOutcome", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "用户期望的处理结果或诉求。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "currentAttempt", + Value: &einojsonschema.Schema{ + Type: "string", + Description: "当前已尝试过的处理步骤,可选。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "priority", + Value: &einojsonschema.Schema{ + Type: "integer", + Description: "建议工单优先级,可选。", + }, + }, + orderedmap.Pair[string, *einojsonschema.Schema]{ + Key: "severity", + Value: &einojsonschema.Schema{ + Type: "integer", + Description: "建议严重度,可选;1=轻微,2=严重,3=致命。", + }, + }, + )), + }), + Extra: map[string]any{ + "toolCode": PrepareTicketDraftToolCode, + "sourceType": "graph", + }, + }, nil +} + +func (t *PrepareTicketDraftTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) { + if t == nil || t.conversation == nil { + return "", fmt.Errorf("prepare ticket draft tool not initialized") + } + return graphs.NewPrepareTicketDraftGraph(t.conversation).Run(ctx, argumentsInJSON) +} diff --git a/internal/pkg/toolx/builtin_tools.go b/internal/pkg/toolx/builtin_tools.go index 7b0c90e..bdd32ec 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" + GraphPrepareTicketDraftToolCode = "graph/prepare_ticket_draft" + GraphPrepareTicketDraftToolName = "prepare_ticket_draft" + GraphPrepareTicketDraftToolTitle = "整理工单草稿" + GraphPrepareTicketDraftToolDescription = "Graph Tool。用于根据当前会话和已收集信息整理工单草稿,输出建议标题、描述、缺失字段和追问建议。" GraphCreateTicketConfirmToolCode = "graph/create_ticket_with_confirmation" GraphCreateTicketConfirmToolName = "create_ticket_with_confirmation" GraphCreateTicketConfirmToolTitle = "创建工单确认流程"