refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -1,176 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
applicationruntime "code.tczkiot.com/wlw/ai-agent/internal/ai/application/runtime"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/graphs"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/response"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
svc "code.tczkiot.com/wlw/ai-agent/internal/services"
|
||||
)
|
||||
|
||||
func init() {
|
||||
svc.SkillDebugRunHook = DebugRunSkill
|
||||
svc.SkillDebugResumeHook = DebugResumeSkill
|
||||
}
|
||||
|
||||
func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*response.SkillDebugRunResponse, error) {
|
||||
aiAgent := svc.AIAgentService.Get(req.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0007")
|
||||
}
|
||||
aiConfig := svc.AIConfigService.Get(aiAgent.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0008")
|
||||
}
|
||||
skill := svc.SkillDefinitionService.Get(req.SkillDefinitionID)
|
||||
if skill == nil || skill.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0054")
|
||||
}
|
||||
debugAgent := *aiAgent
|
||||
debugAgent.SkillIDs = fmt.Sprintf("%d", skill.ID)
|
||||
var conversation *models.Conversation
|
||||
if req.ConversationID > 0 {
|
||||
if conversation = svc.ConversationService.Get(req.ConversationID); conversation == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
} else {
|
||||
conversation = &models.Conversation{ID: req.ConversationID, AIAgentID: req.AIAgentID}
|
||||
}
|
||||
message := models.Message{
|
||||
ConversationID: req.ConversationID,
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: strings.TrimSpace(req.UserMessage),
|
||||
}
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.RunInput{
|
||||
Conversation: *conversation,
|
||||
UserMessage: message,
|
||||
AIAgent: debugAgent,
|
||||
AIConfig: *aiConfig,
|
||||
Debug: true,
|
||||
})
|
||||
if err != nil {
|
||||
return buildSkillDebugRunResponse(req, summary, skill), err
|
||||
}
|
||||
return buildSkillDebugRunResponse(req, summary, skill), nil
|
||||
}
|
||||
|
||||
func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest) (*response.SkillDebugRunResponse, error) {
|
||||
aiAgent := svc.AIAgentService.Get(req.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0007")
|
||||
}
|
||||
aiConfig := svc.AIConfigService.Get(aiAgent.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0008")
|
||||
}
|
||||
pendingInterrupt := svc.ConversationInterruptService.GetByCheckPointID(strings.TrimSpace(req.CheckPointID))
|
||||
if pendingInterrupt == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0014")
|
||||
}
|
||||
if pendingInterrupt.AIAgentID > 0 && pendingInterrupt.AIAgentID != req.AIAgentID {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0015")
|
||||
}
|
||||
conversationID := req.ConversationID
|
||||
if conversationID <= 0 {
|
||||
conversationID = pendingInterrupt.ConversationID
|
||||
}
|
||||
if conversationID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
conversation := svc.ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
if conversation.AIAgentID > 0 && conversation.AIAgentID != req.AIAgentID {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0117")
|
||||
}
|
||||
resumeText := strings.TrimSpace(req.UserMessage)
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeInput{
|
||||
Conversation: *conversation,
|
||||
AIAgent: *aiAgent,
|
||||
AIConfig: *aiConfig,
|
||||
CheckPointID: strings.TrimSpace(req.CheckPointID),
|
||||
ResumeData: map[string]string{
|
||||
strings.TrimSpace(pendingInterrupt.InterruptID): resumeText,
|
||||
},
|
||||
Debug: true,
|
||||
})
|
||||
if err != nil {
|
||||
if isCheckpointMissingError(err) {
|
||||
summary = &applicationruntime.RunResult{
|
||||
Status: "expired",
|
||||
ReplyText: graphs.ConfirmationExpiredReply,
|
||||
}
|
||||
if pendingInterrupt.ID > 0 {
|
||||
_ = svc.ConversationInterruptService.MarkExpired(pendingInterrupt.ID, 0)
|
||||
}
|
||||
return buildSkillDebugResumeResponse(req, summary, conversationID), nil
|
||||
}
|
||||
return buildSkillDebugResumeResponse(req, summary, conversationID), err
|
||||
}
|
||||
if pendingInterrupt.ID > 0 {
|
||||
if summary != nil && summary.Interrupted {
|
||||
_ = svc.ConversationInterruptService.MarkPendingAgain(pendingInterrupt.ID, firstInterruptID(summary), resolveInterruptPrompt(summary), 0)
|
||||
} else if summary != nil && graphs.IsCancellationReply(summary.ReplyText) {
|
||||
_ = svc.ConversationInterruptService.MarkCancelled(pendingInterrupt.ID, 0)
|
||||
} else {
|
||||
_ = svc.ConversationInterruptService.MarkResolved(pendingInterrupt.ID, 0)
|
||||
}
|
||||
}
|
||||
return buildSkillDebugResumeResponse(req, summary, conversationID), nil
|
||||
}
|
||||
|
||||
func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *applicationruntime.RunResult, skill *models.SkillDefinition) *response.SkillDebugRunResponse {
|
||||
resp := &response.SkillDebugRunResponse{
|
||||
ConversationID: req.ConversationID,
|
||||
AIAgentID: req.AIAgentID,
|
||||
}
|
||||
if skill != nil {
|
||||
resp.SkillDefinitionID = skill.ID
|
||||
resp.SkillName = skill.Name
|
||||
}
|
||||
if summary == nil {
|
||||
return resp
|
||||
}
|
||||
if resp.SkillDefinitionID <= 0 {
|
||||
resp.SkillDefinitionID = summary.PlannedSkillID
|
||||
}
|
||||
resp.ReplyText = summary.ReplyText
|
||||
resp.ToolWhitelist = append([]string(nil), summary.SkillAllowedToolCodes...)
|
||||
resp.InvokedToolCodes = append([]string(nil), summary.InvokedToolCodes...)
|
||||
resp.InterruptType = firstInterruptType(summary)
|
||||
resp.CheckPointID = summary.CheckPointID
|
||||
resp.Interrupted = summary.Interrupted
|
||||
resp.TraceData = summary.TraceData
|
||||
resp.ErrorMessage = summary.ErrorMessage
|
||||
return resp
|
||||
}
|
||||
|
||||
func buildSkillDebugResumeResponse(req request.SkillDebugResumeRequest, summary *applicationruntime.RunResult, conversationID int64) *response.SkillDebugRunResponse {
|
||||
resp := &response.SkillDebugRunResponse{
|
||||
ConversationID: conversationID,
|
||||
AIAgentID: req.AIAgentID,
|
||||
}
|
||||
if summary == nil {
|
||||
return resp
|
||||
}
|
||||
resp.SkillDefinitionID = summary.PlannedSkillID
|
||||
resp.SkillName = strings.TrimSpace(summary.PlannedSkillName)
|
||||
resp.ReplyText = summary.ReplyText
|
||||
resp.ToolWhitelist = append([]string(nil), summary.SkillAllowedToolCodes...)
|
||||
resp.InvokedToolCodes = append([]string(nil), summary.InvokedToolCodes...)
|
||||
resp.InterruptType = firstInterruptType(summary)
|
||||
resp.CheckPointID = summary.CheckPointID
|
||||
resp.Interrupted = summary.Interrupted
|
||||
resp.TraceData = summary.TraceData
|
||||
resp.ErrorMessage = summary.ErrorMessage
|
||||
return resp
|
||||
}
|
||||
@@ -20,9 +20,9 @@ func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationReque
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0007")
|
||||
}
|
||||
config := svc.AIConfigService.Get(agent.AIConfigID)
|
||||
if config == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0008")
|
||||
config, err := applicationruntime.ResolveRuntimeAIConfig(ctx, agent.AIConfigID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cases := make([]applicationruntime.OfflineEvaluationCase, 0, len(req.Cases))
|
||||
for _, item := range req.Cases {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type evaluationPlatformAIProvider struct{}
|
||||
|
||||
func (evaluationPlatformAIProvider) ModelSource(context.Context) (string, error) {
|
||||
return contract.ModelSourcePlatform, nil
|
||||
}
|
||||
|
||||
func (evaluationPlatformAIProvider) Config(context.Context) (*contract.PlatformAIConfig, error) {
|
||||
return &contract.PlatformAIConfig{
|
||||
APIKey: "license-signed",
|
||||
BaseURL: "https://platform.example/v1",
|
||||
ModelName: "platform-default",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (evaluationPlatformAIProvider) Status(context.Context) (*contract.PlatformAIStatus, error) {
|
||||
return &contract.PlatformAIStatus{Enabled: true}, nil
|
||||
}
|
||||
|
||||
func TestRunAgentEvaluationResolvesPlatformWithoutCustomConfig(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AIAgent{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
agent := &models.AIAgent{Name: "platform-agent", Status: enums.StatusOk, AIConfigID: 0}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
|
||||
ai.SetPlatformAIProvider(evaluationPlatformAIProvider{})
|
||||
t.Cleanup(func() { ai.SetPlatformAIProvider(nil) })
|
||||
|
||||
report, err := RunAgentEvaluation(context.Background(), request.RunAgentEvaluationRequest{AIAgentID: agent.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("RunAgentEvaluation() error = %v", err)
|
||||
}
|
||||
if report.Total != 0 || !strings.Contains(report.CSV, "case_id") {
|
||||
t.Fatalf("RunAgentEvaluation() = %+v", report)
|
||||
}
|
||||
}
|
||||
@@ -7,26 +7,26 @@ import (
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/services"
|
||||
)
|
||||
|
||||
type AnalyzeConversationInput struct {
|
||||
Goal string `json:"goal"`
|
||||
ObservedIssue string `json:"observedIssue"`
|
||||
NeedTicket bool `json:"needTicket"`
|
||||
NeedHumanHandoff bool `json:"needHumanHandoff"`
|
||||
NeedQualityCheck bool `json:"needQualityCheck"`
|
||||
AdditionalContext string `json:"additionalContext"`
|
||||
ObservedIssue string `json:"observed_issue"`
|
||||
NeedHumanHandoff bool `json:"need_human_handoff"`
|
||||
NeedQualityCheck bool `json:"need_quality_check"`
|
||||
AdditionalContext string `json:"additional_context"`
|
||||
}
|
||||
|
||||
type AnalyzeConversationResult struct {
|
||||
Summary string `json:"summary"`
|
||||
UserIntent string `json:"userIntent"`
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
RiskSignals []string `json:"riskSignals,omitempty"`
|
||||
RecommendedNextAction string `json:"recommendedNextAction"`
|
||||
RecommendedQuestions []string `json:"recommendedQuestions,omitempty"`
|
||||
ConversationFacts []string `json:"conversationFacts,omitempty"`
|
||||
UserIntent string `json:"user_intent"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
RiskSignals []string `json:"risk_signals,omitempty"`
|
||||
RecommendedNextAction string `json:"recommended_next_action"`
|
||||
RecommendedQuestions []string `json:"recommended_questions,omitempty"`
|
||||
ConversationFacts []string `json:"conversation_facts,omitempty"`
|
||||
}
|
||||
|
||||
type AnalyzeConversationGraph struct {
|
||||
@@ -130,9 +130,6 @@ func collectRiskSignals(joined string, input AnalyzeConversationInput) []string
|
||||
if containsAny(joined, "人工", "转人工", "真人", "客服") || input.NeedHumanHandoff {
|
||||
add("handoff_requested")
|
||||
}
|
||||
if containsAny(joined, "工单", "报障", "售后", "登记", "记录问题") || input.NeedTicket {
|
||||
add("ticket_expected")
|
||||
}
|
||||
if input.NeedQualityCheck {
|
||||
add("quality_review_requested")
|
||||
}
|
||||
@@ -143,8 +140,6 @@ func detectUserIntent(joined string, input AnalyzeConversationInput) string {
|
||||
switch {
|
||||
case input.NeedHumanHandoff || containsAny(joined, "人工", "转人工", "真人"):
|
||||
return "handoff_request"
|
||||
case input.NeedTicket || containsAny(joined, "工单", "报障", "售后", "登记问题"):
|
||||
return "ticket_request"
|
||||
case containsAny(joined, "投诉", "举报", "差评", "赔偿"):
|
||||
return "complaint"
|
||||
default:
|
||||
@@ -171,8 +166,6 @@ func recommendNextAction(intent string, signals []string, input AnalyzeConversat
|
||||
return "quality_review"
|
||||
case containsSignal(signals, "handoff_requested") || intent == "handoff_request":
|
||||
return "handoff_to_human"
|
||||
case containsSignal(signals, "ticket_expected") || intent == "ticket_request":
|
||||
return "prepare_ticket"
|
||||
case containsSignal(signals, "complaint_escalation"):
|
||||
return "handoff_to_human"
|
||||
default:
|
||||
@@ -182,9 +175,6 @@ func recommendNextAction(intent string, signals []string, input AnalyzeConversat
|
||||
|
||||
func recommendQuestions(intent string, signals []string, input AnalyzeConversationInput) []string {
|
||||
questions := make([]string, 0, 3)
|
||||
if containsSignal(signals, "ticket_expected") && strings.TrimSpace(input.ObservedIssue) == "" {
|
||||
questions = append(questions, "Please confirm the specific issue, error message, and expected outcome.")
|
||||
}
|
||||
if containsSignal(signals, "handoff_requested") {
|
||||
questions = append(questions, "Please confirm whether the user explicitly requested human support and why the issue needs human handling.")
|
||||
}
|
||||
@@ -222,3 +212,36 @@ func containsSignal(signals []string, target string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildRecentMessageDigest(messages []models.Message) string {
|
||||
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)+":"+limitAnalysisText(content, 60))
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func messageSenderLabel(senderType enums.IMSenderType) string {
|
||||
switch senderType {
|
||||
case enums.IMSenderTypeCustomer:
|
||||
return "Customer"
|
||||
case enums.IMSenderTypeAgent:
|
||||
return "Agent"
|
||||
case enums.IMSenderTypeAI:
|
||||
return "AI"
|
||||
default:
|
||||
return "Message"
|
||||
}
|
||||
}
|
||||
|
||||
func limitAnalysisText(value string, max int) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if max <= 0 || len(runes) <= max {
|
||||
return string(runes)
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:max])) + "..."
|
||||
}
|
||||
|
||||
@@ -29,23 +29,3 @@ func TestBuildAnalyzeConversationResult_RecommendsHandoffForComplaint(t *testing
|
||||
t.Fatalf("expected handoff_to_human, got %q", got.RecommendedNextAction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAnalyzeConversationResult_RecommendsPrepareTicket(t *testing.T) {
|
||||
conversation := models.Conversation{
|
||||
LastMessageSummary: "用户要求登记问题并尽快处理",
|
||||
}
|
||||
messages := []models.Message{
|
||||
{SenderType: enums.IMSenderTypeCustomer, Content: "麻烦帮我建个工单,订单一直支付失败"},
|
||||
}
|
||||
|
||||
got := buildAnalyzeConversationResult(conversation, messages, AnalyzeConversationInput{
|
||||
NeedTicket: true,
|
||||
})
|
||||
|
||||
if got.UserIntent != "ticket_request" {
|
||||
t.Fatalf("expected ticket_request, got %q", got.UserIntent)
|
||||
}
|
||||
if got.RecommendedNextAction != "prepare_ticket" {
|
||||
t.Fatalf("expected prepare_ticket, got %q", got.RecommendedNextAction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/tooling"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto/request"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/services"
|
||||
|
||||
componenttool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type CreateTicketGraphState struct {
|
||||
Request request.CreateTicketFromConversationRequest
|
||||
}
|
||||
|
||||
type CreateTicketGraphInterruptInfo struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type createTicketGraphArgs struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
schema.RegisterName[CreateTicketGraphState]("cs_ai_agent_create_ticket_graph_state")
|
||||
schema.RegisterName[CreateTicketGraphInterruptInfo]("cs_ai_agent_create_ticket_graph_interrupt_info")
|
||||
}
|
||||
|
||||
type CreateTicketGraph struct {
|
||||
conversation models.Conversation
|
||||
aiAgent models.AIAgent
|
||||
}
|
||||
|
||||
func NewCreateTicketGraph(conversation models.Conversation, aiAgent models.AIAgent) *CreateTicketGraph {
|
||||
return &CreateTicketGraph{
|
||||
conversation: conversation,
|
||||
aiAgent: aiAgent,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *CreateTicketGraph) Run(ctx context.Context, argumentsInJSON string) (string, error) {
|
||||
wasInterrupted, hasState, state := componenttool.GetInterruptState[CreateTicketGraphState](ctx)
|
||||
if !wasInterrupted {
|
||||
req, err := g.buildCreateRequest(argumentsInJSON)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info := CreateTicketGraphInterruptInfo{
|
||||
Type: InterruptTypeTicketCreationConfirmation,
|
||||
Message: g.buildConfirmationPrompt(req),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, CreateTicketGraphState{Request: req})
|
||||
}
|
||||
if !hasState {
|
||||
return "", fmt.Errorf("create ticket graph state missing")
|
||||
}
|
||||
isResumeTarget, hasData, resumeText := componenttool.GetResumeContext[string](ctx)
|
||||
if !isResumeTarget {
|
||||
info := CreateTicketGraphInterruptInfo{
|
||||
Type: InterruptTypeTicketCreationConfirmation,
|
||||
Message: g.buildConfirmationPrompt(state.Request),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
if !hasData {
|
||||
info := CreateTicketGraphInterruptInfo{
|
||||
Type: InterruptTypeTicketCreationConfirmation,
|
||||
Message: ConfirmOrCancelPrompt,
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
decision := ParseConfirmationDecision(resumeText)
|
||||
switch decision {
|
||||
case ConfirmationDecisionConfirm:
|
||||
item, err := services.TicketService.CreateFromConversation(state.Request, g.buildAIPrincipal())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tooling.MarshalToolResult(tooling.ToolResult{
|
||||
Handled: true,
|
||||
Terminal: true,
|
||||
Action: "ticket_created",
|
||||
ReplyText: i18nx.Getf(i18nx.DefaultLocale, "graph.ticketCreated", strings.TrimSpace(item.TicketNo), strings.TrimSpace(item.Title)),
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
case ConfirmationDecisionCancel:
|
||||
return tooling.MarshalToolResult(tooling.ToolResult{
|
||||
Handled: true,
|
||||
Terminal: true,
|
||||
Action: "ticket_cancelled",
|
||||
ReplyText: CancelCreateTicketReply,
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
default:
|
||||
info := CreateTicketGraphInterruptInfo{
|
||||
Type: InterruptTypeTicketCreationConfirmation,
|
||||
Message: NeedExplicitConfirmationPrompt,
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *CreateTicketGraph) buildCreateRequest(argumentsInJSON string) (request.CreateTicketFromConversationRequest, error) {
|
||||
req := request.CreateTicketFromConversationRequest{
|
||||
ConversationID: g.conversation.ID,
|
||||
}
|
||||
var args createTicketGraphArgs
|
||||
if strings.TrimSpace(argumentsInJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return req, fmt.Errorf("invalid create ticket arguments: %w", err)
|
||||
}
|
||||
}
|
||||
req.Title = strings.TrimSpace(args.Title)
|
||||
req.Description = strings.TrimSpace(args.Description)
|
||||
if req.Title == "" {
|
||||
req.Title = strings.TrimSpace(g.conversation.LastMessageSummary)
|
||||
}
|
||||
if req.Description == "" {
|
||||
req.Description = strings.TrimSpace(g.conversation.LastMessageSummary)
|
||||
}
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
return req, fmt.Errorf("ticket title is required")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (g *CreateTicketGraph) buildConfirmationPrompt(req request.CreateTicketFromConversationRequest) string {
|
||||
return i18nx.Getf(i18nx.DefaultLocale, "graph.createTicketConfirmPrompt",
|
||||
strings.TrimSpace(req.Title), strings.TrimSpace(req.Description))
|
||||
}
|
||||
|
||||
func (g *CreateTicketGraph) buildAIPrincipal() *dto.AuthPrincipal {
|
||||
username := "AI"
|
||||
if strings.TrimSpace(g.aiAgent.Name) != "" {
|
||||
username = strings.TrimSpace(g.aiAgent.Name)
|
||||
}
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: username,
|
||||
Nickname: username,
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
InterruptTypeTicketCreationConfirmation = "ticket_creation_confirmation"
|
||||
InterruptTypeHandoffConfirmation = "handoff_confirmation"
|
||||
InterruptTypeHandoffConfirmation = "handoff_confirmation"
|
||||
)
|
||||
|
||||
var (
|
||||
ConfirmOrCancelPrompt = i18nx.Get("graph.confirmOrCancel")
|
||||
NeedExplicitConfirmationPrompt = i18nx.Get("graph.needExplicitConfirmation")
|
||||
ConfirmationExpiredReply = i18nx.Get("graph.confirmationExpired")
|
||||
CancelCreateTicketReply = i18nx.Get("graph.cancelCreateTicket")
|
||||
CancelHandoffReply = i18nx.Get("graph.cancelHandoff")
|
||||
)
|
||||
|
||||
@@ -31,25 +29,30 @@ func ParseConfirmationDecision(value string) ConfirmationDecision {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
confirmWords := []string{"确认", "是", "好的", "可以", "ok", "yes", "continue", "confirm", "继续", "同意"}
|
||||
for _, item := range confirmWords {
|
||||
if strings.Contains(value, item) {
|
||||
return ConfirmationDecisionConfirm
|
||||
}
|
||||
cancelWords := []string{
|
||||
"不确认", "取消", "不用", "不需要", "算了", "no", "cancel",
|
||||
"不提交", "不要提交", "暂不提交", "不办理", "不要办理", "不执行", "不要执行",
|
||||
}
|
||||
cancelWords := []string{"取消", "不用", "不需要", "算了", "no", "cancel"}
|
||||
for _, item := range cancelWords {
|
||||
if strings.Contains(value, item) {
|
||||
return ConfirmationDecisionCancel
|
||||
}
|
||||
}
|
||||
confirmWords := []string{
|
||||
"确认", "是", "好的", "可以", "ok", "yes", "continue", "confirm", "继续", "同意",
|
||||
"提交", "确定", "办理", "执行",
|
||||
}
|
||||
for _, item := range confirmWords {
|
||||
if strings.Contains(value, item) {
|
||||
return ConfirmationDecisionConfirm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func IsCancellationReply(replyText string) bool {
|
||||
replyText = strings.TrimSpace(replyText)
|
||||
return strings.Contains(replyText, CancelCreateTicketReply) ||
|
||||
strings.Contains(replyText, CancelHandoffReply) ||
|
||||
strings.Contains(replyText, "已取消本次工单创建。") ||
|
||||
strings.Contains(replyText, "已取消本次转人工。")
|
||||
return strings.Contains(replyText, CancelHandoffReply) ||
|
||||
strings.Contains(replyText, "已取消本次转人工。") ||
|
||||
strings.Contains(replyText, "操作已取消。")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package graphs
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseConfirmationDecisionPrefersCancellation(t *testing.T) {
|
||||
for _, input := range []string{"不确认", "好的,取消", "cancel", "不提交", "不要办理", "暂不执行"} {
|
||||
if got := ParseConfirmationDecision(input); got != ConfirmationDecisionCancel {
|
||||
t.Fatalf("ParseConfirmationDecision(%q) = %q", input, got)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"确认", "提交", "确定办理", "执行"} {
|
||||
if got := ParseConfirmationDecision(input); got != ConfirmationDecisionConfirm {
|
||||
t.Fatalf("ParseConfirmationDecision(%q) = %q", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-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"`
|
||||
}
|
||||
|
||||
type PrepareTicketDraftResult struct {
|
||||
Ready bool `json:"ready"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
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) {
|
||||
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{
|
||||
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, "Please provide a concise ticket title that clearly summarizes the issue.")
|
||||
}
|
||||
if !hasSufficientIssueContext(input, result.Description) {
|
||||
result.MissingFields = append(result.MissingFields, "issue")
|
||||
result.FollowUpQuestions = append(result.FollowUpQuestions, "Please provide the specific issue, error message, or request so I can prepare the ticket.")
|
||||
}
|
||||
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 strings.TrimSpace(conversation.LastMessageSummary) != "":
|
||||
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, "Issue: "+input.Issue)
|
||||
}
|
||||
if input.Impact != "" {
|
||||
parts = append(parts, "Impact: "+input.Impact)
|
||||
}
|
||||
if input.ExpectedOutcome != "" {
|
||||
parts = append(parts, "Requested outcome: "+input.ExpectedOutcome)
|
||||
}
|
||||
if input.CurrentAttempt != "" {
|
||||
parts = append(parts, "Attempts so far: "+input.CurrentAttempt)
|
||||
}
|
||||
if strings.TrimSpace(conversation.LastMessageSummary) != "" {
|
||||
parts = append(parts, "Conversation summary: "+strings.TrimSpace(conversation.LastMessageSummary))
|
||||
}
|
||||
if recent := buildRecentMessageDigest(messages); recent != "" {
|
||||
parts = append(parts, "Recent messages: "+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 strings.TrimSpace(conversation.LastMessageSummary) != "" {
|
||||
facts = append(facts, "Recent summary: "+strings.TrimSpace(conversation.LastMessageSummary))
|
||||
}
|
||||
if digest := buildRecentMessageDigest(messages); digest != "" {
|
||||
facts = append(facts, "Recent messages: "+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 "Customer"
|
||||
case enums.IMSenderTypeAgent:
|
||||
return "Agent"
|
||||
case enums.IMSenderTypeAI:
|
||||
return "AI"
|
||||
default:
|
||||
return "Message"
|
||||
}
|
||||
}
|
||||
|
||||
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])) + "..."
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
func TestBuildPrepareTicketDraftResult_UsesConversationFallbacks(t *testing.T) {
|
||||
conversation := models.Conversation{
|
||||
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{
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,14 @@ import (
|
||||
|
||||
type TriageServiceRequestInput struct {
|
||||
Goal string `json:"goal"`
|
||||
ObservedIssue string `json:"observedIssue"`
|
||||
NeedTicket bool `json:"needTicket"`
|
||||
NeedHumanHandoff bool `json:"needHumanHandoff"`
|
||||
AdditionalContext string `json:"additionalContext"`
|
||||
ObservedIssue string `json:"observed_issue"`
|
||||
NeedHumanHandoff bool `json:"need_human_handoff"`
|
||||
AdditionalContext string `json:"additional_context"`
|
||||
}
|
||||
|
||||
type TriageServiceRequestResult struct {
|
||||
Analysis AnalyzeConversationResult `json:"analysis"`
|
||||
TicketDraft *PrepareTicketDraftResult `json:"ticketDraft,omitempty"`
|
||||
RecommendedAction string `json:"recommendedAction"`
|
||||
RecommendedAction string `json:"recommended_action"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
@@ -42,7 +40,6 @@ func (g *TriageServiceRequestGraph) Run(_ context.Context, argumentsInJSON strin
|
||||
analysis := buildAnalyzeConversationResult(g.conversation, messages, AnalyzeConversationInput{
|
||||
Goal: input.Goal,
|
||||
ObservedIssue: input.ObservedIssue,
|
||||
NeedTicket: input.NeedTicket,
|
||||
NeedHumanHandoff: input.NeedHumanHandoff,
|
||||
AdditionalContext: input.AdditionalContext,
|
||||
})
|
||||
@@ -51,13 +48,6 @@ func (g *TriageServiceRequestGraph) Run(_ context.Context, argumentsInJSON strin
|
||||
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
|
||||
|
||||
@@ -7,27 +7,6 @@ import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
func TestTriageServiceRequestResult_PrepareTicket(t *testing.T) {
|
||||
conversation := models.Conversation{
|
||||
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{
|
||||
LastMessageSummary: "用户要求人工处理扣费投诉",
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/identity"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
svc "code.tczkiot.com/wlw/ai-agent/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
missingBusinessIdentityReply = "暂未识别到您要咨询的业务对象。\n\n" +
|
||||
"卡板用户:请发送“卡号 + 您的卡号”\n" +
|
||||
"设备用户:请发送“设备号 + 您的设备号”\n" +
|
||||
"商城用户:请先登录商城,再从商城的客服入口进入。\n\n" +
|
||||
"识别成功后即可继续查询;需要人工协助可回复“人工客服”。"
|
||||
invalidBusinessIdentityReply = "没有查询到您发送的卡号或设备号,请核对后重新发送。\n\n" +
|
||||
"卡板用户:发送“卡号 + 您的卡号”\n" +
|
||||
"设备用户:发送“设备号 + 您的设备号”\n" +
|
||||
"商城用户:请登录商城后从客服入口进入。"
|
||||
identityLookupFailedReply = "业务身份识别暂时不可用,请稍后重试,或回复“人工客服”。"
|
||||
)
|
||||
|
||||
var businessIdentifierPattern = regexp.MustCompile(`[A-Za-z0-9][A-Za-z0-9:_-]{5,63}`)
|
||||
|
||||
type guestBusinessIdentityResolution struct {
|
||||
Conversation models.Conversation
|
||||
NeedsPrompt bool
|
||||
CandidateProvided bool
|
||||
}
|
||||
|
||||
func resolveGuestBusinessIdentity(ctx context.Context, conversation models.Conversation, message models.Message) (guestBusinessIdentityResolution, error) {
|
||||
resolution := guestBusinessIdentityResolution{Conversation: conversation}
|
||||
messageContent := businessIdentityMessageContent(message)
|
||||
if isBoundBusinessCustomerType(conversation.CustomerType) || isHumanHandoffMessage(messageContent) {
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
currentCandidates, currentCandidateProvided := businessIdentityCandidates(messageContent)
|
||||
resolution.CandidateProvided = currentCandidateProvided
|
||||
if subject, ok, err := resolveBusinessSubject(ctx, messageContent, currentCandidates); err != nil {
|
||||
return resolution, err
|
||||
} else if ok {
|
||||
resolution.Conversation = conversationWithBusinessSubject(conversation, subject)
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
history, _, _ := svc.MessageService.FindByConversationIDCursor(
|
||||
conversation.ID,
|
||||
0,
|
||||
20,
|
||||
string(enums.IMSenderTypeCustomer),
|
||||
"",
|
||||
)
|
||||
for index := len(history) - 1; index >= 0; index-- {
|
||||
item := history[index]
|
||||
if item.ID == message.ID {
|
||||
continue
|
||||
}
|
||||
if item.MessageType != enums.IMMessageTypeText && item.MessageType != enums.IMMessageTypeHTML {
|
||||
continue
|
||||
}
|
||||
content := businessIdentityMessageContent(item)
|
||||
candidates, _ := businessIdentityCandidates(content)
|
||||
if subject, ok, err := resolveBusinessSubject(ctx, content, candidates); err != nil {
|
||||
return resolution, err
|
||||
} else if ok {
|
||||
resolution.Conversation = conversationWithBusinessSubject(conversation, subject)
|
||||
return resolution, nil
|
||||
}
|
||||
}
|
||||
|
||||
resolution.NeedsPrompt = true
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
func businessIdentityMessageContent(message models.Message) string {
|
||||
return utils.BuildRuntimeMessageText(message.MessageType, message.Content)
|
||||
}
|
||||
|
||||
func isBoundBusinessCustomerType(customerType string) bool {
|
||||
switch identity.SubjectType(strings.TrimSpace(customerType)) {
|
||||
case identity.SubjectCard, identity.SubjectDevice, identity.SubjectMallUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isHumanHandoffMessage(content string) bool {
|
||||
content = strings.ToLower(strings.TrimSpace(content))
|
||||
return strings.Contains(content, "人工") ||
|
||||
strings.Contains(content, "转接客服") ||
|
||||
strings.Contains(content, "human agent")
|
||||
}
|
||||
|
||||
func businessIdentityCandidates(content string) ([]string, bool) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, false
|
||||
}
|
||||
candidates := businessIdentifierPattern.FindAllString(content, -1)
|
||||
candidates = slices.Compact(candidates)
|
||||
lower := strings.ToLower(content)
|
||||
explicit := strings.Contains(content, "卡号") ||
|
||||
strings.Contains(content, "卡板") ||
|
||||
strings.Contains(content, "设备号") ||
|
||||
strings.Contains(lower, "iccid") ||
|
||||
strings.Contains(lower, "imei")
|
||||
if len(candidates) == 1 && candidates[0] == content {
|
||||
explicit = true
|
||||
}
|
||||
return candidates, explicit
|
||||
}
|
||||
|
||||
func resolveBusinessSubject(ctx context.Context, content string, candidates []string) (identity.Subject, bool, error) {
|
||||
if len(candidates) == 0 {
|
||||
return identity.Subject{}, false, nil
|
||||
}
|
||||
types := []identity.SubjectType{identity.SubjectCard, identity.SubjectDevice}
|
||||
lower := strings.ToLower(content)
|
||||
switch {
|
||||
case strings.Contains(content, "设备") || strings.Contains(lower, "imei"):
|
||||
types = []identity.SubjectType{identity.SubjectDevice}
|
||||
case strings.Contains(content, "卡号") || strings.Contains(lower, "iccid"):
|
||||
types = []identity.SubjectType{identity.SubjectCard}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
for _, subjectType := range types {
|
||||
subjects, err := svc.SubjectService.Query(ctx, identity.Query{
|
||||
Types: []identity.SubjectType{subjectType},
|
||||
Keyword: candidate,
|
||||
EnabledOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
return identity.Subject{}, false, err
|
||||
}
|
||||
for _, subject := range subjects {
|
||||
if businessSubjectMatchesIdentifier(subject, candidate) {
|
||||
return subject, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return identity.Subject{}, false, nil
|
||||
}
|
||||
|
||||
func businessSubjectMatchesIdentifier(subject identity.Subject, candidate string) bool {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
return strings.EqualFold(strings.TrimSpace(subject.Identifier), candidate) ||
|
||||
strings.EqualFold(strings.TrimSpace(subject.Username), candidate)
|
||||
}
|
||||
|
||||
func conversationWithBusinessSubject(conversation models.Conversation, subject identity.Subject) models.Conversation {
|
||||
conversation.CustomerType = string(subject.Type)
|
||||
conversation.CustomerID = subject.ID
|
||||
externalID := strings.TrimSpace(subject.Identifier)
|
||||
if subject.Type == identity.SubjectCard && strings.TrimSpace(subject.Username) != "" {
|
||||
externalID = strings.TrimSpace(subject.Username)
|
||||
}
|
||||
conversation.CustomerExternalID = externalID
|
||||
conversation.CustomerName = strings.TrimSpace(subject.Name)
|
||||
return conversation
|
||||
}
|
||||
|
||||
func guestBusinessIdentityPrompt(resolution guestBusinessIdentityResolution, err error) string {
|
||||
if err != nil {
|
||||
return identityLookupFailedReply
|
||||
}
|
||||
if resolution.CandidateProvided {
|
||||
return invalidBusinessIdentityReply
|
||||
}
|
||||
return missingBusinessIdentityReply
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/identity"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
svc "code.tczkiot.com/wlw/ai-agent/internal/services"
|
||||
)
|
||||
|
||||
func TestResolveGuestBusinessIdentityFromCardNumber(t *testing.T) {
|
||||
svc.SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
if len(query.Types) == 1 && query.Types[0] == identity.SubjectCard && query.Keyword == "50506783" {
|
||||
return []identity.Subject{{
|
||||
Type: identity.SubjectCard,
|
||||
Category: identity.CategoryUser,
|
||||
ID: 17443,
|
||||
Username: "50506783",
|
||||
Name: "卡号 50506783",
|
||||
Identifier: "898608691025D4186783",
|
||||
Enabled: true,
|
||||
}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
t.Cleanup(func() { svc.SetQuerySubjects(nil) })
|
||||
|
||||
resolution, err := resolveGuestBusinessIdentity(context.Background(), models.Conversation{
|
||||
ID: 9,
|
||||
CustomerType: string(enums.ExternalSourceGuest),
|
||||
CustomerExternalID: "guest-1",
|
||||
CustomerName: "访客",
|
||||
CurrentAssigneeID: 0,
|
||||
CustomerUnreadCount: 0,
|
||||
AgentUnreadCount: 0,
|
||||
}, models.Message{
|
||||
ID: 20,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "卡号 50506783,帮我查流量",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGuestBusinessIdentity() error = %v", err)
|
||||
}
|
||||
if resolution.NeedsPrompt {
|
||||
t.Fatal("resolved card identity must not request another identity prompt")
|
||||
}
|
||||
if resolution.Conversation.CustomerType != string(identity.SubjectCard) ||
|
||||
resolution.Conversation.CustomerID != 17443 ||
|
||||
resolution.Conversation.CustomerExternalID != "50506783" {
|
||||
t.Fatalf("unexpected resolved conversation: %#v", resolution.Conversation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGuestBusinessIdentityFromDeviceNumber(t *testing.T) {
|
||||
svc.SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
if len(query.Types) == 1 && query.Types[0] == identity.SubjectDevice && query.Keyword == "37012627000987" {
|
||||
return []identity.Subject{{
|
||||
Type: identity.SubjectDevice,
|
||||
Category: identity.CategoryUser,
|
||||
ID: 27,
|
||||
Username: "37012627000987",
|
||||
Name: "设备号 37012627000987",
|
||||
Identifier: "37012627000987",
|
||||
Enabled: true,
|
||||
}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
t.Cleanup(func() { svc.SetQuerySubjects(nil) })
|
||||
|
||||
resolution, err := resolveGuestBusinessIdentity(context.Background(), models.Conversation{
|
||||
ID: 10,
|
||||
CustomerType: string(enums.ExternalSourceGuest),
|
||||
CustomerExternalID: "guest-2",
|
||||
}, models.Message{
|
||||
ID: 21,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "设备号 37012627000987",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGuestBusinessIdentity() error = %v", err)
|
||||
}
|
||||
if resolution.NeedsPrompt || resolution.Conversation.CustomerType != string(identity.SubjectDevice) || resolution.Conversation.CustomerID != 27 {
|
||||
t.Fatalf("unexpected resolved conversation: %#v", resolution.Conversation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessIdentityCandidatesRequireExplicitIdentifier(t *testing.T) {
|
||||
candidates, explicit := businessIdentityCandidates("请帮我查流量")
|
||||
if len(candidates) != 0 || explicit {
|
||||
t.Fatalf("unexpected candidates=%v explicit=%v", candidates, explicit)
|
||||
}
|
||||
|
||||
candidates, explicit = businessIdentityCandidates("37012627000987")
|
||||
if len(candidates) != 1 || candidates[0] != "37012627000987" || !explicit {
|
||||
t.Fatalf("unexpected candidates=%v explicit=%v", candidates, explicit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessIdentityMessageContentSupportsHTMLInput(t *testing.T) {
|
||||
content := businessIdentityMessageContent(models.Message{
|
||||
MessageType: enums.IMMessageTypeHTML,
|
||||
Content: "<p>卡板50506783</p>",
|
||||
})
|
||||
if content != "卡板50506783" {
|
||||
t.Fatalf("unexpected html message content: %q", content)
|
||||
}
|
||||
candidates, explicit := businessIdentityCandidates(content)
|
||||
if len(candidates) != 1 || candidates[0] != "50506783" || !explicit {
|
||||
t.Fatalf("html card identifier was not extracted: candidates=%#v explicit=%v", candidates, explicit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessIdentityHTMLMenuInput(t *testing.T) {
|
||||
if !isBusinessIdentityOnlyMessage(models.Message{
|
||||
MessageType: enums.IMMessageTypeHTML,
|
||||
Content: "<p>卡板50506783</p>",
|
||||
}) {
|
||||
t.Fatal("card-only html message must open the deterministic service menu")
|
||||
}
|
||||
if isBusinessIdentityOnlyMessage(models.Message{
|
||||
MessageType: enums.IMMessageTypeHTML,
|
||||
Content: "<p>卡号 50506783,请查询流量</p>",
|
||||
}) {
|
||||
t.Fatal("card query must execute the requested service instead of opening the menu")
|
||||
}
|
||||
selection, ok := businessIdentityMenuSelection(models.Message{
|
||||
MessageType: enums.IMMessageTypeHTML,
|
||||
Content: "<p>1</p>",
|
||||
})
|
||||
if !ok || selection != 1 {
|
||||
t.Fatalf("unexpected html menu selection: selection=%d ok=%v", selection, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCardMenuActionCode(t *testing.T) {
|
||||
statusMenu := &models.Message{MessageType: enums.IMMessageTypeText, Content: `已确认您查询的是卡板 50506783 的卡片状态。
|
||||
1. 卡片提示停机/被暂停使用
|
||||
2. 无法上网(有信号但连不上)
|
||||
3. 无信号/无服务
|
||||
4. 已充值但未恢复`}
|
||||
for selection := 1; selection <= 4; selection++ {
|
||||
code, ok := legacyCardMenuActionCode(statusMenu, selection)
|
||||
if !ok || code != "card/network_diagnosis" {
|
||||
t.Fatalf("status menu selection %d returned code=%q ok=%v", selection, code, ok)
|
||||
}
|
||||
}
|
||||
|
||||
helpMenu := &models.Message{MessageType: enums.IMMessageTypeText, Content: `请问您遇到的是哪种情况?
|
||||
1. 卡片状态异常 / 停机
|
||||
2. 无法上网 / 网络连接问题
|
||||
3. 套餐或流量相关
|
||||
4. 其他问题`}
|
||||
wants := map[int]string{1: "card/status", 2: "card/network_diagnosis", 3: "card/package"}
|
||||
for selection, want := range wants {
|
||||
code, ok := legacyCardMenuActionCode(helpMenu, selection)
|
||||
if !ok || code != want {
|
||||
t.Fatalf("help menu selection %d returned code=%q ok=%v, want %q", selection, code, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := legacyCardMenuActionCode(helpMenu, 4); ok {
|
||||
t.Fatal("free-form other problem must remain available to the AI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestBusinessIdentityPromptForInvalidIdentifier(t *testing.T) {
|
||||
got := guestBusinessIdentityPrompt(guestBusinessIdentityResolution{CandidateProvided: true}, nil)
|
||||
if got != invalidBusinessIdentityReply {
|
||||
t.Fatalf("unexpected prompt: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,12 @@ type Assembler struct{}
|
||||
|
||||
type AssemblerInput struct {
|
||||
AgentInstruction string
|
||||
SkillInstruction string
|
||||
ToolAppendices []string
|
||||
}
|
||||
|
||||
type AssemblySummary struct {
|
||||
SectionTitles []string
|
||||
HasAgentRule bool
|
||||
HasSkillRule bool
|
||||
HasToolRule bool
|
||||
}
|
||||
|
||||
@@ -38,11 +36,6 @@ func (a *Assembler) Assemble(input AssemblerInput) AssemblyResult {
|
||||
summary.HasAgentRule = true
|
||||
summary.SectionTitles = append(summary.SectionTitles, "Agent 规则")
|
||||
}
|
||||
if skillInstruction := strings.TrimSpace(input.SkillInstruction); skillInstruction != "" {
|
||||
parts = append(parts, buildInstructionSection("当前技能上下文", skillInstruction))
|
||||
summary.HasSkillRule = true
|
||||
summary.SectionTitles = append(summary.SectionTitles, "当前技能上下文")
|
||||
}
|
||||
if appendix := buildToolAppendix(input.ToolAppendices); appendix != "" {
|
||||
parts = append(parts, buildInstructionSection("工具补充规则", appendix))
|
||||
summary.HasToolRule = true
|
||||
|
||||
@@ -8,19 +8,15 @@ import (
|
||||
func TestAssemblerRespectsProvidedSources(t *testing.T) {
|
||||
result := NewAssembler().Assemble(AssemblerInput{
|
||||
AgentInstruction: "agent-rule",
|
||||
SkillInstruction: "skill-rule",
|
||||
ToolAppendices: []string{"tool-rule-1", "tool-rule-2"},
|
||||
})
|
||||
if !strings.Contains(result.Text, "Agent 规则:\nagent-rule") {
|
||||
t.Fatalf("missing agent instruction: %s", result.Text)
|
||||
}
|
||||
if !strings.Contains(result.Text, "当前技能上下文:\nskill-rule") {
|
||||
t.Fatalf("missing skill instruction: %s", result.Text)
|
||||
}
|
||||
if !strings.Contains(result.Text, "工具补充规则:\ntool-rule-1") {
|
||||
t.Fatalf("missing tool appendix: %s", result.Text)
|
||||
}
|
||||
if !result.Summary.HasAgentRule || !result.Summary.HasSkillRule || !result.Summary.HasToolRule {
|
||||
if !result.Summary.HasAgentRule || !result.Summary.HasToolRule {
|
||||
t.Fatalf("unexpected summary: %#v", result.Summary)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +26,7 @@ func TestAssemblerReturnsEmptyTextWhenInputIsEmpty(t *testing.T) {
|
||||
if result.Text != "" {
|
||||
t.Fatalf("expected empty assembled text, got: %s", result.Text)
|
||||
}
|
||||
if len(result.Summary.SectionTitles) != 0 || result.Summary.HasAgentRule || result.Summary.HasSkillRule || result.Summary.HasToolRule {
|
||||
if len(result.Summary.SectionTitles) != 0 || result.Summary.HasAgentRule || result.Summary.HasToolRule {
|
||||
t.Fatalf("expected empty summary, got %#v", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package instruction
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
runtimetooling "code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/tooling"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
func BuildSelectedSkillActivationInstruction(skill *models.SkillDefinition) string {
|
||||
if skill == nil {
|
||||
return ""
|
||||
}
|
||||
lines := []string{
|
||||
"当前命中的专项技能:",
|
||||
fmt.Sprintf("- id: %d", skill.ID),
|
||||
fmt.Sprintf("- name: %s", strings.TrimSpace(skill.Name)),
|
||||
}
|
||||
if desc := strings.TrimSpace(skill.Description); desc != "" {
|
||||
lines = append(lines, fmt.Sprintf("- description: %s", desc))
|
||||
}
|
||||
lines = append(lines, "", "执行要求:", "- 本轮优先处理该技能范围内的问题。", fmt.Sprintf("- 需要专项处理细节时,优先调用 %s 工具加载该技能说明后再继续。", toolx.BuiltinSkill.Name), "- 如果关键信息不足,先向用户追问。", "- 不得调用当前技能未授权的工具。")
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func BuildSelectedSkillDocument(skill *models.SkillDefinition, toolDefinitions []runtimetooling.MCPToolDefinition) string {
|
||||
return BuildSkillDocument(skill, toolDefinitions)
|
||||
}
|
||||
|
||||
func BuildSkillDocument(skill *models.SkillDefinition, toolDefinitions []runtimetooling.MCPToolDefinition) string {
|
||||
if skill == nil {
|
||||
return ""
|
||||
}
|
||||
lines := []string{
|
||||
"当前命中的专项技能:",
|
||||
fmt.Sprintf("- id: %d", skill.ID),
|
||||
fmt.Sprintf("- name: %s", strings.TrimSpace(skill.Name)),
|
||||
}
|
||||
if desc := strings.TrimSpace(skill.Description); desc != "" {
|
||||
lines = append(lines, fmt.Sprintf("- description: %s", desc))
|
||||
}
|
||||
if content := strings.TrimSpace(skill.Instruction); content != "" {
|
||||
lines = append(lines, "", "技能说明:", content)
|
||||
}
|
||||
if examples := parseJSONStringArray(skill.Examples); len(examples) > 0 {
|
||||
lines = append(lines, "", "典型示例问法:")
|
||||
for _, item := range examples {
|
||||
lines = append(lines, "- "+item)
|
||||
}
|
||||
}
|
||||
if len(toolDefinitions) > 0 {
|
||||
lines = append(lines, "", "当前技能允许使用的工具:")
|
||||
for _, item := range toolDefinitions {
|
||||
if strings.TrimSpace(item.ToolCode) == "" {
|
||||
continue
|
||||
}
|
||||
line := "- " + strings.TrimSpace(item.ToolCode)
|
||||
if title := strings.TrimSpace(item.Title); title != "" {
|
||||
line += " | " + title
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
lines = append(lines, "", "执行要求:", "- 优先遵循该技能说明完成任务。", "- 如果关键信息不足,先向用户追问。", "- 不得调用当前技能未授权的工具。")
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func parseJSONStringArray(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var ret []string
|
||||
if err := json.Unmarshal([]byte(raw), &ret); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(ret))
|
||||
for _, item := range ret {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package instruction
|
||||
|
||||
import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/tooling"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
type ToolAppendixProvider struct{}
|
||||
|
||||
func NewToolAppendixProvider() *ToolAppendixProvider {
|
||||
return &ToolAppendixProvider{}
|
||||
}
|
||||
|
||||
type SkillInstructionProvider struct{}
|
||||
|
||||
func NewSkillInstructionProvider() *SkillInstructionProvider {
|
||||
return &SkillInstructionProvider{}
|
||||
}
|
||||
|
||||
func (p *SkillInstructionProvider) Resolve(selectedSkill *models.SkillDefinition) string {
|
||||
return BuildSelectedSkillActivationInstruction(selectedSkill)
|
||||
}
|
||||
|
||||
func (p *ToolAppendixProvider) Build(toolDefinitions []tooling.MCPToolDefinition, extraToolCodes map[string]string) []string {
|
||||
appendixParts := make([]string, 0, 1)
|
||||
toolCodes := make([]string, 0, len(toolDefinitions)+len(extraToolCodes))
|
||||
for _, item := range toolDefinitions {
|
||||
toolCodes = append(toolCodes, item.ToolCode)
|
||||
}
|
||||
for _, item := range extraToolCodes {
|
||||
toolCodes = append(toolCodes, item)
|
||||
}
|
||||
appendixParts = append(appendixParts, toolx.BuildToolAppendicesForCodes(len(toolDefinitions) > 0, toolCodes)...)
|
||||
return appendixParts
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package instruction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
runtimetooling "code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/tooling"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
assembler *Assembler
|
||||
skillInstructionProvider *SkillInstructionProvider
|
||||
toolAppendixProvider *ToolAppendixProvider
|
||||
}
|
||||
|
||||
func NewService(
|
||||
assembler *Assembler,
|
||||
skillProvider *SkillInstructionProvider,
|
||||
toolProvider *ToolAppendixProvider,
|
||||
) *Service {
|
||||
if assembler == nil {
|
||||
assembler = NewAssembler()
|
||||
}
|
||||
if skillProvider == nil {
|
||||
skillProvider = NewSkillInstructionProvider()
|
||||
}
|
||||
if toolProvider == nil {
|
||||
toolProvider = NewToolAppendixProvider()
|
||||
}
|
||||
return &Service{
|
||||
assembler: assembler,
|
||||
skillInstructionProvider: skillProvider,
|
||||
toolAppendixProvider: toolProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Build(
|
||||
aiAgent models.AIAgent,
|
||||
selectedSkill *models.SkillDefinition,
|
||||
toolDefinitions []runtimetooling.MCPToolDefinition,
|
||||
extraToolCodes map[string]string,
|
||||
) AssemblyResult {
|
||||
skillInstruction := ""
|
||||
toolAppendices := make([]string, 0)
|
||||
if s != nil && s.skillInstructionProvider != nil {
|
||||
skillInstruction = s.skillInstructionProvider.Resolve(selectedSkill)
|
||||
}
|
||||
if s != nil && s.toolAppendixProvider != nil {
|
||||
toolAppendices = s.toolAppendixProvider.Build(toolDefinitions, extraToolCodes)
|
||||
}
|
||||
assembler := NewAssembler()
|
||||
if s != nil && s.assembler != nil {
|
||||
assembler = s.assembler
|
||||
}
|
||||
return assembler.Assemble(AssemblerInput{
|
||||
AgentInstruction: strings.TrimSpace(aiAgent.SystemPrompt),
|
||||
SkillInstruction: skillInstruction,
|
||||
ToolAppendices: toolAppendices,
|
||||
})
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
func ExecuteGraphTool(ctx context.Context, conversation models.Conversation, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) {
|
||||
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
|
||||
if toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code {
|
||||
if toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code {
|
||||
return aitooling.Definition{}, "", fmt.Errorf("tool is not a graph read tool")
|
||||
}
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(toolCode)
|
||||
@@ -48,10 +48,8 @@ func ExecuteGraphTool(ctx context.Context, conversation models.Conversation, too
|
||||
case toolx.GraphAnalyzeConversation.Code:
|
||||
result, err := graphs.NewAnalyzeConversationGraph(conversation).Run(ctx, string(data))
|
||||
return definition, result, err
|
||||
default:
|
||||
result, err := graphs.NewPrepareTicketDraftGraph(conversation).Run(ctx, string(data))
|
||||
return definition, result, err
|
||||
}
|
||||
return definition, "", fmt.Errorf("tool is not a graph read tool")
|
||||
}
|
||||
|
||||
// RetrieveKnowledge executes the built-in knowledge tool after the same
|
||||
@@ -61,7 +59,7 @@ func RetrieveKnowledge(ctx context.Context, agent models.AIAgent, knowledgeBaseI
|
||||
if err != nil {
|
||||
return aitooling.Definition{}, nil, err
|
||||
}
|
||||
arguments := map[string]any{"query": strings.TrimSpace(query), "knowledgeBaseIds": knowledgeBaseIDs}
|
||||
arguments := map[string]any{"query": strings.TrimSpace(query), "knowledge_base_ids": knowledgeBaseIDs}
|
||||
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
|
||||
Definition: definition,
|
||||
Arguments: arguments,
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
|
||||
func TestExecuteGraphToolRejectsDisallowedToolBeforeGraphExecution(t *testing.T) {
|
||||
definition, _, err := ExecuteGraphTool(context.Background(), models.Conversation{}, toolx.GraphAnalyzeConversation.Code, map[string]any{
|
||||
"observedIssue": "需要分析的问题",
|
||||
"observed_issue": "需要分析的问题",
|
||||
}, aitooling.Policy{
|
||||
AllowedToolCodes: []string{toolx.GraphPrepareTicketDraft.Code},
|
||||
AllowedToolCodes: []string{toolx.GraphTriageServiceRequest.Code},
|
||||
AllowedRiskLevels: []string{aitooling.RiskLevelRead},
|
||||
Confirmed: true,
|
||||
})
|
||||
|
||||
@@ -21,8 +21,8 @@ func (t stubTool) Spec() toolx.ToolSpec {
|
||||
return toolx.ToolSpec{
|
||||
Code: t.code,
|
||||
Name: t.name,
|
||||
ServerCode: toolx.GraphCreateTicketConfirm.ServerCode,
|
||||
SourceType: toolx.GraphCreateTicketConfirm.SourceType,
|
||||
ServerCode: toolx.GraphHandoffConversation.ServerCode,
|
||||
SourceType: toolx.GraphHandoffConversation.SourceType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ func (t stubBaseTool) Info(context.Context) (*schema.ToolInfo, error) {
|
||||
|
||||
func TestResolveBuildsStaticToolMetadata(t *testing.T) {
|
||||
r := registry.NewRegistry(stubTool{
|
||||
name: toolx.GraphCreateTicketConfirm.Name,
|
||||
code: toolx.GraphCreateTicketConfirm.Code,
|
||||
name: toolx.GraphHandoffConversation.Name,
|
||||
code: toolx.GraphHandoffConversation.Code,
|
||||
})
|
||||
toolSet, err := r.Resolve(registry.Context{
|
||||
Conversation: models.Conversation{ID: 1},
|
||||
@@ -61,20 +61,20 @@ func TestResolveBuildsStaticToolMetadata(t *testing.T) {
|
||||
if len(toolSet.StaticToolMetadata) != 1 {
|
||||
t.Fatalf("expected 1 metadata item, got %d", len(toolSet.StaticToolMetadata))
|
||||
}
|
||||
item, ok := toolSet.StaticToolMetadata[toolx.GraphCreateTicketConfirm.Name]
|
||||
item, ok := toolSet.StaticToolMetadata[toolx.GraphHandoffConversation.Name]
|
||||
if !ok {
|
||||
t.Fatalf("missing metadata for %s", toolx.GraphCreateTicketConfirm.Name)
|
||||
t.Fatalf("missing metadata for %s", toolx.GraphHandoffConversation.Name)
|
||||
}
|
||||
if item.ToolCode != toolx.GraphCreateTicketConfirm.Code {
|
||||
if item.ToolCode != toolx.GraphHandoffConversation.Code {
|
||||
t.Fatalf("unexpected tool code: %s", item.ToolCode)
|
||||
}
|
||||
if item.ServerCode != toolx.GraphCreateTicketConfirm.ServerCode {
|
||||
if item.ServerCode != toolx.GraphHandoffConversation.ServerCode {
|
||||
t.Fatalf("unexpected server code: %s", item.ServerCode)
|
||||
}
|
||||
if item.ToolName != toolx.GraphCreateTicketConfirm.Name {
|
||||
if item.ToolName != toolx.GraphHandoffConversation.Name {
|
||||
t.Fatalf("unexpected tool name: %s", item.ToolName)
|
||||
}
|
||||
if item.SourceType != toolx.GraphCreateTicketConfirm.SourceType {
|
||||
if item.SourceType != toolx.GraphHandoffConversation.SourceType {
|
||||
t.Fatalf("unexpected source type: %s", item.SourceType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ type replyCommitInput struct {
|
||||
AIAgent models.AIAgent
|
||||
ReplyText string
|
||||
ClientPrefix string
|
||||
WorkflowRunID int64
|
||||
IncrementRound bool
|
||||
}
|
||||
|
||||
@@ -37,7 +36,7 @@ func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Messag
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID(
|
||||
replyMessage, err := svc.MessageService.SendAIMessageWithRequestID(
|
||||
input.Conversation.ID,
|
||||
input.AIAgent.ID,
|
||||
fmt.Sprintf("%s_%d", strings.TrimSpace(input.ClientPrefix), input.Message.ID),
|
||||
@@ -46,7 +45,6 @@ func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Messag
|
||||
"",
|
||||
s.buildAIPrincipal(input.AIAgent),
|
||||
input.Message.RequestID,
|
||||
input.WorkflowRunID,
|
||||
)
|
||||
if err != nil || !input.IncrementRound {
|
||||
return replyMessage, err
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,18 +15,17 @@ import (
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestReplyCommitStoresWorkflowRunIDOnAIMessage(t *testing.T) {
|
||||
func TestReplyCommitStoresAIMessage(t *testing.T) {
|
||||
db := setupReplyCommitTestDB(t)
|
||||
aiAgent := createReplyCommitTestAIAgent(t, db)
|
||||
conversation := createReplyCommitTestConversation(t, db, aiAgent.ID)
|
||||
|
||||
replyMessage, err := newReplyCommitService().CommitAIReply(replyCommitInput{
|
||||
Conversation: *conversation,
|
||||
Message: models.Message{ID: 101, RequestID: "trace-101"},
|
||||
AIAgent: *aiAgent,
|
||||
ReplyText: "AI reply",
|
||||
ClientPrefix: "ai_reply",
|
||||
WorkflowRunID: 9988,
|
||||
Conversation: *conversation,
|
||||
Message: models.Message{ID: 101, RequestID: "trace-101"},
|
||||
AIAgent: *aiAgent,
|
||||
ReplyText: "AI reply",
|
||||
ClientPrefix: "ai_reply",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CommitAIReply() error = %v", err)
|
||||
@@ -33,16 +33,12 @@ func TestReplyCommitStoresWorkflowRunIDOnAIMessage(t *testing.T) {
|
||||
if replyMessage == nil {
|
||||
t.Fatalf("expected reply message")
|
||||
}
|
||||
if replyMessage.WorkflowRunID != 9988 {
|
||||
t.Fatalf("replyMessage.WorkflowRunID=%d want 9988", replyMessage.WorkflowRunID)
|
||||
}
|
||||
|
||||
var stored models.Message
|
||||
if err := db.First(&stored, replyMessage.ID).Error; err != nil {
|
||||
t.Fatalf("find reply message: %v", err)
|
||||
}
|
||||
if stored.WorkflowRunID != 9988 {
|
||||
t.Fatalf("stored.WorkflowRunID=%d want 9988", stored.WorkflowRunID)
|
||||
if stored.Content != "AI reply" || stored.RequestID != "trace-101" {
|
||||
t.Fatalf("unexpected stored reply: %#v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +62,28 @@ func TestReplyCommitRejectsSensitiveModelOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureReplyDeduplicatesByDeterministicClientMessageID(t *testing.T) {
|
||||
db := setupReplyCommitTestDB(t)
|
||||
aiAgent := createReplyCommitTestAIAgent(t, db)
|
||||
conversation := createReplyCommitTestConversation(t, db, aiAgent.ID)
|
||||
message := models.Message{ID: 103, ConversationID: conversation.ID, RequestID: "trace-shared"}
|
||||
service := newAIReplyService()
|
||||
|
||||
service.commitFailureReplyIfNeeded(*conversation, message, *aiAgent, context.DeadlineExceeded)
|
||||
// The request ID is deliberately changed: error idempotency is tied to the
|
||||
// triggering customer message, not a transport trace that can be regenerated.
|
||||
message.RequestID = "trace-retry"
|
||||
service.commitFailureReplyIfNeeded(*conversation, message, *aiAgent, context.DeadlineExceeded)
|
||||
|
||||
var messages []models.Message
|
||||
if err := db.Where("conversation_id = ? AND client_msg_id = ?", conversation.ID, "ai_error_103").Find(&messages).Error; err != nil {
|
||||
t.Fatalf("find failure messages: %v", err)
|
||||
}
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("failure reply count = %d, want 1", len(messages))
|
||||
}
|
||||
}
|
||||
|
||||
func setupReplyCommitTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbName := "reply_commit_test_" + strings.NewReplacer("/", "_").Replace(t.Name())
|
||||
|
||||
@@ -24,11 +24,10 @@ func TestExtractInterruptMessageAndCheckpointError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
|
||||
func TestBuildConversationInterruptStoresCheckpointData(t *testing.T) {
|
||||
item := buildConversationInterrupt(testConversation(1), testMessage(2), testAIAgent(3), &applicationruntime.RunResult{
|
||||
CheckPointData: `{"confirmNodeId":"confirm_1"}`,
|
||||
Interrupted: true,
|
||||
WorkflowRunID: 99,
|
||||
AgentRunID: 88,
|
||||
Interrupts: []applicationruntime.InterruptContextSummary{
|
||||
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
|
||||
@@ -40,8 +39,8 @@ func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
|
||||
if item.RequestData != `{"confirmNodeId":"confirm_1"}` {
|
||||
t.Fatalf("unexpected request data: %q", item.RequestData)
|
||||
}
|
||||
if item.WorkflowRunID != 99 || item.AgentRunID != 88 || item.WorkflowNodeID != "confirm_1" {
|
||||
t.Fatalf("unexpected workflow interrupt identity: run=%d node=%q", item.WorkflowRunID, item.WorkflowNodeID)
|
||||
if item.AgentRunID != 88 || item.InterruptID != "confirm_1" {
|
||||
t.Fatalf("unexpected interrupt identity: run=%d interrupt=%q", item.AgentRunID, item.InterruptID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@ func buildConversationInterrupt(conversation models.Conversation, message models
|
||||
item.SourceMessageID = message.ID
|
||||
item.InterruptID = firstInterruptID(summary)
|
||||
item.InterruptType = firstInterruptType(summary)
|
||||
item.WorkflowRunID = summary.WorkflowRunID
|
||||
item.WorkflowNodeID = firstInterruptID(summary)
|
||||
item.Status = "pending"
|
||||
item.PromptText = resolveInterruptPrompt(summary)
|
||||
item.RequestData = strings.TrimSpace(summary.CheckPointData)
|
||||
|
||||
@@ -32,12 +32,11 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne
|
||||
summary = expiredInterruptSummary()
|
||||
replyCtx.setSummary(summary)
|
||||
replyMessage, expireErr := owner.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: summary.ReplyText,
|
||||
ClientPrefix: "ai_interrupt_expired",
|
||||
WorkflowRunID: summary.WorkflowRunID,
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: summary.ReplyText,
|
||||
ClientPrefix: "ai_interrupt_expired",
|
||||
})
|
||||
if expireErr != nil {
|
||||
return expireErr
|
||||
@@ -58,12 +57,11 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne
|
||||
}
|
||||
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
|
||||
replyMessage, err := owner.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: summary.ReplyText,
|
||||
ClientPrefix: "ai_resume",
|
||||
WorkflowRunID: summary.WorkflowRunID,
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: summary.ReplyText,
|
||||
ClientPrefix: "ai_resume",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -91,12 +89,11 @@ func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService,
|
||||
pending = svc.ConversationInterruptService.GetByCheckPointID(summary.CheckPointID)
|
||||
replyText := resolveInterruptPrompt(summary)
|
||||
replyMessage, err := owner.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: replyText,
|
||||
ClientPrefix: "ai_interrupt",
|
||||
WorkflowRunID: summary.WorkflowRunID,
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: replyText,
|
||||
ClientPrefix: "ai_interrupt",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -113,12 +110,11 @@ func (s *replyInterruptService) HandleInterruptedResume(owner *aiReplyService, r
|
||||
}
|
||||
replyText := resolveInterruptPrompt(summary)
|
||||
replyMessage, err := owner.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: replyText,
|
||||
ClientPrefix: "ai_interrupt_resume",
|
||||
WorkflowRunID: summary.WorkflowRunID,
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: replyText,
|
||||
ClientPrefix: "ai_interrupt_resume",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
applicationruntime "code.tczkiot.com/wlw/ai-agent/internal/ai/application/runtime"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
svc "code.tczkiot.com/wlw/ai-agent/internal/services"
|
||||
)
|
||||
|
||||
@@ -23,10 +25,11 @@ func newAIReplyService() *aiReplyService {
|
||||
}
|
||||
|
||||
type aiReplyService struct {
|
||||
eligibility *replyEligibility
|
||||
executor *runtimeReplyExecutor
|
||||
interrupts *replyInterruptService
|
||||
commit *replyCommitService
|
||||
eligibility *replyEligibility
|
||||
executor *runtimeReplyExecutor
|
||||
interrupts *replyInterruptService
|
||||
commit *replyCommitService
|
||||
triggerReply func(context.Context, models.Conversation, models.Message, models.AIAgent) error
|
||||
}
|
||||
|
||||
func firstInvokedToolCode(summary *applicationruntime.RunResult) string {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -85,6 +87,36 @@ func TestResolveReplyTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIReplyFailureTextShowsSafeActionableErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
contains string
|
||||
}{
|
||||
{name: "request id", err: errors.New("AI 请求标识未设置"), contains: "AI 请求标识无效"},
|
||||
{name: "balance", err: errors.New("insufficient_ai_balance: AI 额度不足"), contains: "AI 额度不足"},
|
||||
{name: "key", err: errors.New("invalid_ai_key"), contains: "AI Key 无效或已撤销"},
|
||||
{name: "model", err: errors.New("ai_gateway_not_configured"), contains: "AI 模型尚未配置"},
|
||||
{name: "timeout", err: errors.New("context deadline exceeded"), contains: "AI 请求超时"},
|
||||
{name: "gateway internal", err: errors.New("internal_error: 网关内部异常 (request_id: req-qwen-123)"), contains: "排查编号:req-qwen-123"},
|
||||
{name: "upstream model", err: errors.New(`ai_upstream_failed: deepseek returned 400: {"message":"Model Not Exist"}`), contains: "模型不存在或暂不可用"},
|
||||
{name: "upstream key", err: errors.New("ai_upstream_failed: Authentication Fails, invalid api key"), contains: "API Key 无效或无权限"},
|
||||
{name: "upstream unknown", err: errors.New("ai_upstream_failed: provider returned 502"), contains: "上游模型服务返回错误"},
|
||||
{name: "wrapped qwen parameter", err: errors.New(`failed to generate: status code: 502, message: qwen 返回 400: {"code":"InvalidParameter","message":"The parameter temperature is invalid"}`), contains: "千问请求参数不兼容"},
|
||||
{name: "wrapped qwen tool unsupported", err: errors.New(`status code: 502, message: qwen 返回 400: {"code":"InvalidParameter","message":"The model does not support tools"}`), contains: "不支持客服工具调用"},
|
||||
{name: "qwen arrearage", err: errors.New(`qwen 返回 400: {"code":"Arrearage","message":"Access denied due to owing balance"}`), contains: "额度不足或已欠费"},
|
||||
{name: "qwen unknown", err: errors.New(`qwen 返回 500: {"code":"InternalError","message":"Temporary upstream failure"}`), contains: "千问服务返回错误"},
|
||||
{name: "unknown", err: errors.New("database password leaked"), contains: aiReplyFailedReply},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := aiReplyFailureText(test.err); !strings.Contains(got, test.contains) {
|
||||
t.Fatalf("aiReplyFailureText() = %q, want it to contain %q", got, test.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInterruptPrompt(t *testing.T) {
|
||||
summary := &applicationruntime.RunResult{
|
||||
Interrupts: []applicationruntime.InterruptContextSummary{
|
||||
|
||||
@@ -2,17 +2,30 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
applicationruntime "code.tczkiot.com/wlw/ai-agent/internal/ai/application/runtime"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/tracex"
|
||||
svc "code.tczkiot.com/wlw/ai-agent/internal/services"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
const aiReplyFailedReply = "已收到您的消息,但本次智能处理没有完成。请稍后重试,或回复“人工客服”继续处理。"
|
||||
|
||||
const businessIdentityMenuPrefix = "identity_menu"
|
||||
|
||||
const aiReplyInvocationToolCode = "runtime/ai_reply"
|
||||
|
||||
const aiReplyInvocationRecoveryGrace = 30 * time.Second
|
||||
|
||||
func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration {
|
||||
if aiAgent.ReplyTimeoutSeconds <= 0 {
|
||||
return time.Duration(defaultAIReplyAsyncTimeoutSeconds) * time.Second
|
||||
@@ -23,27 +36,265 @@ func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Durati
|
||||
return time.Duration(aiAgent.ReplyTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, message models.Message) {
|
||||
go func() {
|
||||
aiAgent := svc.AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return
|
||||
func (s *aiReplyService) TriggerReplyAsync(requestContext context.Context, conversation models.Conversation, message models.Message) {
|
||||
aiAgent := svc.AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return
|
||||
}
|
||||
timeout := s.resolveReplyTimeout(*aiAgent)
|
||||
invocationKey := fmt.Sprintf("message:%d:revision:%d", message.ID, aiAgent.PublishedRevisionID)
|
||||
claim, err := svc.AgentToolInvocationService.ClaimRecoverable(
|
||||
conversation.ID,
|
||||
aiAgent.ID,
|
||||
aiReplyInvocationToolCode,
|
||||
invocationKey,
|
||||
time.Now().Add(-(timeout + aiReplyInvocationRecoveryGrace)),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("failed to claim ai reply run",
|
||||
"requestId", message.RequestID,
|
||||
"conversation_id", conversation.ID,
|
||||
"message_id", message.ID,
|
||||
"revision_id", aiAgent.PublishedRevisionID,
|
||||
"error", err)
|
||||
return
|
||||
}
|
||||
if claim == nil || claim.Item == nil || !claim.Acquired {
|
||||
return
|
||||
}
|
||||
if committedAIReply(conversation.ID, message.ID) != nil {
|
||||
if err := svc.AgentToolInvocationService.Complete(claim.Item, `{}`); err != nil {
|
||||
slog.Error("failed to reconcile recovered ai reply claim", "conversation_id", conversation.ID, "message_id", message.ID, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
proofContext := contract.BindCustomerAccessProofToMessage(requestContext, conversation.ID, message.ID, message.RequestID)
|
||||
proof, hasProof := contract.CustomerAccessProofFromContext(proofContext)
|
||||
go func() {
|
||||
startedAt := time.Now()
|
||||
timeout := s.resolveReplyTimeout(*aiAgent)
|
||||
ctx, cancel := context.WithTimeout(tracex.ContextWithRequestID(context.Background(), message.RequestID), timeout)
|
||||
ctx := tracex.ContextWithRequestID(context.Background(), message.RequestID)
|
||||
if hasProof {
|
||||
ctx = contract.WithCustomerAccessProof(ctx, proof)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
if err := s.TriggerReply(ctx, conversation, message, *aiAgent); err != nil {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err := fmt.Errorf("ai reply panic: %v", recovered)
|
||||
_ = svc.AgentToolInvocationService.FailRetryable(claim.Item, err)
|
||||
slog.Error("panic while triggering ai reply",
|
||||
"requestId", message.RequestID,
|
||||
"conversation_id", conversation.ID,
|
||||
"message_id", message.ID,
|
||||
"error", err)
|
||||
s.commitFailureReplyIfNeeded(conversation, message, *aiAgent, err)
|
||||
}
|
||||
}()
|
||||
var triggerErr error
|
||||
if s.triggerReply != nil {
|
||||
triggerErr = s.triggerReply(ctx, conversation, message, *aiAgent)
|
||||
} else {
|
||||
triggerErr = s.TriggerReply(ctx, conversation, message, *aiAgent)
|
||||
}
|
||||
if triggerErr != nil {
|
||||
_ = svc.AgentToolInvocationService.FailRetryable(claim.Item, triggerErr)
|
||||
slog.Error("failed to trigger ai reply",
|
||||
"requestId", message.RequestID,
|
||||
"message_id", message.ID,
|
||||
"timeout_ms", timeout.Milliseconds(),
|
||||
"elapsed_ms", time.Since(startedAt).Milliseconds(),
|
||||
"error", triggerErr)
|
||||
s.commitFailureReplyIfNeeded(conversation, message, *aiAgent, triggerErr)
|
||||
return
|
||||
}
|
||||
if err := svc.AgentToolInvocationService.Complete(claim.Item, `{}`); err != nil {
|
||||
slog.Error("failed to complete ai reply run claim",
|
||||
"requestId", message.RequestID,
|
||||
"conversation_id", conversation.ID,
|
||||
"message_id", message.ID,
|
||||
"error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func committedAIReply(conversationID, messageID int64) *models.Message {
|
||||
for _, prefix := range []string{"ai_reply", "identity_prompt", "ai_interrupt", "ai_interrupt_expired", "ai_resume", "ai_interrupt_resume"} {
|
||||
clientMsgID := fmt.Sprintf("%s_%d", prefix, messageID)
|
||||
if existing := svc.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversationID).Eq("client_msg_id", clientMsgID)); existing != nil {
|
||||
return existing
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *aiReplyService) commitFailureReplyIfNeeded(conversation models.Conversation, message models.Message, aiAgent models.AIAgent, cause error) {
|
||||
clientMsgID := fmt.Sprintf("ai_error_%d", message.ID)
|
||||
if existing := svc.MessageService.FindOne(
|
||||
sqls.NewCnd().Eq("conversation_id", conversation.ID).Eq("client_msg_id", clientMsgID),
|
||||
); existing != nil {
|
||||
return
|
||||
}
|
||||
if _, err := s.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: conversation,
|
||||
Message: message,
|
||||
AIAgent: aiAgent,
|
||||
ReplyText: aiReplyFailureText(cause),
|
||||
ClientPrefix: "ai_error",
|
||||
}); err != nil {
|
||||
slog.Error("failed to commit ai error reply",
|
||||
"requestId", message.RequestID,
|
||||
"conversation_id", conversation.ID,
|
||||
"message_id", message.ID,
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func aiReplyFailureText(cause error) string {
|
||||
if cause == nil {
|
||||
return aiReplyFailedReply
|
||||
}
|
||||
message := strings.ToLower(cause.Error())
|
||||
switch {
|
||||
case strings.Contains(message, "ai 请求标识未设置"),
|
||||
strings.Contains(message, "invalid_ai_request_id"):
|
||||
return "系统内置 AI 请求失败:AI 请求标识无效。请联系管理员检查网关配置,或回复“人工客服”继续处理。"
|
||||
case strings.Contains(message, "insufficient_ai_balance"),
|
||||
strings.Contains(message, "ai 额度不足"):
|
||||
return "系统内置 AI 额度不足,请充值后重试,或回复“人工客服”继续处理。"
|
||||
case strings.Contains(message, "invalid_ai_key"),
|
||||
strings.Contains(message, "missing_ai_key"),
|
||||
strings.Contains(message, "ai key 格式无效"),
|
||||
strings.Contains(message, "ai 授权凭证无效"):
|
||||
return "系统内置 AI Key 无效或已撤销,请联系管理员检查客服设置。"
|
||||
case strings.Contains(message, "ai_gateway_not_configured"),
|
||||
strings.Contains(message, "system built-in llm model is not configured"),
|
||||
strings.Contains(message, "系统内置模型尚未配置"):
|
||||
return "系统内置 AI 模型尚未配置,请联系管理员完成配置。"
|
||||
case strings.Contains(message, "context deadline exceeded"),
|
||||
strings.Contains(message, "request timeout"),
|
||||
strings.Contains(message, "client.timeout"):
|
||||
return "系统内置 AI 请求超时,请稍后重试,或回复“人工客服”继续处理。"
|
||||
case strings.Contains(message, "internal_error"),
|
||||
strings.Contains(message, "网关内部异常"):
|
||||
if requestID := extractAIGatewayRequestID(cause.Error()); requestID != "" {
|
||||
return "系统内置 AI 网关内部异常,请稍后重试;排查编号:" + requestID + "。如仍失败,请联系管理员或回复“人工客服”。"
|
||||
}
|
||||
return "系统内置 AI 网关内部异常,请稍后重试;如仍失败,请联系管理员或回复“人工客服”。"
|
||||
case isAIUpstreamError(message):
|
||||
return aiUpstreamFailureText(message)
|
||||
default:
|
||||
return aiReplyFailedReply
|
||||
}
|
||||
}
|
||||
|
||||
func extractAIGatewayRequestID(message string) string {
|
||||
lowerMessage := strings.ToLower(message)
|
||||
for _, marker := range []string{"request_id:", "request_id="} {
|
||||
start := strings.Index(lowerMessage, marker)
|
||||
if start < 0 {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(message[start+len(marker):])
|
||||
value = strings.TrimLeft(value, "(\"'")
|
||||
end := 0
|
||||
for end < len(value) {
|
||||
char := value[end]
|
||||
if (char >= 'a' && char <= 'z') ||
|
||||
(char >= 'A' && char <= 'Z') ||
|
||||
(char >= '0' && char <= '9') || char == '-' || char == '_' {
|
||||
end++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if end > 0 {
|
||||
return value[:end]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAIUpstreamError(message string) bool {
|
||||
if strings.Contains(message, "ai_upstream_failed") || strings.Contains(message, "ai_request_failed") {
|
||||
return true
|
||||
}
|
||||
providerMentioned := strings.Contains(message, "qwen") ||
|
||||
strings.Contains(message, "千问") ||
|
||||
strings.Contains(message, "deepseek") ||
|
||||
strings.Contains(message, "dashscope")
|
||||
if !providerMentioned {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"status code", "bad request", "unauthorized", "forbidden", "too many requests",
|
||||
"returned 4", "returned 5", "返回 4", "返回 5", "invalidparameter",
|
||||
"invalid_parameter", "throttling", "arrearage", "accessdenied", "error",
|
||||
} {
|
||||
if strings.Contains(message, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func aiUpstreamFailureText(message string) string {
|
||||
provider := "上游模型"
|
||||
if strings.Contains(message, "qwen") || strings.Contains(message, "千问") || strings.Contains(message, "dashscope") {
|
||||
provider = "千问"
|
||||
} else if strings.Contains(message, "deepseek") {
|
||||
provider = "DeepSeek"
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(message, "model not exist"),
|
||||
strings.Contains(message, "model_not_found"),
|
||||
strings.Contains(message, "invalid model"),
|
||||
strings.Contains(message, "model.accessdenied"),
|
||||
strings.Contains(message, "model access denied"),
|
||||
strings.Contains(message, "模型不存在"):
|
||||
return "系统内置 AI 调用失败:" + provider + "模型不存在或暂不可用,也可能尚未开通,请联系管理员检查模型名称和开通状态。"
|
||||
case strings.Contains(message, "authentication"),
|
||||
strings.Contains(message, "invalid api key"),
|
||||
strings.Contains(message, "invalid_api_key"),
|
||||
strings.Contains(message, "invalidapikey"),
|
||||
strings.Contains(message, "unauthorized"):
|
||||
return "系统内置 AI 调用失败:" + provider + " API Key 无效或无权限,请联系管理员检查官网模型配置。"
|
||||
case strings.Contains(message, "rate limit"),
|
||||
strings.Contains(message, "rate_limit"),
|
||||
strings.Contains(message, "too many requests"),
|
||||
strings.Contains(message, "throttling"):
|
||||
return "系统内置 AI 调用失败:" + provider + "请求过于频繁,请稍后重试。"
|
||||
case strings.Contains(message, "insufficient balance"),
|
||||
strings.Contains(message, "insufficient quota"),
|
||||
strings.Contains(message, "arrearage"),
|
||||
strings.Contains(message, "quota"):
|
||||
return "系统内置 AI 调用失败:" + provider + "账户额度不足或已欠费,请联系管理员处理。"
|
||||
case strings.Contains(message, "maximum context length"),
|
||||
strings.Contains(message, "context_length"),
|
||||
strings.Contains(message, "input length"),
|
||||
strings.Contains(message, "tokens exceed"),
|
||||
strings.Contains(message, "too many tokens"):
|
||||
return "系统内置 AI 调用失败:" + provider + "请求内容超出模型上下文长度,请缩短消息后重试。"
|
||||
case strings.Contains(message, "does not support tools"),
|
||||
strings.Contains(message, "tool calling is not supported"),
|
||||
strings.Contains(message, "function calling is not supported"),
|
||||
strings.Contains(message, "unsupported tool"),
|
||||
strings.Contains(message, "unsupported function"):
|
||||
return "系统内置 AI 调用失败:当前" + provider + "模型不支持客服工具调用,请联系管理员更换可用模型。"
|
||||
case strings.Contains(message, "data_inspection_failed"),
|
||||
strings.Contains(message, "content_filter"),
|
||||
strings.Contains(message, "inappropriate content"):
|
||||
return "系统内置 AI 调用失败:" + provider + "拒绝了本次内容,请调整表述后重试。"
|
||||
case strings.Contains(message, "invalidparameter"),
|
||||
strings.Contains(message, "invalid_parameter"),
|
||||
strings.Contains(message, "bad request"),
|
||||
strings.Contains(message, "返回 400"),
|
||||
strings.Contains(message, "returned 400"):
|
||||
return "系统内置 AI 调用失败:" + provider + "请求参数不兼容,请联系管理员检查模型与客服工具配置。"
|
||||
default:
|
||||
return "系统内置 AI 调用失败:" + provider + "服务返回错误,请联系管理员在 AI 回复记录中查看详细原因。"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent) (retErr error) {
|
||||
var summary *applicationruntime.RunResult
|
||||
replyCtx := aiReplyContext{
|
||||
@@ -61,10 +312,174 @@ func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.C
|
||||
if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) {
|
||||
return nil
|
||||
}
|
||||
identityResolution, identityErr := resolveGuestBusinessIdentity(ctx, conversation, message)
|
||||
if identityErr != nil || identityResolution.NeedsPrompt {
|
||||
_, err := s.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: conversation,
|
||||
Message: message,
|
||||
AIAgent: aiAgent,
|
||||
ReplyText: guestBusinessIdentityPrompt(identityResolution, identityErr),
|
||||
ClientPrefix: "identity_prompt",
|
||||
})
|
||||
return err
|
||||
}
|
||||
replyCtx.Conversation = identityResolution.Conversation
|
||||
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
|
||||
replyCtx.PendingInterrupt = pendingInterrupt
|
||||
return s.resumePendingInterrupt(ctx, replyCtx)
|
||||
}
|
||||
if identityResolution.CandidateProvided && isBusinessIdentityOnlyMessage(message) {
|
||||
handled, err := s.sendBusinessIdentityMenu(ctx, replyCtx)
|
||||
if handled || err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if selection, ok := businessIdentityMenuSelection(message); ok {
|
||||
latest := latestAIMessage(conversation.ID)
|
||||
if latest != nil && strings.HasPrefix(latest.ClientMsgID, businessIdentityMenuPrefix+"_") {
|
||||
matched, aiMessage, err := svc.CustomerQuickActionService.ExecuteSelectedReply(
|
||||
ctx, &replyCtx.Conversation, selection, message.RequestID, message.ID,
|
||||
)
|
||||
if matched || err != nil {
|
||||
return s.finishQuickActionReply(ctx, replyCtx, matched, aiMessage, err)
|
||||
}
|
||||
}
|
||||
if actionCode, matched := legacyCardMenuActionCode(latest, selection); matched {
|
||||
actionMatched, aiMessage, err := svc.CustomerQuickActionService.ExecuteActionReply(
|
||||
ctx, &replyCtx.Conversation, actionCode, message.RequestID, message.ID,
|
||||
)
|
||||
if actionMatched || err != nil {
|
||||
return s.finishQuickActionReply(ctx, replyCtx, actionMatched, aiMessage, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched, err := svc.CustomerQuickActionService.ExecuteMatchedReply(
|
||||
ctx,
|
||||
&replyCtx.Conversation,
|
||||
message.Content,
|
||||
message.RequestID,
|
||||
message.ID,
|
||||
); matched || err != nil {
|
||||
return err
|
||||
}
|
||||
return s.executeReply(ctx, replyCtx)
|
||||
}
|
||||
|
||||
func (s *aiReplyService) sendBusinessIdentityMenu(ctx context.Context, replyCtx aiReplyContext) (bool, error) {
|
||||
actions, err := svc.CustomerQuickActionService.ListForConversation(ctx, &replyCtx.Conversation)
|
||||
if err != nil || len(actions) == 0 {
|
||||
return false, err
|
||||
}
|
||||
objectLabel := "业务对象"
|
||||
switch replyCtx.Conversation.CustomerType {
|
||||
case "card":
|
||||
objectLabel = "卡号"
|
||||
case "device":
|
||||
objectLabel = "设备号"
|
||||
case "mall_user":
|
||||
objectLabel = "商城用户"
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString("已识别")
|
||||
builder.WriteString(objectLabel)
|
||||
if identifier := strings.TrimSpace(replyCtx.Conversation.CustomerExternalID); identifier != "" {
|
||||
builder.WriteString(":")
|
||||
builder.WriteString(identifier)
|
||||
}
|
||||
builder.WriteString("。\n\n请回复序号选择需要的服务:")
|
||||
for index, action := range actions {
|
||||
builder.WriteString(fmt.Sprintf("\n%d. %s", index+1, action.Title))
|
||||
}
|
||||
builder.WriteString("\n\n也可以直接输入要咨询的问题。")
|
||||
_, err = svc.MessageService.SendAutomaticServiceMessageWithRequestID(
|
||||
replyCtx.Conversation.ID,
|
||||
fmt.Sprintf("%s_%d", businessIdentityMenuPrefix, replyCtx.Message.ID),
|
||||
builder.String(),
|
||||
replyCtx.Message.RequestID,
|
||||
)
|
||||
return true, err
|
||||
}
|
||||
|
||||
func isBusinessIdentityOnlyMessage(message models.Message) bool {
|
||||
content := businessIdentityMessageContent(message)
|
||||
candidates, _ := businessIdentityCandidates(content)
|
||||
for _, candidate := range candidates {
|
||||
content = strings.ReplaceAll(content, candidate, "")
|
||||
}
|
||||
for _, marker := range []string{"卡号", "卡板", "设备号", "设备", "iccid", "imei"} {
|
||||
content = strings.ReplaceAll(strings.ToLower(content), marker, "")
|
||||
}
|
||||
content = strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == ' ' || r == '\n' || r == '\r' || r == '\t' || r == ' ' {
|
||||
return -1
|
||||
}
|
||||
switch r {
|
||||
case ':', ':', ',', ',', '。', '.', ';', ';', '-', '_':
|
||||
return -1
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, content)
|
||||
return content == ""
|
||||
}
|
||||
|
||||
func businessIdentityMenuSelection(message models.Message) (int, bool) {
|
||||
content := strings.TrimSpace(businessIdentityMessageContent(message))
|
||||
selection, err := strconv.Atoi(content)
|
||||
return selection, err == nil && selection > 0
|
||||
}
|
||||
|
||||
func latestAIMessage(conversationID int64) *models.Message {
|
||||
return svc.MessageService.FindOne(sqls.NewCnd().
|
||||
Eq("conversation_id", conversationID).
|
||||
Eq("sender_type", enums.IMSenderTypeAI).
|
||||
Desc("id"))
|
||||
}
|
||||
|
||||
func legacyCardMenuActionCode(message *models.Message, selection int) (string, bool) {
|
||||
if message == nil || selection <= 0 {
|
||||
return "", false
|
||||
}
|
||||
content := businessIdentityMessageContent(*message)
|
||||
if strings.Contains(content, "卡片提示停机") &&
|
||||
strings.Contains(content, "无法上网") &&
|
||||
strings.Contains(content, "无信号") &&
|
||||
strings.Contains(content, "已充值但未恢复") {
|
||||
if selection >= 1 && selection <= 4 {
|
||||
return "card/network_diagnosis", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if strings.Contains(content, "卡片状态") &&
|
||||
strings.Contains(content, "网络连接") &&
|
||||
strings.Contains(content, "套餐") &&
|
||||
strings.Contains(content, "其他问题") {
|
||||
actions := map[int]string{
|
||||
1: "card/status",
|
||||
2: "card/network_diagnosis",
|
||||
3: "card/package",
|
||||
}
|
||||
code, ok := actions[selection]
|
||||
return code, ok
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (s *aiReplyService) finishQuickActionReply(
|
||||
ctx context.Context,
|
||||
replyCtx aiReplyContext,
|
||||
matched bool,
|
||||
aiMessage string,
|
||||
err error,
|
||||
) error {
|
||||
if err != nil || !matched {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(aiMessage) == "" {
|
||||
return nil
|
||||
}
|
||||
replyCtx.Message.Content = aiMessage
|
||||
replyCtx.Message.MessageType = enums.IMMessageTypeText
|
||||
return s.executeReply(ctx, replyCtx)
|
||||
}
|
||||
|
||||
@@ -98,12 +513,11 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte
|
||||
}
|
||||
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
|
||||
_, err := s.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: summary.ReplyText,
|
||||
ClientPrefix: "ai_reply",
|
||||
WorkflowRunID: summary.WorkflowRunID,
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: summary.ReplyText,
|
||||
ClientPrefix: "ai_reply",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestTriggerReplyAsyncBindsCustomerProofToCurrentMessage(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.AIAgent{}, &models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("migrate runtime claim tables: %v", err)
|
||||
}
|
||||
sqls.SetDB(database)
|
||||
agent := models.AIAgent{Name: "test", Status: enums.StatusOk, PublishedRevisionID: 19, ReplyTimeoutSeconds: 5}
|
||||
if err := database.Create(&agent).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
|
||||
proofContext := contract.WithCustomerAccessProof(context.Background(), contract.CustomerAccessProof{
|
||||
SessionID: "opaque-session", TargetType: "device", TargetID: 27,
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
})
|
||||
conversation := models.Conversation{ID: 101, AIAgentID: agent.ID}
|
||||
message := models.Message{
|
||||
ID: 202, ConversationID: conversation.ID, SenderType: enums.IMSenderTypeCustomer,
|
||||
Content: "请帮我切换网络", RequestID: "request-303",
|
||||
}
|
||||
received := make(chan contract.CustomerAccessProof, 1)
|
||||
service := newAIReplyService()
|
||||
service.triggerReply = func(ctx context.Context, _ models.Conversation, _ models.Message, _ models.AIAgent) error {
|
||||
proof, ok := contract.CustomerAccessProofFromContext(ctx)
|
||||
if !ok {
|
||||
return context.Canceled
|
||||
}
|
||||
received <- proof
|
||||
return nil
|
||||
}
|
||||
service.TriggerReplyAsync(proofContext, conversation, message)
|
||||
|
||||
select {
|
||||
case proof := <-received:
|
||||
if proof.ConversationID != conversation.ID || proof.MessageID != message.ID || proof.RequestID != message.RequestID {
|
||||
t.Fatalf("async proof was not bound to current message: %#v", proof)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reply execution did not receive customer access proof")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerReplyAsyncClaimsMessageRevisionOnce(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.AIAgent{}, &models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("migrate runtime claim tables: %v", err)
|
||||
}
|
||||
sqls.SetDB(database)
|
||||
agent := models.AIAgent{Name: "test", Status: enums.StatusOk, PublishedRevisionID: 7, ReplyTimeoutSeconds: 5}
|
||||
if err := database.Create(&agent).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
|
||||
var executions atomic.Int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
service := newAIReplyService()
|
||||
service.triggerReply = func(context.Context, models.Conversation, models.Message, models.AIAgent) error {
|
||||
if executions.Add(1) == 1 {
|
||||
close(started)
|
||||
}
|
||||
<-release
|
||||
close(done)
|
||||
return nil
|
||||
}
|
||||
conversation := models.Conversation{ID: 100, AIAgentID: agent.ID}
|
||||
message := models.Message{ID: 200, ConversationID: conversation.ID, SenderType: enums.IMSenderTypeCustomer, Content: "hello", RequestID: "req-concurrent"}
|
||||
service.TriggerReplyAsync(context.Background(), conversation, message)
|
||||
service.TriggerReplyAsync(context.Background(), conversation, message)
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reply execution did not start")
|
||||
}
|
||||
if got := executions.Load(); got != 1 {
|
||||
t.Fatalf("concurrent triggers executed %d times", got)
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reply execution did not finish")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(database, conversation.ID, aiReplyInvocationToolCode, "message:200:revision:7")
|
||||
if item != nil && item.Status == "completed" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("reply invocation was not completed: %#v", item)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerReplyAsyncReconcilesRecoveredCommittedReplyWithoutModelCall(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.AIAgent{}, &models.AgentToolInvocation{}, &models.Message{}); err != nil {
|
||||
t.Fatalf("migrate runtime claim tables: %v", err)
|
||||
}
|
||||
sqls.SetDB(database)
|
||||
agent := models.AIAgent{Name: "test", Status: enums.StatusOk, PublishedRevisionID: 8, ReplyTimeoutSeconds: 1}
|
||||
if err := database.Create(&agent).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
conversation := models.Conversation{ID: 101, AIAgentID: agent.ID}
|
||||
message := models.Message{ID: 201, ConversationID: conversation.ID, SenderType: enums.IMSenderTypeCustomer, Content: "hello"}
|
||||
invocation := models.AgentToolInvocation{
|
||||
ConversationID: conversation.ID, AIAgentID: agent.ID, ToolCode: aiReplyInvocationToolCode,
|
||||
IdempotencyKey: "message:201:revision:8", Status: "running", ResultData: "old-lease",
|
||||
}
|
||||
if err := database.Create(&invocation).Error; err != nil {
|
||||
t.Fatalf("create stale invocation: %v", err)
|
||||
}
|
||||
if err := database.Model(&models.AgentToolInvocation{}).Where("id = ?", invocation.ID).Update("updated_at", time.Now().Add(-time.Hour)).Error; err != nil {
|
||||
t.Fatalf("age invocation: %v", err)
|
||||
}
|
||||
committed := models.Message{ConversationID: conversation.ID, ClientMsgID: "ai_reply_201", SenderType: enums.IMSenderTypeAI, MessageType: enums.IMMessageTypeText, Content: "done"}
|
||||
if err := database.Create(&committed).Error; err != nil {
|
||||
t.Fatalf("create committed reply: %v", err)
|
||||
}
|
||||
var executions atomic.Int32
|
||||
service := newAIReplyService()
|
||||
service.triggerReply = func(context.Context, models.Conversation, models.Message, models.AIAgent) error {
|
||||
executions.Add(1)
|
||||
return nil
|
||||
}
|
||||
service.TriggerReplyAsync(context.Background(), conversation, message)
|
||||
if executions.Load() != 0 {
|
||||
t.Fatalf("model executed despite committed reply: %d", executions.Load())
|
||||
}
|
||||
item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(database, conversation.ID, aiReplyInvocationToolCode, invocation.IdempotencyKey)
|
||||
if item == nil || item.Status != "completed" {
|
||||
t.Fatalf("recovered invocation not reconciled: %#v", item)
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,19 @@ func newRuntimeReplyExecutor() *runtimeReplyExecutor {
|
||||
}
|
||||
|
||||
func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error) {
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.Run(ctx, applicationruntime.ApplicationRunInput{
|
||||
ConversationID: input.Conversation.ID,
|
||||
MessageID: input.Message.ID,
|
||||
AIAgentID: input.AIAgent.ID,
|
||||
config, err := applicationruntime.ResolveRuntimeAIConfig(ctx, input.AIAgent.AIConfigID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The trigger layer may enrich an anonymous channel conversation with a
|
||||
// business subject resolved from the current message or recent history.
|
||||
// Run the already validated objects so that card/device identity is not lost
|
||||
// by reloading the original guest ownership record from the database.
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.RunInput{
|
||||
Conversation: input.Conversation,
|
||||
UserMessage: input.Message,
|
||||
AIAgent: input.AIAgent,
|
||||
AIConfig: *config,
|
||||
})
|
||||
return summary, err
|
||||
}
|
||||
@@ -42,12 +51,15 @@ func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input
|
||||
if input.PendingInterrupt == nil {
|
||||
return nil, fmt.Errorf("pending interrupt is required")
|
||||
}
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.Resume(ctx, applicationruntime.ApplicationResumeInput{
|
||||
ApplicationRunInput: applicationruntime.ApplicationRunInput{
|
||||
ConversationID: input.Conversation.ID,
|
||||
MessageID: input.Message.ID,
|
||||
AIAgentID: input.AIAgent.ID,
|
||||
},
|
||||
config, err := applicationruntime.ResolveRuntimeAIConfig(ctx, input.AIAgent.AIConfigID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeInput{
|
||||
Conversation: input.Conversation,
|
||||
UserMessage: input.Message,
|
||||
AIAgent: input.AIAgent,
|
||||
AIConfig: *config,
|
||||
CheckPointID: strings.TrimSpace(input.PendingInterrupt.CheckPointID),
|
||||
ResumeData: map[string]string{
|
||||
strings.TrimSpace(input.PendingInterrupt.InterruptID): strings.TrimSpace(input.Message.Content),
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package tooling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var toolNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_]`)
|
||||
|
||||
type MCPToolDefinition struct {
|
||||
ToolCode string
|
||||
ServerCode string
|
||||
ToolName string
|
||||
ModelName string
|
||||
Title string
|
||||
Description string
|
||||
FixedArgs map[string]string
|
||||
}
|
||||
|
||||
func BuildModelToolName(definition MCPToolDefinition) string {
|
||||
if strings.TrimSpace(definition.ModelName) != "" {
|
||||
return strings.TrimSpace(definition.ModelName)
|
||||
}
|
||||
base := "mcp_" + strings.TrimSpace(definition.ServerCode) + "_" + strings.TrimSpace(definition.ToolName)
|
||||
base = toolNameSanitizer.ReplaceAllString(base, "_")
|
||||
base = strings.Trim(base, "_")
|
||||
if base == "" {
|
||||
base = "mcp_tool"
|
||||
}
|
||||
checksum := crc32.ChecksumIEEE([]byte(definition.ToolCode))
|
||||
return fmt.Sprintf("%s_%08x", base, checksum)
|
||||
}
|
||||
@@ -9,9 +9,9 @@ type ToolResult struct {
|
||||
Handled bool `json:"handled"`
|
||||
Terminal bool `json:"terminal"`
|
||||
Action string `json:"action"`
|
||||
ReplyText string `json:"replyText,omitempty"`
|
||||
ReplySent bool `json:"replySent,omitempty"`
|
||||
ShouldRetry bool `json:"shouldRetry"`
|
||||
ReplyText string `json:"reply_text,omitempty"`
|
||||
ReplySent bool `json:"reply_sent,omitempty"`
|
||||
ShouldRetry bool `json:"should_retry"`
|
||||
}
|
||||
|
||||
func MarshalToolResult(result ToolResult) string {
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package tooling
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/mcps"
|
||||
)
|
||||
|
||||
const (
|
||||
maxToolResultSummaryChars = 4000
|
||||
maxToolResultSegments = 12
|
||||
)
|
||||
|
||||
var reductionInfoPattern = regexp.MustCompile(`\[tool result reduced: original_length=(\d+), kept_length=(\d+)\]`)
|
||||
|
||||
type ReductionInfo struct {
|
||||
Reduced bool
|
||||
OriginalChars int
|
||||
KeptChars int
|
||||
}
|
||||
|
||||
// BuildReducedToolResultSummary returns a bounded text summary for MCP tool results.
|
||||
// It keeps the main payload visible to the model while preventing a single large tool
|
||||
// response from exhausting too much context.
|
||||
func BuildReducedToolResultSummary(result *mcps.ToolCallResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
segments := collectToolResultSegments(result)
|
||||
if len(segments) == 0 {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(strings.Join(segments, "\n"))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxToolResultSummaryChars {
|
||||
return text
|
||||
}
|
||||
truncated := strings.TrimSpace(string(runes[:maxToolResultSummaryChars]))
|
||||
return fmt.Sprintf("%s\n\n[tool result reduced: original_length=%d, kept_length=%d]", truncated, len(runes), maxToolResultSummaryChars)
|
||||
}
|
||||
|
||||
func ParseReductionInfo(summary string) ReductionInfo {
|
||||
matches := reductionInfoPattern.FindStringSubmatch(strings.TrimSpace(summary))
|
||||
if len(matches) != 3 {
|
||||
return ReductionInfo{}
|
||||
}
|
||||
originalChars, err1 := strconv.Atoi(matches[1])
|
||||
keptChars, err2 := strconv.Atoi(matches[2])
|
||||
if err1 != nil || err2 != nil {
|
||||
return ReductionInfo{}
|
||||
}
|
||||
return ReductionInfo{
|
||||
Reduced: true,
|
||||
OriginalChars: originalChars,
|
||||
KeptChars: keptChars,
|
||||
}
|
||||
}
|
||||
|
||||
func collectToolResultSegments(result *mcps.ToolCallResult) []string {
|
||||
segments := make([]string, 0, len(result.Content)+2)
|
||||
if result.IsError {
|
||||
segments = append(segments, "tool returned an error")
|
||||
}
|
||||
if result.StructuredContent != nil {
|
||||
if data, err := json.Marshal(result.StructuredContent); err == nil {
|
||||
segments = appendNonBlankSegment(segments, string(data))
|
||||
}
|
||||
}
|
||||
for _, item := range result.Content {
|
||||
if len(segments) >= maxToolResultSegments {
|
||||
segments = append(segments, "[tool result reduced: remaining segments omitted]")
|
||||
break
|
||||
}
|
||||
switch item.Type {
|
||||
case "text":
|
||||
segments = appendNonBlankSegment(segments, item.Text)
|
||||
default:
|
||||
if item.Data == nil {
|
||||
continue
|
||||
}
|
||||
if data, err := json.Marshal(item.Data); err == nil {
|
||||
segments = appendNonBlankSegment(segments, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func appendNonBlankSegment(input []string, value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return input
|
||||
}
|
||||
key := canonicalToolResultSegment(value)
|
||||
for _, existing := range input {
|
||||
if canonicalToolResultSegment(existing) == key {
|
||||
return input
|
||||
}
|
||||
}
|
||||
return append(input, value)
|
||||
}
|
||||
|
||||
func canonicalToolResultSegment(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
return value
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package tooling
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/mcps"
|
||||
)
|
||||
|
||||
func TestBuildReducedToolResultSummaryDeduplicatesStructuredAndTextContent(t *testing.T) {
|
||||
result := &mcps.ToolCallResult{
|
||||
StructuredContent: map[string]any{
|
||||
"timestamp": "2026-07-28 11:51:52",
|
||||
"timezone": "Local",
|
||||
},
|
||||
Content: []mcps.ToolResultContent{{
|
||||
Type: "text",
|
||||
Text: `{"timezone":"Local","timestamp":"2026-07-28 11:51:52"}`,
|
||||
}},
|
||||
}
|
||||
|
||||
summary := BuildReducedToolResultSummary(result)
|
||||
if strings.Count(summary, "timestamp") != 1 {
|
||||
t.Fatalf("duplicate MCP result was not removed: %q", summary)
|
||||
}
|
||||
if summary != `{"timestamp":"2026-07-28 11:51:52","timezone":"Local"}` {
|
||||
t.Fatalf("unexpected reduced result: %q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReducedToolResultSummaryKeepsDistinctSegments(t *testing.T) {
|
||||
result := &mcps.ToolCallResult{
|
||||
StructuredContent: map[string]any{"status": "ok"},
|
||||
Content: []mcps.ToolResultContent{{
|
||||
Type: "text",
|
||||
Text: "additional context",
|
||||
}},
|
||||
}
|
||||
|
||||
summary := BuildReducedToolResultSummary(result)
|
||||
if !strings.Contains(summary, `{"status":"ok"}`) || !strings.Contains(summary, "additional context") {
|
||||
t.Fatalf("distinct MCP result segments were lost: %q", summary)
|
||||
}
|
||||
}
|
||||
@@ -62,35 +62,28 @@ func (t *AnalyzeConversationTool) Info(ctx context.Context) (*schema.ToolInfo, e
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "observedIssue",
|
||||
Key: "observed_issue",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.analyzeConversation.param.observedIssue"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "needTicket",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "boolean",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.needTicket"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "needHumanHandoff",
|
||||
Key: "need_human_handoff",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "boolean",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.needHumanHandoff"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "needQualityCheck",
|
||||
Key: "need_quality_check",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "boolean",
|
||||
Description: i18nx.Get("tool.graph.analyzeConversation.param.needQualityCheck"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "additionalContext",
|
||||
Key: "additional_context",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.analyzeConversation.param.additionalContext"),
|
||||
@@ -99,8 +92,8 @@ func (t *AnalyzeConversationTool) Info(ctx context.Context) (*schema.ToolInfo, e
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": toolx.GraphAnalyzeConversation.Code,
|
||||
"sourceType": toolx.GraphAnalyzeConversation.SourceType,
|
||||
"tool_code": toolx.GraphAnalyzeConversation.Code,
|
||||
"source_type": toolx.GraphAnalyzeConversation.SourceType,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/graphs"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/registry"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
||||
"code.tczkiot.com/wlw/ai-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"
|
||||
)
|
||||
|
||||
type CreateTicketGraphTool struct {
|
||||
conversation models.Conversation
|
||||
aiAgent models.AIAgent
|
||||
}
|
||||
|
||||
func NewCreateTicketGraphTool() *CreateTicketGraphTool {
|
||||
return &CreateTicketGraphTool{}
|
||||
}
|
||||
|
||||
func (t *CreateTicketGraphTool) Spec() toolx.ToolSpec {
|
||||
return toolx.GraphCreateTicketConfirm
|
||||
}
|
||||
|
||||
func (t *CreateTicketGraphTool) Name() string {
|
||||
return toolx.GraphCreateTicketConfirm.Name
|
||||
}
|
||||
|
||||
func (t *CreateTicketGraphTool) Code() string {
|
||||
return toolx.GraphCreateTicketConfirm.Code
|
||||
}
|
||||
|
||||
func (t *CreateTicketGraphTool) Enabled(ctx registry.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *CreateTicketGraphTool) Build(ctx registry.Context) (einotool.BaseTool, error) {
|
||||
if !t.Enabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
return &CreateTicketGraphTool{
|
||||
conversation: ctx.Conversation,
|
||||
aiAgent: ctx.AIAgent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *CreateTicketGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: toolx.GraphCreateTicketConfirm.Name,
|
||||
Desc: i18nx.Get("tool.graph.createTicketConfirm.info"),
|
||||
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
|
||||
Version: einojsonschema.Version,
|
||||
Type: "object",
|
||||
Required: []string{
|
||||
"title",
|
||||
"description",
|
||||
},
|
||||
Properties: orderedmap.New[string, *einojsonschema.Schema](orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "title",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.createTicketConfirm.param.title"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "description",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.createTicketConfirm.param.description"),
|
||||
},
|
||||
},
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": toolx.GraphCreateTicketConfirm.Code,
|
||||
"sourceType": "graph",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *CreateTicketGraphTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
|
||||
return graphs.NewCreateTicketGraph(t.conversation, t.aiAgent).Run(ctx, argumentsInJSON)
|
||||
}
|
||||
@@ -68,8 +68,8 @@ func (t *HandoffGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": toolx.GraphHandoffConversation.Code,
|
||||
"sourceType": toolx.GraphHandoffConversation.SourceType,
|
||||
"tool_code": toolx.GraphHandoffConversation.Code,
|
||||
"source_type": toolx.GraphHandoffConversation.SourceType,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -19,18 +19,24 @@ func ParseConfirmationDecision(value string) Decision {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
confirmWords := []string{"确认", "是", "好的", "可以", "ok", "yes", "继续", "同意"}
|
||||
for _, item := range confirmWords {
|
||||
if strings.Contains(value, item) {
|
||||
return DecisionConfirm
|
||||
}
|
||||
cancelWords := []string{
|
||||
"不确认", "取消", "不用", "不需要", "算了", "no", "cancel",
|
||||
"不提交", "不要提交", "暂不提交", "不办理", "不要办理", "不执行", "不要执行",
|
||||
}
|
||||
cancelWords := []string{"取消", "不用", "不需要", "算了", "no"}
|
||||
for _, item := range cancelWords {
|
||||
if strings.Contains(value, item) {
|
||||
return DecisionCancel
|
||||
}
|
||||
}
|
||||
confirmWords := []string{
|
||||
"确认", "是", "好的", "可以", "ok", "yes", "continue", "confirm", "继续", "同意",
|
||||
"提交", "确定", "办理", "执行",
|
||||
}
|
||||
for _, item := range confirmWords {
|
||||
if strings.Contains(value, item) {
|
||||
return DecisionConfirm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -40,10 +46,6 @@ func NewRuntimeStaticTool(toolCode string) registry.Tool {
|
||||
return NewTriageServiceRequestTool()
|
||||
case toolx.GraphAnalyzeConversation.Code:
|
||||
return NewAnalyzeConversationTool()
|
||||
case toolx.GraphPrepareTicketDraft.Code:
|
||||
return NewPrepareTicketDraftTool()
|
||||
case toolx.GraphCreateTicketConfirm.Code:
|
||||
return NewCreateTicketGraphTool()
|
||||
case toolx.GraphHandoffConversation.Code:
|
||||
return NewHandoffGraphTool()
|
||||
default:
|
||||
|
||||
@@ -10,8 +10,6 @@ func TestNewRuntimeStaticTool(t *testing.T) {
|
||||
items := []string{
|
||||
toolx.GraphTriageServiceRequest.Code,
|
||||
toolx.GraphAnalyzeConversation.Code,
|
||||
toolx.GraphPrepareTicketDraft.Code,
|
||||
toolx.GraphCreateTicketConfirm.Code,
|
||||
toolx.GraphHandoffConversation.Code,
|
||||
}
|
||||
for _, item := range items {
|
||||
@@ -30,3 +28,16 @@ func TestNewRuntimeStaticToolReturnsNilForUnknownTool(t *testing.T) {
|
||||
t.Fatalf("expected nil tool for unknown tool code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfirmationDecisionSupportsBusinessActionWords(t *testing.T) {
|
||||
for _, input := range []string{"确认", "提交", "确定办理", "执行"} {
|
||||
if got := ParseConfirmationDecision(input); got != DecisionConfirm {
|
||||
t.Fatalf("ParseConfirmationDecision(%q) = %q", input, got)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"不确认", "好的,取消", "不提交", "不要办理", "暂不执行"} {
|
||||
if got := ParseConfirmationDecision(input); got != DecisionCancel {
|
||||
t.Fatalf("ParseConfirmationDecision(%q) = %q", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/graphs"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/registry"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
||||
"code.tczkiot.com/wlw/ai-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"
|
||||
)
|
||||
|
||||
type PrepareTicketDraftTool struct {
|
||||
conversation models.Conversation
|
||||
}
|
||||
|
||||
func NewPrepareTicketDraftTool() *PrepareTicketDraftTool {
|
||||
return &PrepareTicketDraftTool{}
|
||||
}
|
||||
|
||||
func (t *PrepareTicketDraftTool) Spec() toolx.ToolSpec {
|
||||
return toolx.GraphPrepareTicketDraft
|
||||
}
|
||||
|
||||
func (t *PrepareTicketDraftTool) Name() string {
|
||||
return toolx.GraphPrepareTicketDraft.Name
|
||||
}
|
||||
|
||||
func (t *PrepareTicketDraftTool) Code() string {
|
||||
return toolx.GraphPrepareTicketDraft.Code
|
||||
}
|
||||
|
||||
func (t *PrepareTicketDraftTool) Enabled(ctx registry.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
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: toolx.GraphPrepareTicketDraft.Name,
|
||||
Desc: i18nx.Get("tool.graph.prepareTicketDraft.info"),
|
||||
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: i18nx.Get("tool.graph.prepareTicketDraft.param.title"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "description",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.prepareTicketDraft.param.description"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "issue",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.prepareTicketDraft.param.issue"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "impact",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.prepareTicketDraft.param.impact"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "expectedOutcome",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.prepareTicketDraft.param.expectedOutcome"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "currentAttempt",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.prepareTicketDraft.param.currentAttempt"),
|
||||
},
|
||||
},
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": toolx.GraphPrepareTicketDraft.Code,
|
||||
"sourceType": "graph",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *PrepareTicketDraftTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
|
||||
return graphs.NewPrepareTicketDraftGraph(t.conversation).Run(ctx, argumentsInJSON)
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/mcps"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/registry"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/tooling"
|
||||
aitooling "code.tczkiot.com/wlw/ai-agent/internal/ai/tooling"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/i18nx"
|
||||
"code.tczkiot.com/wlw/ai-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"
|
||||
)
|
||||
|
||||
type ToolSearchTool struct {
|
||||
allowedToolCodes []string
|
||||
}
|
||||
|
||||
func NewToolSearchTool() *ToolSearchTool {
|
||||
return &ToolSearchTool{}
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) Spec() toolx.ToolSpec {
|
||||
return toolx.BuiltinToolSearch
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) Name() string {
|
||||
return toolx.BuiltinToolSearch.Name
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) Code() string {
|
||||
return toolx.BuiltinToolSearch.Code
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) Enabled(ctx registry.Context) bool {
|
||||
return len(filterAllowedMCPToolCodes(ctx.AllowedToolCodes)) > 0
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) Build(ctx registry.Context) (einotool.BaseTool, error) {
|
||||
if !t.Enabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
return &ToolSearchTool{
|
||||
allowedToolCodes: filterAllowedMCPToolCodes(ctx.AllowedToolCodes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: toolx.BuiltinToolSearch.Name,
|
||||
Desc: "当你需要使用当前会话允许的长尾 MCP 工具时,先调用本工具搜索合适的 toolCode;确认目标后,可再次调用本工具并传入 toolCode 与 arguments 代理执行。不要用它替代明确固定的内置流程工具。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
|
||||
Version: einojsonschema.Version,
|
||||
Type: "object",
|
||||
Properties: orderedmap.New[string, *einojsonschema.Schema](orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "query",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: "要搜索的工具意图、能力或关键词;当只想列出候选工具时使用。",
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "toolCode",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: "已确定目标后要调用的 MCP toolCode,例如 mcp_server/tool_name。",
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "arguments",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "object",
|
||||
Description: "调用目标工具时传入的参数对象。",
|
||||
AdditionalProperties: &einojsonschema.Schema{},
|
||||
},
|
||||
},
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": toolx.BuiltinToolSearch.Code,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
|
||||
if t == nil {
|
||||
return "", fmt.Errorf("tool search tool is nil")
|
||||
}
|
||||
req, err := parseToolSearchRequest(argumentsInJSON)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if req.ToolCode != "" {
|
||||
return t.invokeTargetTool(ctx, req.ToolCode, req.Arguments)
|
||||
}
|
||||
return t.searchCandidates(ctx, req.Query)
|
||||
}
|
||||
|
||||
type toolSearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
ToolCode string `json:"toolCode"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
type toolSearchCandidate struct {
|
||||
ToolCode string `json:"toolCode"`
|
||||
ServerCode string `json:"serverCode"`
|
||||
ToolName string `json:"toolName"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func parseToolSearchRequest(argumentsInJSON string) (*toolSearchRequest, error) {
|
||||
argumentsInJSON = strings.TrimSpace(argumentsInJSON)
|
||||
if argumentsInJSON == "" {
|
||||
return &toolSearchRequest{}, nil
|
||||
}
|
||||
var req toolSearchRequest
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &req); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool_search arguments: %w", err)
|
||||
}
|
||||
req.Query = strings.TrimSpace(req.Query)
|
||||
req.ToolCode = strings.TrimSpace(req.ToolCode)
|
||||
if req.Arguments == nil {
|
||||
req.Arguments = map[string]any{}
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) searchCandidates(ctx context.Context, query string) (string, error) {
|
||||
candidates, err := t.loadAllowedCandidates(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
matched := filterCandidatesByQuery(candidates, query)
|
||||
if len(matched) == 0 {
|
||||
return "未找到匹配的动态工具,请换个关键词,或继续向用户追问后再搜索。", nil
|
||||
}
|
||||
if len(matched) > 8 {
|
||||
matched = matched[:8]
|
||||
}
|
||||
buf, err := json.Marshal(map[string]any{
|
||||
"query": strings.TrimSpace(query),
|
||||
"total": len(matched),
|
||||
"candidates": matched,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) invokeTargetTool(ctx context.Context, toolCode string, arguments map[string]any) (string, error) {
|
||||
toolCode = strings.TrimSpace(toolCode)
|
||||
serverCode, toolName := toolx.SplitMCPToolCode(toolCode)
|
||||
if serverCode == "" || toolName == "" {
|
||||
return "", i18nx.Errorf("error.e0077")
|
||||
}
|
||||
if !containsToolCode(t.allowedToolCodes, toolCode) {
|
||||
return "", i18nx.Errorf("error.e0279")
|
||||
}
|
||||
// The published Agent allow-list is the approval boundary for MCP tools.
|
||||
// The registry still enforces call limits and safety metadata.
|
||||
_, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, aitooling.Policy{
|
||||
AllowedToolCodes: t.allowedToolCodes,
|
||||
Confirmed: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return aitooling.SanitizePreview(buildToolCallResultSummary(result)), nil
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) loadAllowedCandidates(ctx context.Context) ([]toolSearchCandidate, error) {
|
||||
serverToToolCodes := make(map[string]map[string]struct{})
|
||||
for _, toolCode := range t.allowedToolCodes {
|
||||
serverCode, toolName := toolx.SplitMCPToolCode(toolCode)
|
||||
if serverCode == "" || toolName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := serverToToolCodes[serverCode]; !ok {
|
||||
serverToToolCodes[serverCode] = make(map[string]struct{})
|
||||
}
|
||||
serverToToolCodes[serverCode][toolCode] = struct{}{}
|
||||
}
|
||||
serverCodes := make([]string, 0, len(serverToToolCodes))
|
||||
for serverCode := range serverToToolCodes {
|
||||
serverCodes = append(serverCodes, serverCode)
|
||||
}
|
||||
slices.Sort(serverCodes)
|
||||
ret := make([]toolSearchCandidate, 0)
|
||||
for _, serverCode := range serverCodes {
|
||||
tools, err := mcps.Runtime.ListTools(ctx, serverCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := serverToToolCodes[serverCode]
|
||||
for _, item := range tools {
|
||||
toolCode := toolx.BuildMCPToolCode(serverCode, item.Name)
|
||||
if _, ok := allowed[toolCode]; !ok {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, toolSearchCandidate{
|
||||
ToolCode: toolCode,
|
||||
ServerCode: serverCode,
|
||||
ToolName: strings.TrimSpace(item.Name),
|
||||
Title: strings.TrimSpace(item.Title),
|
||||
Description: strings.TrimSpace(item.Description),
|
||||
})
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func filterAllowedMCPToolCodes(input []string) []string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make([]string, 0, len(input))
|
||||
for _, item := range input {
|
||||
item = strings.TrimSpace(item)
|
||||
serverCode, toolName := toolx.SplitMCPToolCode(item)
|
||||
if serverCode == "" || toolName == "" {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, item)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func containsToolCode(items []string, target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterCandidatesByQuery(candidates []toolSearchCandidate, query string) []toolSearchCandidate {
|
||||
query = strings.TrimSpace(strings.ToLower(query))
|
||||
if query == "" {
|
||||
return candidates
|
||||
}
|
||||
ret := make([]toolSearchCandidate, 0, len(candidates))
|
||||
for _, item := range candidates {
|
||||
searchText := strings.ToLower(strings.Join([]string{
|
||||
item.ToolCode,
|
||||
item.ServerCode,
|
||||
item.ToolName,
|
||||
item.Title,
|
||||
item.Description,
|
||||
}, "\n"))
|
||||
if strings.Contains(searchText, query) {
|
||||
ret = append(ret, item)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func cloneArguments(input map[string]any) map[string]any {
|
||||
if len(input) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
ret := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
ret[key] = value
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func buildToolCallResultSummary(result *mcps.ToolCallResult) string {
|
||||
return tooling.BuildReducedToolResultSummary(result)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseToolSearchRequest(t *testing.T) {
|
||||
req, err := parseToolSearchRequest(`{"query":" search docs ","toolCode":" mcp_server/search ","arguments":{"q":"hello"}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseToolSearchRequest returned error: %v", err)
|
||||
}
|
||||
if req.Query != "search docs" {
|
||||
t.Fatalf("unexpected query: %q", req.Query)
|
||||
}
|
||||
if req.ToolCode != "mcp_server/search" {
|
||||
t.Fatalf("unexpected toolCode: %q", req.ToolCode)
|
||||
}
|
||||
if req.Arguments["q"] != "hello" {
|
||||
t.Fatalf("unexpected arguments: %#v", req.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolSearchRequestDefaultsArguments(t *testing.T) {
|
||||
req, err := parseToolSearchRequest(`{"query":"list"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseToolSearchRequest returned error: %v", err)
|
||||
}
|
||||
if req.Arguments == nil {
|
||||
t.Fatalf("expected non-nil arguments map")
|
||||
}
|
||||
if len(req.Arguments) != 0 {
|
||||
t.Fatalf("expected empty arguments map, got %#v", req.Arguments)
|
||||
}
|
||||
}
|
||||
@@ -62,28 +62,21 @@ func (t *TriageServiceRequestTool) Info(ctx context.Context) (*schema.ToolInfo,
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "observedIssue",
|
||||
Key: "observed_issue",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.observedIssue"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "needTicket",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "boolean",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.needTicket"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "needHumanHandoff",
|
||||
Key: "need_human_handoff",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "boolean",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.needHumanHandoff"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "additionalContext",
|
||||
Key: "additional_context",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.additionalContext"),
|
||||
@@ -92,8 +85,8 @@ func (t *TriageServiceRequestTool) Info(ctx context.Context) (*schema.ToolInfo,
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": toolx.GraphTriageServiceRequest.Code,
|
||||
"sourceType": "graph",
|
||||
"tool_code": toolx.GraphTriageServiceRequest.Code,
|
||||
"source_type": "graph",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ package traces
|
||||
|
||||
type RetrieverTraceItem struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"`
|
||||
DocumentID int64 `json:"documentId,omitempty"`
|
||||
DocumentTitle string `json:"documentTitle,omitempty"`
|
||||
KnowledgeBaseID int64 `json:"knowledge_base_id,omitempty"`
|
||||
DocumentID int64 `json:"document_id,omitempty"`
|
||||
DocumentTitle string `json:"document_title,omitempty"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||
}
|
||||
|
||||
type RetrieverTraceSummary struct {
|
||||
@@ -23,7 +23,7 @@ type RetrieverTraceSummary struct {
|
||||
}
|
||||
|
||||
type RetrieverPolicyTraceItem struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"`
|
||||
TopK int `json:"topK,omitempty"`
|
||||
ScoreThreshold float64 `json:"scoreThreshold,omitempty"`
|
||||
KnowledgeBaseID int64 `json:"knowledge_base_id,omitempty"`
|
||||
TopK int `json:"top_k,omitempty"`
|
||||
ScoreThreshold float64 `json:"score_threshold,omitempty"`
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user