refactor: 将客服后端重构为宿主可嵌入模块

- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
t
2026-08-28 22:23:13 +08:00
parent 6845c728f8
commit 18c9354095
377 changed files with 13199 additions and 22881 deletions
-176
View File
@@ -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
}
+3 -3
View File
@@ -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,
}
}
+16 -13
View File
@@ -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, "操作已取消。")
}
+16
View File
@@ -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,
})
+10 -10
View File
@@ -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)
}
}
+1 -3
View File
@@ -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())
+3 -4
View File
@@ -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)
+20 -24
View File
@@ -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
+7 -4
View File
@@ -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 {
+32
View File
@@ -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{
+428 -14
View File
@@ -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)
}
}
+22 -10
View File
@@ -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)
}
+3 -3
View File
@@ -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
}
+12 -10
View File
@@ -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:
+13 -2
View File
@@ -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
}
+7 -7
View File
@@ -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