feat: add PrepareTicketDraft tool and related graph for drafting ticket information
This commit is contained in:
+1
-1
Submodule docs updated: d24b234349...601709ad12
@@ -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])) + "..."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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. 如果用户只是咨询、抱怨或泛泛表达不满,但没有明确要求建单,优先继续澄清,不要主动创建工单。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ func newService() *service {
|
||||
return &service{
|
||||
runtime: engine.NewService(),
|
||||
registry: registry.NewRegistry(
|
||||
tools.NewPrepareTicketDraftTool(),
|
||||
tools.NewCreateTicketGraphTool(),
|
||||
tools.NewHandoffGraphTool(),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = "创建工单确认流程"
|
||||
|
||||
Reference in New Issue
Block a user