chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
176
internal/ai/runtime/debug_run.go
Normal file
176
internal/ai/runtime/debug_run.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
svc "agent-desk/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
|
||||
}
|
||||
44
internal/ai/runtime/evaluation_run.go
Normal file
44
internal/ai/runtime/evaluation_run.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
svc "agent-desk/internal/services"
|
||||
)
|
||||
|
||||
func init() {
|
||||
svc.AgentEvaluationRunHook = RunAgentEvaluation
|
||||
}
|
||||
|
||||
func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
|
||||
agent := svc.AIAgentService.Get(req.AIAgentID)
|
||||
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")
|
||||
}
|
||||
cases := make([]applicationruntime.OfflineEvaluationCase, 0, len(req.Cases))
|
||||
for _, item := range req.Cases {
|
||||
cases = append(cases, applicationruntime.OfflineEvaluationCase{ID: item.ID, Category: item.Category, Message: item.Message, History: item.History, Expect: item.Expect})
|
||||
}
|
||||
report, err := applicationruntime.NewService().RunOfflineEvaluation(ctx, *agent, *config, cases)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
csv, err := report.CSV()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := &response.AgentEvaluationReportResponse{Total: report.Total, Passed: report.Passed, CSV: csv, Results: make([]response.AgentEvaluationResultResponse, 0, len(report.Results))}
|
||||
for _, item := range report.Results {
|
||||
ret.Results = append(ret.Results, response.AgentEvaluationResultResponse{CaseID: item.CaseID, Category: item.Category, Passed: item.Passed, ReplyText: item.ReplyText, Interrupted: item.Interrupted, Error: item.Error, Finding: item.Finding})
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
224
internal/ai/runtime/graphs/analyze_conversation_graph.go
Normal file
224
internal/ai/runtime/graphs/analyze_conversation_graph.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type AnalyzeConversationGraph struct {
|
||||
conversation models.Conversation
|
||||
}
|
||||
|
||||
func NewAnalyzeConversationGraph(conversation models.Conversation) *AnalyzeConversationGraph {
|
||||
return &AnalyzeConversationGraph{conversation: conversation}
|
||||
}
|
||||
|
||||
func (g *AnalyzeConversationGraph) 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, 8, "", "")
|
||||
result := buildAnalyzeConversationResult(g.conversation, messages, input)
|
||||
buf, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func (g *AnalyzeConversationGraph) parseInput(argumentsInJSON string) (AnalyzeConversationInput, error) {
|
||||
var input AnalyzeConversationInput
|
||||
if strings.TrimSpace(argumentsInJSON) == "" {
|
||||
return input, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &input); err != nil {
|
||||
return input, fmt.Errorf("invalid analyze conversation arguments: %w", err)
|
||||
}
|
||||
input.Goal = strings.TrimSpace(input.Goal)
|
||||
input.ObservedIssue = strings.TrimSpace(input.ObservedIssue)
|
||||
input.AdditionalContext = strings.TrimSpace(input.AdditionalContext)
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func buildAnalyzeConversationResult(conversation models.Conversation, messages []models.Message, input AnalyzeConversationInput) AnalyzeConversationResult {
|
||||
joined := strings.ToLower(buildConversationCorpus(conversation, messages, input))
|
||||
signals := collectRiskSignals(joined, input)
|
||||
intent := detectUserIntent(joined, input)
|
||||
recommendedAction := recommendNextAction(intent, signals, input)
|
||||
result := AnalyzeConversationResult{
|
||||
Summary: buildConversationSummary(conversation, messages, input),
|
||||
UserIntent: intent,
|
||||
RiskLevel: deriveRiskLevel(signals),
|
||||
RiskSignals: signals,
|
||||
RecommendedNextAction: recommendedAction,
|
||||
RecommendedQuestions: recommendQuestions(intent, signals, input),
|
||||
ConversationFacts: buildConversationAnalysisFacts(conversation, messages),
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildConversationSummary(conversation models.Conversation, messages []models.Message, input AnalyzeConversationInput) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if input.ObservedIssue != "" {
|
||||
parts = append(parts, "当前问题:"+input.ObservedIssue)
|
||||
} else if strings.TrimSpace(conversation.LastMessageSummary) != "" {
|
||||
parts = append(parts, "当前问题:"+strings.TrimSpace(conversation.LastMessageSummary))
|
||||
}
|
||||
if digest := buildRecentMessageDigest(messages); digest != "" {
|
||||
parts = append(parts, "最近对话:"+digest)
|
||||
}
|
||||
if input.AdditionalContext != "" {
|
||||
parts = append(parts, "补充信息:"+input.AdditionalContext)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, "\n"))
|
||||
}
|
||||
|
||||
func buildConversationCorpus(conversation models.Conversation, messages []models.Message, input AnalyzeConversationInput) string {
|
||||
parts := make([]string, 0, len(messages)+4)
|
||||
parts = append(parts, strings.TrimSpace(conversation.LastMessageSummary))
|
||||
parts = append(parts, input.Goal, input.ObservedIssue, input.AdditionalContext)
|
||||
for i := range messages {
|
||||
parts = append(parts, strings.TrimSpace(messages[i].Content))
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func collectRiskSignals(joined string, input AnalyzeConversationInput) []string {
|
||||
signals := make([]string, 0, 6)
|
||||
add := func(signal string) {
|
||||
for _, item := range signals {
|
||||
if item == signal {
|
||||
return
|
||||
}
|
||||
}
|
||||
signals = append(signals, signal)
|
||||
}
|
||||
if containsAny(joined, "投诉", "举报", "差评", "曝光", "媒体", "起诉", "律师", "12315") {
|
||||
add("complaint_escalation")
|
||||
}
|
||||
if containsAny(joined, "退款", "赔偿", "损失", "扣款", "重复扣费", "金额") {
|
||||
add("financial_risk")
|
||||
}
|
||||
if containsAny(joined, "生气", "愤怒", "垃圾", "太差", "一直没人", "再不处理") {
|
||||
add("negative_sentiment")
|
||||
}
|
||||
if containsAny(joined, "人工", "转人工", "真人", "客服") || input.NeedHumanHandoff {
|
||||
add("handoff_requested")
|
||||
}
|
||||
if containsAny(joined, "工单", "报障", "售后", "登记", "记录问题") || input.NeedTicket {
|
||||
add("ticket_expected")
|
||||
}
|
||||
if input.NeedQualityCheck {
|
||||
add("quality_review_requested")
|
||||
}
|
||||
return signals
|
||||
}
|
||||
|
||||
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:
|
||||
return "general_support"
|
||||
}
|
||||
}
|
||||
|
||||
func deriveRiskLevel(signals []string) string {
|
||||
if len(signals) == 0 {
|
||||
return "low"
|
||||
}
|
||||
if containsSignal(signals, "complaint_escalation") || containsSignal(signals, "financial_risk") {
|
||||
return "high"
|
||||
}
|
||||
if containsSignal(signals, "handoff_requested") || containsSignal(signals, "negative_sentiment") {
|
||||
return "medium"
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
func recommendNextAction(intent string, signals []string, input AnalyzeConversationInput) string {
|
||||
switch {
|
||||
case input.NeedQualityCheck:
|
||||
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:
|
||||
return "continue_answering"
|
||||
}
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
if intent == "complaint" {
|
||||
questions = append(questions, "Please confirm the complaint, impact, and the user's most important desired outcome.")
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
func buildConversationAnalysisFacts(conversation models.Conversation, messages []models.Message) []string {
|
||||
facts := make([]string, 0, 4)
|
||||
if strings.TrimSpace(conversation.LastMessageSummary) != "" {
|
||||
facts = append(facts, "最近摘要:"+strings.TrimSpace(conversation.LastMessageSummary))
|
||||
}
|
||||
if digest := buildRecentMessageDigest(messages); digest != "" {
|
||||
facts = append(facts, "最近消息:"+digest)
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
func containsAny(value string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(value, strings.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsSignal(signals []string, target string) bool {
|
||||
for _, item := range signals {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
func TestBuildAnalyzeConversationResult_RecommendsHandoffForComplaint(t *testing.T) {
|
||||
conversation := models.Conversation{
|
||||
LastMessageSummary: "用户反馈被重复扣费,并要求人工处理",
|
||||
}
|
||||
messages := []models.Message{
|
||||
{SenderType: enums.IMSenderTypeCustomer, Content: "你们重复扣费了,我要投诉并转人工"},
|
||||
}
|
||||
|
||||
got := buildAnalyzeConversationResult(conversation, messages, AnalyzeConversationInput{
|
||||
NeedHumanHandoff: true,
|
||||
})
|
||||
|
||||
if got.UserIntent != "handoff_request" {
|
||||
t.Fatalf("expected handoff_request, got %q", got.UserIntent)
|
||||
}
|
||||
if got.RiskLevel != "high" {
|
||||
t.Fatalf("expected high risk, got %q", got.RiskLevel)
|
||||
}
|
||||
if got.RecommendedNextAction != "handoff_to_human" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
153
internal/ai/runtime/graphs/create_ticket_graph.go
Normal file
153
internal/ai/runtime/graphs/create_ticket_graph.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/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) {
|
||||
locale := services.ConversationHumanDispatchService.ResolveConversationLocale(g.conversation.ID)
|
||||
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, locale),
|
||||
}
|
||||
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, locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
if !hasData {
|
||||
info := CreateTicketGraphInterruptInfo{
|
||||
Type: InterruptTypeTicketCreationConfirmation,
|
||||
Message: ConfirmOrCancelPromptForLocale(locale),
|
||||
}
|
||||
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(locale, "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: CancelCreateTicketReplyForLocale(locale),
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
default:
|
||||
info := CreateTicketGraphInterruptInfo{
|
||||
Type: InterruptTypeTicketCreationConfirmation,
|
||||
Message: NeedExplicitConfirmationPromptForLocale(locale),
|
||||
}
|
||||
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, locale string) string {
|
||||
return i18nx.Getf(locale, "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,
|
||||
}
|
||||
}
|
||||
150
internal/ai/runtime/graphs/handoff_graph.go
Normal file
150
internal/ai/runtime/graphs/handoff_graph.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/tracex"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
componenttool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type HandoffGraphState struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type HandoffGraphInterruptInfo struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type handoffGraphArgs struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
schema.RegisterName[HandoffGraphState]("cs_ai_agent_handoff_graph_state")
|
||||
schema.RegisterName[HandoffGraphInterruptInfo]("cs_ai_agent_handoff_graph_interrupt_info")
|
||||
}
|
||||
|
||||
type HandoffGraph struct {
|
||||
conversation models.Conversation
|
||||
aiAgent models.AIAgent
|
||||
}
|
||||
|
||||
func NewHandoffGraph(conversation models.Conversation, aiAgent models.AIAgent) *HandoffGraph {
|
||||
return &HandoffGraph{
|
||||
conversation: conversation,
|
||||
aiAgent: aiAgent,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *HandoffGraph) Run(ctx context.Context, argumentsInJSON string) (string, error) {
|
||||
locale := services.ConversationHumanDispatchService.ResolveConversationLocale(g.conversation.ID)
|
||||
wasInterrupted, hasState, state := componenttool.GetInterruptState[HandoffGraphState](ctx)
|
||||
if !wasInterrupted {
|
||||
reason, err := g.buildReason(argumentsInJSON, locale)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
requestID := tracex.RequestIDFromContext(ctx)
|
||||
handled, err := services.ConversationService.TryOffHoursHandoffByAIWithRequestID(g.conversation.ID, g.aiAgent, reason, requestID)
|
||||
if err != nil || handled {
|
||||
if handled && err == nil {
|
||||
offHoursMsg := strings.TrimSpace(g.aiAgent.OffHoursMessage)
|
||||
if offHoursMsg == "" {
|
||||
offHoursMsg = services.HandoffOffHoursMessageForLocale(locale)
|
||||
}
|
||||
return tooling.MarshalToolResult(tooling.ToolResult{
|
||||
Handled: true,
|
||||
Terminal: true,
|
||||
Action: "off_hours_handoff",
|
||||
ReplyText: offHoursMsg,
|
||||
ReplySent: true,
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
info := HandoffGraphInterruptInfo{
|
||||
Type: InterruptTypeHandoffConfirmation,
|
||||
Message: g.buildConfirmationPrompt(reason, locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, HandoffGraphState{Reason: reason})
|
||||
}
|
||||
if !hasState {
|
||||
return "", fmt.Errorf("handoff graph state missing")
|
||||
}
|
||||
isResumeTarget, hasData, resumeText := componenttool.GetResumeContext[string](ctx)
|
||||
if !isResumeTarget {
|
||||
info := HandoffGraphInterruptInfo{
|
||||
Type: InterruptTypeHandoffConfirmation,
|
||||
Message: g.buildConfirmationPrompt(state.Reason, locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
if !hasData {
|
||||
info := HandoffGraphInterruptInfo{
|
||||
Type: InterruptTypeHandoffConfirmation,
|
||||
Message: ConfirmOrCancelPromptForLocale(locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
switch parseHandoffDecision(resumeText) {
|
||||
case ConfirmationDecisionConfirm:
|
||||
if err := services.ConversationService.HandoffByAIWithRequestID(g.conversation.ID, g.aiAgent, state.Reason, tracex.RequestIDFromContext(ctx)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// ConversationService sends the customer-visible handoff notice according to the dispatch decision.
|
||||
return tooling.MarshalToolResult(tooling.ToolResult{
|
||||
Handled: true,
|
||||
Terminal: true,
|
||||
Action: "handoff_confirmed",
|
||||
ReplySent: true,
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
case ConfirmationDecisionCancel:
|
||||
return tooling.MarshalToolResult(tooling.ToolResult{
|
||||
Handled: true,
|
||||
Terminal: true,
|
||||
Action: "handoff_cancelled",
|
||||
ReplyText: CancelHandoffReplyForLocale(locale),
|
||||
ShouldRetry: false,
|
||||
}), nil
|
||||
default:
|
||||
info := HandoffGraphInterruptInfo{
|
||||
Type: InterruptTypeHandoffConfirmation,
|
||||
Message: NeedExplicitConfirmationPromptForLocale(locale),
|
||||
}
|
||||
return "", componenttool.StatefulInterrupt(ctx, info, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *HandoffGraph) buildReason(argumentsInJSON string, locale string) (string, error) {
|
||||
reason := i18nx.Getf(locale, "graph.defaultHandoffReason")
|
||||
var args handoffGraphArgs
|
||||
if strings.TrimSpace(argumentsInJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid handoff arguments: %w", err)
|
||||
}
|
||||
}
|
||||
if parsed := strings.TrimSpace(args.Reason); parsed != "" {
|
||||
reason = parsed
|
||||
}
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
func (g *HandoffGraph) buildConfirmationPrompt(reason string, locale string) string {
|
||||
return i18nx.Getf(locale, "graph.handoffConfirmPrompt", strings.TrimSpace(reason))
|
||||
}
|
||||
|
||||
func parseHandoffDecision(value string) ConfirmationDecision {
|
||||
return ParseConfirmationDecision(value)
|
||||
}
|
||||
247
internal/ai/runtime/graphs/handoff_graph_test.go
Normal file
247
internal/ai/runtime/graphs/handoff_graph_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestHandoffGraphOffHoursSendsNoticeWithoutConfirmation(t *testing.T) {
|
||||
db := setupHandoffGraphTestDB(t)
|
||||
aiAgent := createHandoffGraphAIAgent(t, db, "1")
|
||||
conversation := createHandoffGraphConversation(t, db, aiAgent.ID)
|
||||
|
||||
reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"用户要求转人工"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var result tooling.ToolResult
|
||||
if err := json.Unmarshal([]byte(reply), &result); err != nil {
|
||||
t.Fatalf("expected graph tool result JSON, got %q: %v", reply, err)
|
||||
}
|
||||
if !result.Handled || !result.Terminal || result.ShouldRetry {
|
||||
t.Fatalf("unexpected graph result flags: %+v", result)
|
||||
}
|
||||
if !result.ReplySent {
|
||||
t.Fatalf("expected graph result to mark replySent, got %+v", result)
|
||||
}
|
||||
expectedMsg := services.HandoffOffHoursMessageForLocale(services.ResolveConversationLocale(conversation.ID))
|
||||
if result.Action != "off_hours_handoff" || result.ReplyText != expectedMsg {
|
||||
t.Fatalf("unexpected off-hours graph result: %+v, want %q", result, expectedMsg)
|
||||
}
|
||||
|
||||
message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id"))
|
||||
if message == nil {
|
||||
t.Fatalf("expected off-hours notice message")
|
||||
}
|
||||
if message.Content != expectedMsg {
|
||||
t.Fatalf("expected off-hours notice %q, got %q", expectedMsg, message.Content)
|
||||
}
|
||||
|
||||
current := services.ConversationService.Get(conversation.ID)
|
||||
if current == nil {
|
||||
t.Fatalf("expected conversation")
|
||||
}
|
||||
if current.Status != enums.IMConversationStatusPending {
|
||||
t.Fatalf("expected conversation to be queued in pending status, got %d", current.Status)
|
||||
}
|
||||
if current.HandoffAt == nil {
|
||||
t.Fatalf("expected handoff_at to be set, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffGraphOffHoursSendsNoticeInRussianForRussianConversation(t *testing.T) {
|
||||
db := setupHandoffGraphTestDB(t)
|
||||
aiAgent := createHandoffGraphAIAgent(t, db, "1")
|
||||
conversation := createHandoffGraphConversation(t, db, aiAgent.ID)
|
||||
|
||||
now := time.Now()
|
||||
if err := db.Create(&models.Message{
|
||||
ConversationID: conversation.ID,
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
SenderID: 1,
|
||||
Content: "Привет хочу возрат денег",
|
||||
SentAt: &now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create message error = %v", err)
|
||||
}
|
||||
|
||||
reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"перенаправляю оператору"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var result tooling.ToolResult
|
||||
if err := json.Unmarshal([]byte(reply), &result); err != nil {
|
||||
t.Fatalf("expected graph tool result JSON, got %q: %v", reply, err)
|
||||
}
|
||||
expectedRuMsg := services.HandoffOffHoursMessageForLocale("ru-RU")
|
||||
if result.Action != "off_hours_handoff" || result.ReplyText != expectedRuMsg {
|
||||
t.Fatalf("unexpected off-hours graph result for ru-RU: %+v, want %q", result, expectedRuMsg)
|
||||
}
|
||||
|
||||
message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Eq("sender_type", enums.IMSenderTypeAI).Desc("id"))
|
||||
if message == nil {
|
||||
t.Fatalf("expected off-hours notice message")
|
||||
}
|
||||
if message.Content != expectedRuMsg {
|
||||
t.Fatalf("expected Russian off-hours notice %q, got %q", expectedRuMsg, message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffGraphOffHoursUsesCustomAgentNotice(t *testing.T) {
|
||||
db := setupHandoffGraphTestDB(t)
|
||||
aiAgent := createHandoffGraphAIAgent(t, db, "1")
|
||||
aiAgent.OffHoursMessage = "Операторы сейчас оффлайн. Мы ответим позже!"
|
||||
conversation := createHandoffGraphConversation(t, db, aiAgent.ID)
|
||||
|
||||
reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"позови человека"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var result tooling.ToolResult
|
||||
if err := json.Unmarshal([]byte(reply), &result); err != nil {
|
||||
t.Fatalf("expected graph tool result JSON, got %q: %v", reply, err)
|
||||
}
|
||||
if result.Action != "off_hours_handoff" || result.ReplyText != aiAgent.OffHoursMessage {
|
||||
t.Fatalf("expected custom notice %q, got %+v", aiAgent.OffHoursMessage, result)
|
||||
}
|
||||
|
||||
current := services.ConversationService.Get(conversation.ID)
|
||||
if current == nil || current.Status != enums.IMConversationStatusPending {
|
||||
t.Fatalf("expected conversation to be queued in pending status, got %v", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffGraphWithActiveScheduleStillRequestsConfirmation(t *testing.T) {
|
||||
db := setupHandoffGraphTestDB(t)
|
||||
aiAgent := createHandoffGraphAIAgent(t, db, "1")
|
||||
createHandoffGraphTeam(t, db, 1)
|
||||
createHandoffGraphActiveSchedule(t, db, 1)
|
||||
conversation := createHandoffGraphConversation(t, db, aiAgent.ID)
|
||||
|
||||
reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"用户要求转人工"}`)
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirmation interrupt")
|
||||
}
|
||||
if !strings.Contains(err.Error(), InterruptTypeHandoffConfirmation) {
|
||||
t.Fatalf("expected handoff confirmation interrupt, got %v", err)
|
||||
}
|
||||
if reply != "" {
|
||||
t.Fatalf("expected no reply before confirmation, got %q", reply)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count messages error = %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected no notice before confirmation, got %d messages", count)
|
||||
}
|
||||
}
|
||||
|
||||
func setupHandoffGraphTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(
|
||||
&models.Channel{},
|
||||
&models.CustomerIdentity{},
|
||||
&models.AIAgent{},
|
||||
&models.AgentTeam{},
|
||||
&models.AgentTeamSchedule{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationAssignment{},
|
||||
&models.ConversationEventLog{},
|
||||
&models.ConversationReadState{},
|
||||
&models.Message{},
|
||||
&models.ChannelMessageOutbox{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate error = %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
|
||||
func createHandoffGraphAIAgent(t *testing.T, db *gorm.DB, teamIDs string) models.AIAgent {
|
||||
t.Helper()
|
||||
|
||||
item := models.AIAgent{
|
||||
Name: "测试AI",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
TeamIDs: teamIDs,
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create ai agent error = %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func createHandoffGraphTeam(t *testing.T, db *gorm.DB, id int64) {
|
||||
t.Helper()
|
||||
|
||||
if err := db.Create(&models.AgentTeam{ID: id, Name: "售后支持组", Status: enums.StatusOk}).Error; err != nil {
|
||||
t.Fatalf("create team error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createHandoffGraphActiveSchedule(t *testing.T, db *gorm.DB, teamID int64) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
if err := db.Create(&models.AgentTeamSchedule{
|
||||
TeamID: teamID,
|
||||
StartAt: now.Add(-time.Hour),
|
||||
EndAt: now.Add(time.Hour),
|
||||
Status: enums.StatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create schedule error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createHandoffGraphConversation(t *testing.T, db *gorm.DB, aiAgentID int64) models.Conversation {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
item := models.Conversation{
|
||||
AIAgentID: aiAgentID,
|
||||
ChannelID: 1,
|
||||
CustomerID: 1,
|
||||
CustomerName: "测试访客",
|
||||
Status: enums.IMConversationStatusAIServing,
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
LastMessageAt: now,
|
||||
LastActiveAt: now,
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create conversation error = %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
101
internal/ai/runtime/graphs/hitl.go
Normal file
101
internal/ai/runtime/graphs/hitl.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
)
|
||||
|
||||
const (
|
||||
InterruptTypeTicketCreationConfirmation = "ticket_creation_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")
|
||||
)
|
||||
|
||||
func ConfirmOrCancelPromptForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.confirmOrCancel")
|
||||
}
|
||||
|
||||
func NeedExplicitConfirmationPromptForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.needExplicitConfirmation")
|
||||
}
|
||||
|
||||
func ConfirmationExpiredReplyForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.confirmationExpired")
|
||||
}
|
||||
|
||||
func CancelCreateTicketReplyForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.cancelCreateTicket")
|
||||
}
|
||||
|
||||
func CancelHandoffReplyForLocale(locale string) string {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = i18nx.DefaultLocale
|
||||
}
|
||||
return i18nx.Getf(locale, "graph.cancelHandoff")
|
||||
}
|
||||
|
||||
type ConfirmationDecision string
|
||||
|
||||
const (
|
||||
ConfirmationDecisionConfirm ConfirmationDecision = "confirm"
|
||||
ConfirmationDecisionCancel ConfirmationDecision = "cancel"
|
||||
)
|
||||
|
||||
func ParseConfirmationDecision(value string) ConfirmationDecision {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
cancelWords := []string{
|
||||
"取消", "不用", "不需要", "算了", "no", "cancel", "stop",
|
||||
"нет", "отмена", "отменить", "не надо", "не нужно", "стоп", "хватит", "отбой",
|
||||
}
|
||||
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, "已取消本次转人工。") ||
|
||||
strings.Contains(replyText, "Создание обращения отменено.") ||
|
||||
strings.Contains(replyText, "Передача человеку отменена.") ||
|
||||
strings.Contains(replyText, "Ticket creation cancelled.") ||
|
||||
strings.Contains(replyText, "Human handoff cancelled.")
|
||||
}
|
||||
186
internal/ai/runtime/graphs/prepare_ticket_draft_graph.go
Normal file
186
internal/ai/runtime/graphs/prepare_ticket_draft_graph.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/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])) + "..."
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/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)
|
||||
}
|
||||
}
|
||||
80
internal/ai/runtime/graphs/triage_service_request_graph.go
Normal file
80
internal/ai/runtime/graphs/triage_service_request_graph.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/services"
|
||||
)
|
||||
|
||||
type TriageServiceRequestInput struct {
|
||||
Goal string `json:"goal"`
|
||||
ObservedIssue string `json:"observedIssue"`
|
||||
NeedTicket bool `json:"needTicket"`
|
||||
NeedHumanHandoff bool `json:"needHumanHandoff"`
|
||||
AdditionalContext string `json:"additionalContext"`
|
||||
}
|
||||
|
||||
type TriageServiceRequestResult struct {
|
||||
Analysis AnalyzeConversationResult `json:"analysis"`
|
||||
TicketDraft *PrepareTicketDraftResult `json:"ticketDraft,omitempty"`
|
||||
RecommendedAction string `json:"recommendedAction"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
type TriageServiceRequestGraph struct {
|
||||
conversation models.Conversation
|
||||
}
|
||||
|
||||
func NewTriageServiceRequestGraph(conversation models.Conversation) *TriageServiceRequestGraph {
|
||||
return &TriageServiceRequestGraph{conversation: conversation}
|
||||
}
|
||||
|
||||
func (g *TriageServiceRequestGraph) Run(_ context.Context, argumentsInJSON string) (string, error) {
|
||||
input, err := g.parseInput(argumentsInJSON)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
messages, _, _ := services.MessageService.FindByConversationIDCursor(g.conversation.ID, 0, 8, "", "")
|
||||
analysis := buildAnalyzeConversationResult(g.conversation, messages, AnalyzeConversationInput{
|
||||
Goal: input.Goal,
|
||||
ObservedIssue: input.ObservedIssue,
|
||||
NeedTicket: input.NeedTicket,
|
||||
NeedHumanHandoff: input.NeedHumanHandoff,
|
||||
AdditionalContext: input.AdditionalContext,
|
||||
})
|
||||
result := TriageServiceRequestResult{
|
||||
Analysis: analysis,
|
||||
RecommendedAction: analysis.RecommendedNextAction,
|
||||
Ready: analysis.RecommendedNextAction == "continue_answering" || analysis.RecommendedNextAction == "handoff_to_human",
|
||||
}
|
||||
if analysis.RecommendedNextAction == "prepare_ticket" {
|
||||
draft := buildPrepareTicketDraftResult(g.conversation, messages, PrepareTicketDraftInput{
|
||||
Issue: input.ObservedIssue,
|
||||
})
|
||||
result.TicketDraft = &draft
|
||||
result.Ready = draft.Ready
|
||||
}
|
||||
buf, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func (g *TriageServiceRequestGraph) parseInput(argumentsInJSON string) (TriageServiceRequestInput, error) {
|
||||
var input TriageServiceRequestInput
|
||||
if strings.TrimSpace(argumentsInJSON) == "" {
|
||||
return input, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &input); err != nil {
|
||||
return input, fmt.Errorf("invalid triage service request arguments: %w", err)
|
||||
}
|
||||
input.Goal = strings.TrimSpace(input.Goal)
|
||||
input.ObservedIssue = strings.TrimSpace(input.ObservedIssue)
|
||||
input.AdditionalContext = strings.TrimSpace(input.AdditionalContext)
|
||||
return input, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package graphs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/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: "用户要求人工处理扣费投诉",
|
||||
}
|
||||
messages := []models.Message{
|
||||
{SenderType: enums.IMSenderTypeCustomer, Content: "我要投诉并转人工,你们重复扣费了"},
|
||||
}
|
||||
|
||||
analysis := buildAnalyzeConversationResult(conversation, messages, AnalyzeConversationInput{
|
||||
NeedHumanHandoff: true,
|
||||
})
|
||||
if analysis.RecommendedNextAction != "handoff_to_human" {
|
||||
t.Fatalf("expected handoff_to_human, got %q", analysis.RecommendedNextAction)
|
||||
}
|
||||
}
|
||||
82
internal/ai/runtime/instruction/assembler.go
Normal file
82
internal/ai/runtime/instruction/assembler.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package instruction
|
||||
|
||||
import "strings"
|
||||
|
||||
type Assembler struct{}
|
||||
|
||||
type AssemblerInput struct {
|
||||
AgentInstruction string
|
||||
SkillInstruction string
|
||||
ToolAppendices []string
|
||||
}
|
||||
|
||||
type AssemblySummary struct {
|
||||
SectionTitles []string
|
||||
HasAgentRule bool
|
||||
HasSkillRule bool
|
||||
HasToolRule bool
|
||||
}
|
||||
|
||||
type AssemblyResult struct {
|
||||
Text string
|
||||
Summary AssemblySummary
|
||||
}
|
||||
|
||||
func NewAssembler() *Assembler {
|
||||
return &Assembler{}
|
||||
}
|
||||
|
||||
func (a *Assembler) Build(input AssemblerInput) string {
|
||||
return a.Assemble(input).Text
|
||||
}
|
||||
|
||||
func (a *Assembler) Assemble(input AssemblerInput) AssemblyResult {
|
||||
parts := make([]string, 0, 3)
|
||||
summary := AssemblySummary{SectionTitles: make([]string, 0, 3)}
|
||||
if agentInstruction := strings.TrimSpace(input.AgentInstruction); agentInstruction != "" {
|
||||
parts = append(parts, buildInstructionSection("Agent Rules", agentInstruction))
|
||||
summary.HasAgentRule = true
|
||||
summary.SectionTitles = append(summary.SectionTitles, "Agent Rules")
|
||||
}
|
||||
if skillInstruction := strings.TrimSpace(input.SkillInstruction); skillInstruction != "" {
|
||||
parts = append(parts, buildInstructionSection("Current Skill Context", skillInstruction))
|
||||
summary.HasSkillRule = true
|
||||
summary.SectionTitles = append(summary.SectionTitles, "Current Skill Context")
|
||||
}
|
||||
if appendix := buildToolAppendix(input.ToolAppendices); appendix != "" {
|
||||
parts = append(parts, buildInstructionSection("Tool Supplementary Rules", appendix))
|
||||
summary.HasToolRule = true
|
||||
summary.SectionTitles = append(summary.SectionTitles, "Tool Supplementary Rules")
|
||||
}
|
||||
return AssemblyResult{
|
||||
Text: strings.TrimSpace(strings.Join(parts, "\n\n")),
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
func buildInstructionSection(title, body string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if title == "" {
|
||||
return body
|
||||
}
|
||||
return title + ":\n" + body
|
||||
}
|
||||
|
||||
func buildToolAppendix(input []string) string {
|
||||
if len(input) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(input))
|
||||
for _, item := range input {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, item)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||
}
|
||||
36
internal/ai/runtime/instruction/assembler_test.go
Normal file
36
internal/ai/runtime/instruction/assembler_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package instruction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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 Rules:\nagent-rule") {
|
||||
t.Fatalf("missing agent instruction: %s", result.Text)
|
||||
}
|
||||
if !strings.Contains(result.Text, "Current Skill Context:\nskill-rule") {
|
||||
t.Fatalf("missing skill instruction: %s", result.Text)
|
||||
}
|
||||
if !strings.Contains(result.Text, "Tool Supplementary Rules:\ntool-rule-1") {
|
||||
t.Fatalf("missing tool appendix: %s", result.Text)
|
||||
}
|
||||
if !result.Summary.HasAgentRule || !result.Summary.HasSkillRule || !result.Summary.HasToolRule {
|
||||
t.Fatalf("unexpected summary: %#v", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssemblerReturnsEmptyTextWhenInputIsEmpty(t *testing.T) {
|
||||
result := NewAssembler().Assemble(AssemblerInput{})
|
||||
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 {
|
||||
t.Fatalf("expected empty summary, got %#v", result.Summary)
|
||||
}
|
||||
}
|
||||
89
internal/ai/runtime/instruction/helpers.go
Normal file
89
internal/ai/runtime/instruction/helpers.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package instruction
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
runtimetooling "agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
func BuildSelectedSkillActivationInstruction(skill *models.SkillDefinition) string {
|
||||
if skill == nil {
|
||||
return ""
|
||||
}
|
||||
lines := []string{
|
||||
"Active Specialized Skill:",
|
||||
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, "", "Execution Requirements:", "- Prioritize handling inquiries within this skill's scope this turn.", fmt.Sprintf("- When specialized handling details are needed, invoke %s to load skill documentation before proceeding.", toolx.BuiltinSkill.Name), "- If key information is missing, ask the customer for clarification first.", "- Do not invoke tools unauthorized for this skill.")
|
||||
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{
|
||||
"Active Specialized Skill:",
|
||||
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, "", "Skill Documentation:", content)
|
||||
}
|
||||
if examples := parseJSONStringArray(skill.Examples); len(examples) > 0 {
|
||||
lines = append(lines, "", "Typical Example Inquiries:")
|
||||
for _, item := range examples {
|
||||
lines = append(lines, "- "+item)
|
||||
}
|
||||
}
|
||||
if len(toolDefinitions) > 0 {
|
||||
lines = append(lines, "", "Authorized Tools for this Skill:")
|
||||
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
|
||||
}
|
||||
36
internal/ai/runtime/instruction/providers.go
Normal file
36
internal/ai/runtime/instruction/providers.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package instruction
|
||||
|
||||
import (
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/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
|
||||
}
|
||||
60
internal/ai/runtime/instruction/service.go
Normal file
60
internal/ai/runtime/instruction/service.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package instruction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
runtimetooling "agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/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,
|
||||
})
|
||||
}
|
||||
79
internal/ai/runtime/readtools/graph_executor.go
Normal file
79
internal/ai/runtime/readtools/graph_executor.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Package readtools executes deterministic, read-only graph tools through the
|
||||
// shared Tool Registry boundary.
|
||||
package readtools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/ai/runtime/retrievers"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
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 {
|
||||
return aitooling.Definition{}, "", fmt.Errorf("tool is not a graph read tool")
|
||||
}
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(toolCode)
|
||||
if err != nil {
|
||||
return aitooling.Definition{}, "", err
|
||||
}
|
||||
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
|
||||
Definition: definition,
|
||||
Arguments: arguments,
|
||||
Policy: policy,
|
||||
}); err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
if definition.TimeoutMS > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
data, err := json.Marshal(arguments)
|
||||
if err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
switch toolCode {
|
||||
case toolx.GraphTriageServiceRequest.Code:
|
||||
result, err := graphs.NewTriageServiceRequestGraph(conversation).Run(ctx, string(data))
|
||||
return definition, result, err
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// RetrieveKnowledge executes the built-in knowledge tool after the same
|
||||
// registry policy and timeout checks used by graph tools.
|
||||
func RetrieveKnowledge(ctx context.Context, agent models.AIAgent, knowledgeBaseIDs []int64, query string, policy aitooling.Policy) (aitooling.Definition, *retrievers.KnowledgeRetrieveResult, error) {
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(toolx.BuiltinKnowledgeRetrieve.Code)
|
||||
if err != nil {
|
||||
return aitooling.Definition{}, nil, err
|
||||
}
|
||||
arguments := map[string]any{"query": strings.TrimSpace(query), "knowledgeBaseIds": knowledgeBaseIDs}
|
||||
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
|
||||
Definition: definition,
|
||||
Arguments: arguments,
|
||||
Policy: policy,
|
||||
}); err != nil {
|
||||
return definition, nil, err
|
||||
}
|
||||
if definition.TimeoutMS > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
result, err := retrievers.NewKnowledgeRetriever(agent, knowledgeBaseIDs).RetrieveContext(ctx, strings.TrimSpace(query))
|
||||
return definition, result, err
|
||||
}
|
||||
26
internal/ai/runtime/readtools/graph_executor_test.go
Normal file
26
internal/ai/runtime/readtools/graph_executor_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package readtools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
func TestExecuteGraphToolRejectsDisallowedToolBeforeGraphExecution(t *testing.T) {
|
||||
definition, _, err := ExecuteGraphTool(context.Background(), models.Conversation{}, toolx.GraphAnalyzeConversation.Code, map[string]any{
|
||||
"observedIssue": "需要分析的问题",
|
||||
}, aitooling.Policy{
|
||||
AllowedToolCodes: []string{toolx.GraphPrepareTicketDraft.Code},
|
||||
AllowedRiskLevels: []string{aitooling.RiskLevelRead},
|
||||
Confirmed: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected policy guard to reject the graph tool")
|
||||
}
|
||||
if definition.Code != toolx.GraphAnalyzeConversation.Code {
|
||||
t.Fatalf("definition code = %q, want %q", definition.Code, toolx.GraphAnalyzeConversation.Code)
|
||||
}
|
||||
}
|
||||
103
internal/ai/runtime/registry/registry.go
Normal file
103
internal/ai/runtime/registry/registry.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
type Registry struct {
|
||||
tools []Tool
|
||||
}
|
||||
|
||||
func NewRegistry(tools ...Tool) *Registry {
|
||||
return &Registry{
|
||||
tools: tools,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Resolve(ctx Context) (*ToolSet, error) {
|
||||
ret := &ToolSet{
|
||||
StaticTools: make([]einotool.BaseTool, 0, len(r.tools)),
|
||||
StaticToolCodes: make(map[string]string),
|
||||
StaticToolMetadata: make(map[string]ToolMetadata),
|
||||
}
|
||||
allowedToolCodes := makeAllowedToolCodeSet(ctx.AllowedToolCodes)
|
||||
for _, toolDef := range r.tools {
|
||||
if toolDef == nil || !toolDef.Enabled(ctx) {
|
||||
continue
|
||||
}
|
||||
spec := toolDef.Spec()
|
||||
toolCode := strings.TrimSpace(spec.Code)
|
||||
if toolCode == "" {
|
||||
toolCode = strings.TrimSpace(toolDef.Code())
|
||||
}
|
||||
if len(allowedToolCodes) > 0 && !isAllowedToolCode(toolCode, allowedToolCodes) {
|
||||
continue
|
||||
}
|
||||
tool, err := toolDef.Build(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
toolName := strings.TrimSpace(spec.Name)
|
||||
if toolName == "" {
|
||||
toolName = strings.TrimSpace(toolDef.Name())
|
||||
}
|
||||
if toolName == "" || toolCode == "" {
|
||||
continue
|
||||
}
|
||||
ret.StaticTools = append(ret.StaticTools, tool)
|
||||
ret.StaticToolCodes[toolName] = toolCode
|
||||
resolvedMetadata := toolx.ResolveToolMetadata(toolCode, toolName)
|
||||
if strings.TrimSpace(resolvedMetadata.ServerCode) == "" {
|
||||
resolvedMetadata.ServerCode = strings.TrimSpace(spec.ServerCode)
|
||||
}
|
||||
if strings.TrimSpace(resolvedMetadata.ToolName) == "" {
|
||||
resolvedMetadata.ToolName = toolName
|
||||
}
|
||||
if resolvedMetadata.SourceType == "" {
|
||||
resolvedMetadata.SourceType = spec.SourceType
|
||||
}
|
||||
ret.StaticToolMetadata[toolName] = ToolMetadata{
|
||||
ToolCode: resolvedMetadata.ToolCode,
|
||||
ServerCode: resolvedMetadata.ServerCode,
|
||||
ToolName: resolvedMetadata.ToolName,
|
||||
SourceType: resolvedMetadata.SourceType,
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func isAllowedToolCode(toolCode string, allowedToolCodes map[string]struct{}) bool {
|
||||
if len(allowedToolCodes) == 0 {
|
||||
return true
|
||||
}
|
||||
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
|
||||
if _, ok := allowedToolCodes[toolCode]; ok {
|
||||
return true
|
||||
}
|
||||
if toolx.IsAlwaysAllowedToolCode(toolCode) {
|
||||
return true
|
||||
}
|
||||
return toolx.IsImpliedAllowedToolCode(toolCode, allowedToolCodes)
|
||||
}
|
||||
|
||||
func makeAllowedToolCodeSet(input []string) map[string]struct{} {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make(map[string]struct{}, len(input))
|
||||
for _, item := range input {
|
||||
item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
ret[item] = struct{}{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
80
internal/ai/runtime/registry/registry_test.go
Normal file
80
internal/ai/runtime/registry/registry_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package registry_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type stubTool struct {
|
||||
name string
|
||||
code string
|
||||
}
|
||||
|
||||
func (t stubTool) Spec() toolx.ToolSpec {
|
||||
return toolx.ToolSpec{
|
||||
Code: t.code,
|
||||
Name: t.name,
|
||||
ServerCode: toolx.GraphCreateTicketConfirm.ServerCode,
|
||||
SourceType: toolx.GraphCreateTicketConfirm.SourceType,
|
||||
}
|
||||
}
|
||||
|
||||
func (t stubTool) Name() string { return t.name }
|
||||
func (t stubTool) Code() string { return t.code }
|
||||
func (t stubTool) Enabled(registry.Context) bool {
|
||||
return true
|
||||
}
|
||||
func (t stubTool) Build(registry.Context) (einotool.BaseTool, error) {
|
||||
return stubBaseTool{name: t.name}, nil
|
||||
}
|
||||
|
||||
type stubBaseTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (t stubBaseTool) Info(context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{Name: t.name}, nil
|
||||
}
|
||||
|
||||
func TestResolveBuildsStaticToolMetadata(t *testing.T) {
|
||||
r := registry.NewRegistry(stubTool{
|
||||
name: toolx.GraphCreateTicketConfirm.Name,
|
||||
code: toolx.GraphCreateTicketConfirm.Code,
|
||||
})
|
||||
toolSet, err := r.Resolve(registry.Context{
|
||||
Conversation: models.Conversation{ID: 1},
|
||||
AIAgent: models.AIAgent{ID: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve returned error: %v", err)
|
||||
}
|
||||
if toolSet == nil {
|
||||
t.Fatalf("expected tool set")
|
||||
}
|
||||
if len(toolSet.StaticToolMetadata) != 1 {
|
||||
t.Fatalf("expected 1 metadata item, got %d", len(toolSet.StaticToolMetadata))
|
||||
}
|
||||
item, ok := toolSet.StaticToolMetadata[toolx.GraphCreateTicketConfirm.Name]
|
||||
if !ok {
|
||||
t.Fatalf("missing metadata for %s", toolx.GraphCreateTicketConfirm.Name)
|
||||
}
|
||||
if item.ToolCode != toolx.GraphCreateTicketConfirm.Code {
|
||||
t.Fatalf("unexpected tool code: %s", item.ToolCode)
|
||||
}
|
||||
if item.ServerCode != toolx.GraphCreateTicketConfirm.ServerCode {
|
||||
t.Fatalf("unexpected server code: %s", item.ServerCode)
|
||||
}
|
||||
if item.ToolName != toolx.GraphCreateTicketConfirm.Name {
|
||||
t.Fatalf("unexpected tool name: %s", item.ToolName)
|
||||
}
|
||||
if item.SourceType != toolx.GraphCreateTicketConfirm.SourceType {
|
||||
t.Fatalf("unexpected source type: %s", item.SourceType)
|
||||
}
|
||||
}
|
||||
44
internal/ai/runtime/registry/types.go
Normal file
44
internal/ai/runtime/registry/types.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
Conversation models.Conversation
|
||||
AIAgent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
UserMessage models.Message
|
||||
AllowedToolCodes []string
|
||||
}
|
||||
|
||||
type ToolMetadata struct {
|
||||
ToolCode string
|
||||
ServerCode string
|
||||
ToolName string
|
||||
SourceType enums.ToolSourceType
|
||||
}
|
||||
|
||||
// ToolSet 描述当前运行时可直接挂载到 ToolsNode 上的固定工具集合。
|
||||
//
|
||||
// 这里不承载通过 tool_search 动态暴露的 MCP 工具;动态工具仍由 engine 层单独装配。
|
||||
type ToolSet struct {
|
||||
// StaticTools 为固定可见工具实例,例如 Graph Tool。
|
||||
StaticTools []einotool.BaseTool
|
||||
// StaticToolCodes 为固定工具的 modelName -> toolCode 映射,用于 trace 和运行日志归因。
|
||||
StaticToolCodes map[string]string
|
||||
// StaticToolMetadata 为固定工具的 modelName -> metadata 映射,用于 trace、运行日志和后续装配。
|
||||
StaticToolMetadata map[string]ToolMetadata
|
||||
}
|
||||
|
||||
type Tool interface {
|
||||
Spec() toolx.ToolSpec
|
||||
Name() string
|
||||
Code() string
|
||||
Enabled(ctx Context) bool
|
||||
Build(ctx Context) (einotool.BaseTool, error)
|
||||
}
|
||||
84
internal/ai/runtime/reply_commit_service.go
Normal file
84
internal/ai/runtime/reply_commit_service.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type replyCommitService struct{}
|
||||
|
||||
type replyCommitInput struct {
|
||||
Conversation models.Conversation
|
||||
Message models.Message
|
||||
AIAgent models.AIAgent
|
||||
ReplyText string
|
||||
ClientPrefix string
|
||||
WorkflowRunID int64
|
||||
IncrementRound bool
|
||||
}
|
||||
|
||||
func newReplyCommitService() *replyCommitService {
|
||||
return &replyCommitService{}
|
||||
}
|
||||
|
||||
func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Message, error) {
|
||||
replyText, err := aitooling.NormalizeCustomerReply(input.ReplyText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID(
|
||||
input.Conversation.ID,
|
||||
input.AIAgent.ID,
|
||||
fmt.Sprintf("%s_%d", strings.TrimSpace(input.ClientPrefix), input.Message.ID),
|
||||
enums.IMMessageTypeText,
|
||||
replyText,
|
||||
"",
|
||||
s.buildAIPrincipal(input.AIAgent),
|
||||
input.Message.RequestID,
|
||||
input.WorkflowRunID,
|
||||
)
|
||||
if err != nil || !input.IncrementRound {
|
||||
return replyMessage, err
|
||||
}
|
||||
if err := s.IncrementAIReplyRounds(input.Conversation.ID, input.Conversation.AIReplyRounds+1, input.AIAgent.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return replyMessage, err
|
||||
}
|
||||
|
||||
func (s *replyCommitService) CommitAIReply(input replyCommitInput) (*models.Message, error) {
|
||||
input.IncrementRound = true
|
||||
return s.SendAIReply(input)
|
||||
}
|
||||
|
||||
func (s *replyCommitService) IncrementAIReplyRounds(conversationID int64, nextRounds int, aiAgentName string) error {
|
||||
return repositories.ConversationRepository.Updates(sqls.DB(), conversationID, map[string]any{
|
||||
"ai_reply_rounds": nextRounds,
|
||||
"update_user_id": 0,
|
||||
"update_user_name": strings.TrimSpace(aiAgentName),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *replyCommitService) buildAIPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal {
|
||||
username := "AI"
|
||||
if strings.TrimSpace(aiAgent.Name) != "" {
|
||||
username = aiAgent.Name
|
||||
}
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: username,
|
||||
Nickname: username,
|
||||
}
|
||||
}
|
||||
140
internal/ai/runtime/reply_commit_service_test.go
Normal file
140
internal/ai/runtime/reply_commit_service_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestReplyCommitStoresWorkflowRunIDOnAIMessage(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,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CommitAIReply() error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyCommitRejectsSensitiveModelOutput(t *testing.T) {
|
||||
db := setupReplyCommitTestDB(t)
|
||||
aiAgent := createReplyCommitTestAIAgent(t, db)
|
||||
conversation := createReplyCommitTestConversation(t, db, aiAgent.ID)
|
||||
_, err := newReplyCommitService().CommitAIReply(replyCommitInput{
|
||||
Conversation: *conversation, Message: models.Message{ID: 102, RequestID: "trace-102"}, AIAgent: *aiAgent,
|
||||
ReplyText: "authorization=Bearer-secret", ClientPrefix: "ai_reply",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected sensitive model output to be rejected")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count messages: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("unexpected message written for rejected output: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func setupReplyCommitTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbName := "reply_commit_test_" + strings.NewReplacer("/", "_").Replace(t.Name())
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sqlite db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Fatalf("close sqlite db: %v", err)
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(
|
||||
&models.AIAgent{},
|
||||
&models.Channel{},
|
||||
&models.ChannelMessageOutbox{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationReadState{},
|
||||
&models.ConversationEventLog{},
|
||||
&models.Message{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
|
||||
func createReplyCommitTestAIAgent(t *testing.T, db *gorm.DB) *models.AIAgent {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
item := &models.AIAgent{
|
||||
Name: "reply-agent",
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatalf("create ai agent: %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func createReplyCommitTestConversation(t *testing.T, db *gorm.DB, aiAgentID int64) *models.Conversation {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
item := &models.Conversation{
|
||||
CustomerID: 1,
|
||||
ChannelID: 11,
|
||||
AIAgentID: aiAgentID,
|
||||
Status: enums.IMConversationStatusAIServing,
|
||||
LastActiveAt: now,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
20
internal/ai/runtime/reply_context.go
Normal file
20
internal/ai/runtime/reply_context.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/models"
|
||||
)
|
||||
|
||||
type aiReplyContext struct {
|
||||
Conversation models.Conversation
|
||||
Message models.Message
|
||||
AIAgent models.AIAgent
|
||||
SummaryRef **applicationruntime.RunResult
|
||||
PendingInterrupt *models.ConversationInterrupt
|
||||
}
|
||||
|
||||
func (c aiReplyContext) setSummary(summary *applicationruntime.RunResult) {
|
||||
if c.SummaryRef != nil {
|
||||
*c.SummaryRef = summary
|
||||
}
|
||||
}
|
||||
64
internal/ai/runtime/reply_eligibility.go
Normal file
64
internal/ai/runtime/reply_eligibility.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
type replyEligibility struct{}
|
||||
|
||||
func newReplyEligibility() *replyEligibility {
|
||||
return &replyEligibility{}
|
||||
}
|
||||
|
||||
// IsAIAgentRolloutEligible uses a stable conversation bucket so one customer
|
||||
// remains consistently inside or outside a gray release throughout a session.
|
||||
// Missing legacy values are treated as 100 to preserve existing behavior.
|
||||
func IsAIAgentRolloutEligible(conversation models.Conversation, aiAgent models.AIAgent, channel *models.Channel) bool {
|
||||
percent := normalizedRolloutPercent(aiAgent.RolloutPercent)
|
||||
if channel != nil {
|
||||
channelPercent := normalizedRolloutPercent(channel.AIAgentRolloutPercent)
|
||||
if channelPercent < percent {
|
||||
percent = channelPercent
|
||||
}
|
||||
}
|
||||
if percent >= 100 {
|
||||
return true
|
||||
}
|
||||
if conversation.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
seed := fmt.Sprintf("channel=%d;conversation=%d;agent=%d", conversation.ChannelID, conversation.ID, aiAgent.ID)
|
||||
sum := sha256.Sum256([]byte(seed))
|
||||
bucket := int(binary.BigEndian.Uint64(sum[:8]) % 100)
|
||||
return bucket < percent
|
||||
}
|
||||
|
||||
func normalizedRolloutPercent(percent int) int {
|
||||
if percent <= 0 || percent > 100 {
|
||||
return 100
|
||||
}
|
||||
return percent
|
||||
}
|
||||
|
||||
func (e *replyEligibility) CanReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent) bool {
|
||||
if message.SenderType != enums.IMSenderTypeCustomer {
|
||||
return false
|
||||
}
|
||||
if conversation.CurrentAssigneeID > 0 {
|
||||
return false
|
||||
}
|
||||
if aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly {
|
||||
return false
|
||||
}
|
||||
if strs.IsBlank(message.Content) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
64
internal/ai/runtime/reply_helpers_test.go
Normal file
64
internal/ai/runtime/reply_helpers_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/models"
|
||||
)
|
||||
|
||||
func TestExtractInterruptMessageAndCheckpointError(t *testing.T) {
|
||||
if got := extractInterruptMessage(`{"message":"请补充订单号"}`); got != "请补充订单号" {
|
||||
t.Fatalf("unexpected interrupt message: %q", got)
|
||||
}
|
||||
if got := extractInterruptMessage("not-json"); got != "" {
|
||||
t.Fatalf("expected empty message for invalid json, got %q", got)
|
||||
}
|
||||
|
||||
err := fakeErr("Failed to load from checkpoint: record does not exist")
|
||||
if !isCheckpointMissingError(err) {
|
||||
t.Fatalf("expected checkpoint missing error to be detected")
|
||||
}
|
||||
if isCheckpointMissingError(fakeErr("other error")) {
|
||||
t.Fatalf("expected unrelated error to be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConversationInterruptStoresWorkflowCheckpointData(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":"请确认"}`},
|
||||
},
|
||||
})
|
||||
if item == nil {
|
||||
t.Fatalf("expected interrupt item")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeErr string
|
||||
|
||||
func (e fakeErr) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func testConversation(id int64) models.Conversation {
|
||||
return models.Conversation{ID: id}
|
||||
}
|
||||
|
||||
func testMessage(id int64) models.Message {
|
||||
return models.Message{ID: id}
|
||||
}
|
||||
|
||||
func testAIAgent(id int64) models.AIAgent {
|
||||
return models.AIAgent{ID: id}
|
||||
}
|
||||
114
internal/ai/runtime/reply_interrupt_helpers.go
Normal file
114
internal/ai/runtime/reply_interrupt_helpers.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
svc "agent-desk/internal/services"
|
||||
)
|
||||
|
||||
type interruptMessagePreview struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func buildConversationInterrupt(conversation models.Conversation, message models.Message, aiAgent models.AIAgent, summary *applicationruntime.RunResult) *models.ConversationInterrupt {
|
||||
if summary == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
item := svc.ConversationInterruptService.GetByCheckPointID(summary.CheckPointID)
|
||||
if item == nil {
|
||||
item = &models.ConversationInterrupt{
|
||||
CheckPointID: summary.CheckPointID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
item.ConversationID = conversation.ID
|
||||
item.AIAgentID = aiAgent.ID
|
||||
item.AgentRunID = summary.AgentRunID
|
||||
item.SourceMessageID = message.ID
|
||||
item.InterruptID = firstInterruptID(summary)
|
||||
item.InterruptType = firstInterruptType(summary)
|
||||
item.WorkflowRunID = summary.WorkflowRunID
|
||||
item.WorkflowNodeID = firstInterruptID(summary)
|
||||
item.Status = "pending"
|
||||
locale := svc.ConversationHumanDispatchService.ResolveConversationLocale(conversation.ID)
|
||||
item.PromptText = resolveInterruptPrompt(summary, locale)
|
||||
item.RequestData = strings.TrimSpace(summary.CheckPointData)
|
||||
item.UpdatedAt = now
|
||||
return item
|
||||
}
|
||||
|
||||
func resolveInterruptPrompt(summary *applicationruntime.RunResult, locale ...string) string {
|
||||
targetLocale := i18nx.DefaultLocale
|
||||
if len(locale) > 0 && strings.TrimSpace(locale[0]) != "" {
|
||||
targetLocale = locale[0]
|
||||
}
|
||||
if summary == nil || len(summary.Interrupts) == 0 {
|
||||
return i18nx.Getf(targetLocale, "conversation.interrupt.defaultPrompt")
|
||||
}
|
||||
interrupt := summary.Interrupts[0]
|
||||
if prompt := strings.TrimSpace(interrupt.PromptText); prompt != "" {
|
||||
return prompt
|
||||
}
|
||||
if prompt := extractInterruptMessage(interrupt.InfoPreview); prompt != "" {
|
||||
return prompt
|
||||
}
|
||||
if prompt := strings.TrimSpace(summary.ReplyText); prompt != "" {
|
||||
return prompt
|
||||
}
|
||||
if interrupt.Type != "tool_confirmation" {
|
||||
if prompt := strings.TrimSpace(interrupt.InfoPreview); prompt != "" {
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
if displayName := strings.TrimSpace(interrupt.DisplayName); displayName != "" {
|
||||
switch targetLocale {
|
||||
case "ru-RU":
|
||||
return "Готовится выполнение «" + displayName + "». Подтверждаете продолжение?"
|
||||
case "en-US":
|
||||
return "About to execute \"" + displayName + "\". Confirm to continue?"
|
||||
default:
|
||||
return "即将执行“" + displayName + "”,是否确认继续?"
|
||||
}
|
||||
}
|
||||
return i18nx.Getf(targetLocale, "conversation.interrupt.defaultPrompt")
|
||||
}
|
||||
|
||||
func extractInterruptMessage(infoPreview string) string {
|
||||
infoPreview = strings.TrimSpace(infoPreview)
|
||||
if infoPreview == "" {
|
||||
return ""
|
||||
}
|
||||
var payload interruptMessagePreview
|
||||
if err := json.Unmarshal([]byte(infoPreview), &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(payload.Message)
|
||||
}
|
||||
|
||||
func firstInterruptID(summary *applicationruntime.RunResult) string {
|
||||
if summary == nil || len(summary.Interrupts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary.Interrupts[0].ID)
|
||||
}
|
||||
|
||||
func firstInterruptType(summary *applicationruntime.RunResult) string {
|
||||
if summary == nil || len(summary.Interrupts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary.Interrupts[0].Type)
|
||||
}
|
||||
|
||||
func isCheckpointMissingError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(message, "failed to load from checkpoint") && strings.Contains(message, "not exist")
|
||||
}
|
||||
132
internal/ai/runtime/reply_interrupt_service.go
Normal file
132
internal/ai/runtime/reply_interrupt_service.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
svc "agent-desk/internal/services"
|
||||
)
|
||||
|
||||
type replyInterruptService struct{}
|
||||
|
||||
func newReplyInterruptService() *replyInterruptService {
|
||||
return &replyInterruptService{}
|
||||
}
|
||||
|
||||
func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owner *aiReplyService, replyCtx aiReplyContext) error {
|
||||
if replyCtx.PendingInterrupt == nil {
|
||||
return fmt.Errorf("pending interrupt is required")
|
||||
}
|
||||
summary, err := owner.executor.ResumePendingInterrupt(ctx, runtimeReplyResumeInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
PendingInterrupt: replyCtx.PendingInterrupt,
|
||||
})
|
||||
replyCtx.setSummary(summary)
|
||||
if err != nil {
|
||||
if isCheckpointMissingError(err) {
|
||||
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,
|
||||
})
|
||||
if expireErr != nil {
|
||||
return expireErr
|
||||
}
|
||||
lastResumeMessageID := int64(0)
|
||||
if replyMessage != nil {
|
||||
lastResumeMessageID = replyMessage.ID
|
||||
}
|
||||
if expireMarkErr := svc.ConversationInterruptService.MarkExpired(replyCtx.PendingInterrupt.ID, lastResumeMessageID); expireMarkErr != nil {
|
||||
return expireMarkErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if summary != nil && summary.Interrupted {
|
||||
return s.HandleInterruptedResume(owner, replyCtx, summary)
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replyMessageID := int64(0)
|
||||
if replyMessage != nil {
|
||||
replyMessageID = replyMessage.ID
|
||||
}
|
||||
if graphs.IsCancellationReply(summary.ReplyText) {
|
||||
return svc.ConversationInterruptService.MarkCancelled(replyCtx.PendingInterrupt.ID, replyMessageID)
|
||||
}
|
||||
return svc.ConversationInterruptService.MarkResolved(replyCtx.PendingInterrupt.ID, replyMessageID)
|
||||
}
|
||||
return svc.ConversationInterruptService.MarkResolved(replyCtx.PendingInterrupt.ID, 0)
|
||||
}
|
||||
|
||||
func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.RunResult) error {
|
||||
pending := buildConversationInterrupt(replyCtx.Conversation, replyCtx.Message, replyCtx.AIAgent, summary)
|
||||
if pending != nil && pending.AgentRunID > 0 {
|
||||
pending.AgentStepID = svc.AgentRunService.GetLatestStepID(pending.AgentRunID)
|
||||
}
|
||||
if err := svc.ConversationInterruptService.CreateOrUpdatePending(pending); err != nil {
|
||||
return err
|
||||
}
|
||||
pending = svc.ConversationInterruptService.GetByCheckPointID(summary.CheckPointID)
|
||||
locale := svc.ConversationHumanDispatchService.ResolveConversationLocale(replyCtx.Conversation.ID)
|
||||
replyText := resolveInterruptPrompt(summary, locale)
|
||||
replyMessage, err := owner.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: replyText,
|
||||
ClientPrefix: "ai_interrupt",
|
||||
WorkflowRunID: summary.WorkflowRunID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if replyMessage != nil && pending != nil {
|
||||
return svc.ConversationInterruptService.MarkPendingAgain(pending.ID, pending.InterruptID, replyText, replyMessage.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *replyInterruptService) HandleInterruptedResume(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.RunResult) error {
|
||||
if replyCtx.PendingInterrupt == nil {
|
||||
return fmt.Errorf("pending interrupt is required")
|
||||
}
|
||||
locale := svc.ConversationHumanDispatchService.ResolveConversationLocale(replyCtx.Conversation.ID)
|
||||
replyText := resolveInterruptPrompt(summary, locale)
|
||||
replyMessage, err := owner.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
ReplyText: replyText,
|
||||
ClientPrefix: "ai_interrupt_resume",
|
||||
WorkflowRunID: summary.WorkflowRunID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if replyMessage != nil {
|
||||
return svc.ConversationInterruptService.MarkPendingAgain(replyCtx.PendingInterrupt.ID, firstInterruptID(summary), replyText, replyMessage.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
50
internal/ai/runtime/reply_service.go
Normal file
50
internal/ai/runtime/reply_service.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
svc "agent-desk/internal/services"
|
||||
)
|
||||
|
||||
var AIReplyService = newAIReplyService()
|
||||
|
||||
func init() {
|
||||
svc.TriggerAIReplyAsyncHook = AIReplyService.TriggerReplyAsync
|
||||
}
|
||||
|
||||
type replyExecutor interface {
|
||||
Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error)
|
||||
ResumePendingInterrupt(ctx context.Context, input runtimeReplyResumeInput) (*applicationruntime.RunResult, error)
|
||||
}
|
||||
|
||||
func newAIReplyService() *aiReplyService {
|
||||
return newAIReplyServiceWithExecutor(newRuntimeReplyExecutor())
|
||||
}
|
||||
|
||||
func newAIReplyServiceWithExecutor(executor replyExecutor) *aiReplyService {
|
||||
return &aiReplyService{
|
||||
eligibility: newReplyEligibility(),
|
||||
executor: executor,
|
||||
interrupts: newReplyInterruptService(),
|
||||
commit: newReplyCommitService(),
|
||||
}
|
||||
}
|
||||
|
||||
type aiReplyService struct {
|
||||
eligibility *replyEligibility
|
||||
executor replyExecutor
|
||||
interrupts *replyInterruptService
|
||||
commit *replyCommitService
|
||||
}
|
||||
|
||||
func firstInvokedToolCode(summary *applicationruntime.RunResult) string {
|
||||
if summary == nil {
|
||||
return ""
|
||||
}
|
||||
if len(summary.InvokedToolCodes) > 0 {
|
||||
return strings.TrimSpace(summary.InvokedToolCodes[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
145
internal/ai/runtime/reply_service_test.go
Normal file
145
internal/ai/runtime/reply_service_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
func TestReplyEligibilityCanReply(t *testing.T) {
|
||||
eligibility := newReplyEligibility()
|
||||
conversation := newConversationFixture()
|
||||
message := newCustomerMessageFixture("hello")
|
||||
aiAgent := newAIAgentFixture()
|
||||
|
||||
if !eligibility.CanReply(conversation, message, aiAgent) {
|
||||
t.Fatalf("expected customer message to be replyable")
|
||||
}
|
||||
|
||||
message.SenderType = enums.IMSenderTypeAgent
|
||||
if eligibility.CanReply(conversation, message, aiAgent) {
|
||||
t.Fatalf("expected non-customer message to be rejected")
|
||||
}
|
||||
|
||||
message = newCustomerMessageFixture("hello")
|
||||
conversation.HandoffAt = ptrTime(time.Now())
|
||||
conversation.CurrentAssigneeID = 0
|
||||
if !eligibility.CanReply(conversation, message, aiAgent) {
|
||||
t.Fatalf("expected unassigned handed-off conversation to still be replyable by AI")
|
||||
}
|
||||
|
||||
conversation = newConversationFixture()
|
||||
conversation.CurrentAssigneeID = 1
|
||||
if eligibility.CanReply(conversation, message, aiAgent) {
|
||||
t.Fatalf("expected assigned conversation to be rejected")
|
||||
}
|
||||
|
||||
conversation = newConversationFixture()
|
||||
aiAgent.ServiceMode = enums.IMConversationServiceModeHumanOnly
|
||||
if eligibility.CanReply(conversation, message, aiAgent) {
|
||||
t.Fatalf("expected human-only agent to be rejected")
|
||||
}
|
||||
|
||||
aiAgent = newAIAgentFixture()
|
||||
message.Content = " "
|
||||
if eligibility.CanReply(conversation, message, aiAgent) {
|
||||
t.Fatalf("expected blank message to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentRolloutUsesStableConversationBucket(t *testing.T) {
|
||||
conversation := models.Conversation{ID: 101, ChannelID: 7}
|
||||
agent := models.AIAgent{ID: 9, RolloutPercent: 50}
|
||||
first := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100})
|
||||
for range 20 {
|
||||
if got := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100}); got != first {
|
||||
t.Fatalf("rollout bucket changed within one conversation: first=%t got=%t", first, got)
|
||||
}
|
||||
}
|
||||
if normalizedRolloutPercent(0) != 100 || normalizedRolloutPercent(101) != 100 || normalizedRolloutPercent(25) != 25 {
|
||||
t.Fatal("unexpected rollout percent normalization")
|
||||
}
|
||||
if !IsAIAgentRolloutEligible(conversation, models.AIAgent{ID: 9, RolloutPercent: 0}, &models.Channel{}) {
|
||||
t.Fatal("legacy zero rollout values must preserve full rollout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReplyTimeout(t *testing.T) {
|
||||
service := newAIReplyService()
|
||||
aiAgent := newAIAgentFixture()
|
||||
|
||||
if got := service.resolveReplyTimeout(aiAgent); got != 180*time.Second {
|
||||
t.Fatalf("expected default timeout, got %v", got)
|
||||
}
|
||||
|
||||
aiAgent.ReplyTimeoutSeconds = 30
|
||||
if got := service.resolveReplyTimeout(aiAgent); got != 30*time.Second {
|
||||
t.Fatalf("expected exact timeout, got %v", got)
|
||||
}
|
||||
|
||||
aiAgent.ReplyTimeoutSeconds = 999
|
||||
if got := service.resolveReplyTimeout(aiAgent); got != 600*time.Second {
|
||||
t.Fatalf("expected clamped timeout, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInterruptPrompt(t *testing.T) {
|
||||
summary := &applicationruntime.RunResult{
|
||||
Interrupts: []applicationruntime.InterruptContextSummary{
|
||||
{
|
||||
ID: "interrupt-1",
|
||||
Type: "question",
|
||||
InfoPreview: `{"message":"请补充订单号"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := resolveInterruptPrompt(summary); got != "请补充订单号" {
|
||||
t.Fatalf("unexpected interrupt prompt: %q", got)
|
||||
}
|
||||
|
||||
summary.Interrupts[0].InfoPreview = "直接补充手机号"
|
||||
if got := resolveInterruptPrompt(summary); got != "直接补充手机号" {
|
||||
t.Fatalf("unexpected raw interrupt prompt: %q", got)
|
||||
}
|
||||
|
||||
summary.ReplyText = ""
|
||||
summary.Interrupts[0] = applicationruntime.InterruptContextSummary{
|
||||
ID: "system/server_time",
|
||||
Type: "tool_confirmation",
|
||||
DisplayName: "获取当前时间",
|
||||
InfoPreview: "system/server_time",
|
||||
}
|
||||
if got := resolveInterruptPrompt(summary, "zh-CN"); got != "即将执行“获取当前时间”,是否确认继续?" {
|
||||
t.Fatalf("tool code leaked into customer prompt: %q", got)
|
||||
}
|
||||
if got := resolveInterruptPrompt(summary, "en-US"); got != "About to execute \"获取当前时间\". Confirm to continue?" {
|
||||
t.Fatalf("expected en-US prompt, got: %q", got)
|
||||
}
|
||||
|
||||
summary.Interrupts[0].PromptText = "请确认是否更新客户资料。"
|
||||
if got := resolveInterruptPrompt(summary); got != "请确认是否更新客户资料。" {
|
||||
t.Fatalf("explicit customer prompt was not preferred: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newConversationFixture() models.Conversation {
|
||||
return models.Conversation{}
|
||||
}
|
||||
|
||||
func newCustomerMessageFixture(content string) models.Message {
|
||||
return models.Message{
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
func newAIAgentFixture() models.AIAgent {
|
||||
return models.AIAgent{}
|
||||
}
|
||||
|
||||
func ptrTime(v time.Time) *time.Time {
|
||||
return &v
|
||||
}
|
||||
144
internal/ai/runtime/reply_trigger_service.go
Normal file
144
internal/ai/runtime/reply_trigger_service.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/tracex"
|
||||
svc "agent-desk/internal/services"
|
||||
)
|
||||
|
||||
func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration {
|
||||
if aiAgent.ReplyTimeoutSeconds <= 0 {
|
||||
return time.Duration(defaultAIReplyAsyncTimeoutSeconds) * time.Second
|
||||
}
|
||||
if aiAgent.ReplyTimeoutSeconds > maxAIReplyAsyncTimeoutSeconds {
|
||||
return time.Duration(maxAIReplyAsyncTimeoutSeconds) * time.Second
|
||||
}
|
||||
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
|
||||
}
|
||||
startedAt := time.Now()
|
||||
timeout := s.resolveReplyTimeout(*aiAgent)
|
||||
ctx, cancel := context.WithTimeout(tracex.ContextWithRequestID(context.Background(), message.RequestID), timeout)
|
||||
defer cancel()
|
||||
if err := s.TriggerReply(ctx, conversation, message, *aiAgent); err != nil {
|
||||
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", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent) (retErr error) {
|
||||
var summary *applicationruntime.RunResult
|
||||
replyCtx := aiReplyContext{
|
||||
Conversation: conversation,
|
||||
Message: message,
|
||||
AIAgent: aiAgent,
|
||||
SummaryRef: &summary,
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) {
|
||||
return nil
|
||||
}
|
||||
if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) {
|
||||
return nil
|
||||
}
|
||||
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
|
||||
replyCtx.PendingInterrupt = pendingInterrupt
|
||||
return s.resumePendingInterrupt(ctx, replyCtx)
|
||||
}
|
||||
return s.executeReply(ctx, replyCtx)
|
||||
}
|
||||
|
||||
func (s *aiReplyService) resumePendingInterrupt(ctx context.Context, replyCtx aiReplyContext) error {
|
||||
return s.interrupts.ResumePendingInterrupt(ctx, s, replyCtx)
|
||||
}
|
||||
|
||||
func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyContext) error {
|
||||
summary, err := s.executor.Run(ctx, runtimeReplyRunInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
Message: replyCtx.Message,
|
||||
AIAgent: replyCtx.AIAgent,
|
||||
})
|
||||
replyCtx.setSummary(summary)
|
||||
if err != nil {
|
||||
if replyCtx.AIAgent.AutoHandoffOnFailure {
|
||||
if _, handoffErr := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
||||
replyCtx.Conversation.ID,
|
||||
replyCtx.AIAgent,
|
||||
"AI execution failure: "+err.Error(),
|
||||
replyCtx.Message.RequestID,
|
||||
); handoffErr != nil {
|
||||
slog.Error("failed to auto-handoff on AI execution failure",
|
||||
"conversation_id", replyCtx.Conversation.ID,
|
||||
"ai_agent_id", replyCtx.AIAgent.ID,
|
||||
"error", handoffErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if summary != nil && summary.Interrupted {
|
||||
return s.interrupts.HandleInterruptedSummary(s, replyCtx, summary)
|
||||
}
|
||||
if summary != nil && summary.HandoffRequested {
|
||||
if _, err := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
||||
replyCtx.Conversation.ID,
|
||||
replyCtx.AIAgent,
|
||||
summary.HandoffReason,
|
||||
replyCtx.Message.RequestID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if replyCtx.AIAgent.AutoHandoffOnFailure {
|
||||
if _, handoffErr := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
||||
replyCtx.Conversation.ID,
|
||||
replyCtx.AIAgent,
|
||||
"AI generated empty reply or fallback unavailable",
|
||||
replyCtx.Message.RequestID,
|
||||
); handoffErr != nil {
|
||||
slog.Error("failed to auto-handoff on empty AI reply",
|
||||
"conversation_id", replyCtx.Conversation.ID,
|
||||
"ai_agent_id", replyCtx.AIAgent.ID,
|
||||
"error", handoffErr,
|
||||
)
|
||||
return handoffErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
342
internal/ai/runtime/reply_trigger_service_test.go
Normal file
342
internal/ai/runtime/reply_trigger_service_test.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
type mockReplyExecutor struct {
|
||||
runFunc func(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error)
|
||||
resumeFunc func(ctx context.Context, input runtimeReplyResumeInput) (*applicationruntime.RunResult, error)
|
||||
}
|
||||
|
||||
func (m *mockReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error) {
|
||||
if m.runFunc != nil {
|
||||
return m.runFunc(ctx, input)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockReplyExecutor) ResumePendingInterrupt(ctx context.Context, input runtimeReplyResumeInput) (*applicationruntime.RunResult, error) {
|
||||
if m.resumeFunc != nil {
|
||||
return m.resumeFunc(ctx, input)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func setupReplyTriggerTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbName := "reply_trigger_test_" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sqlite db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
if err := db.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Customer{},
|
||||
&models.CustomerIdentity{},
|
||||
&models.AIAgent{},
|
||||
&models.AgentTeam{},
|
||||
&models.AgentTeamSchedule{},
|
||||
&models.AgentProfile{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationParticipant{},
|
||||
&models.ConversationAssignment{},
|
||||
&models.ConversationEventLog{},
|
||||
&models.ConversationReadState{},
|
||||
&models.ConversationInterrupt{},
|
||||
&models.Message{},
|
||||
&models.Channel{},
|
||||
&models.ChannelMessageOutbox{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
|
||||
func createReplyTriggerTestAIAgent(t *testing.T, db *gorm.DB, autoHandoff bool, handoffMsg string) models.AIAgent {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
item := models.AIAgent{
|
||||
Name: "TriggerAI",
|
||||
Status: enums.StatusOk,
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
AutoHandoffOnFailure: autoHandoff,
|
||||
HandoffMessage: handoffMsg,
|
||||
TeamIDs: "1",
|
||||
RolloutPercent: 100,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create ai agent: %v", err)
|
||||
}
|
||||
if !autoHandoff {
|
||||
if err := db.Model(&item).Update("auto_handoff_on_failure", false).Error; err != nil {
|
||||
t.Fatalf("update auto_handoff_on_failure: %v", err)
|
||||
}
|
||||
item.AutoHandoffOnFailure = false
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func createReplyTriggerTestTeam(t *testing.T, db *gorm.DB, teamID int64) {
|
||||
t.Helper()
|
||||
if err := db.Create(&models.AgentTeam{ID: teamID, Name: "支持组", Status: enums.StatusOk}).Error; err != nil {
|
||||
t.Fatalf("create team: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := db.Create(&models.AgentTeamSchedule{
|
||||
TeamID: teamID,
|
||||
StartAt: now.Add(-time.Hour),
|
||||
EndAt: now.Add(time.Hour),
|
||||
Status: enums.StatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create schedule: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createReplyTriggerTestConversation(t *testing.T, db *gorm.DB, aiAgentID int64) models.Conversation {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
item := models.Conversation{
|
||||
CustomerID: 1,
|
||||
ChannelID: 1,
|
||||
AIAgentID: aiAgentID,
|
||||
Status: enums.IMConversationStatusAIServing,
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
LastActiveAt: now,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func createCustomerTestMessage(t *testing.T, db *gorm.DB, conversationID int64, content string) models.Message {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
item := models.Message{
|
||||
ConversationID: conversationID,
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
SenderID: 1,
|
||||
Content: content,
|
||||
RequestID: "req-trigger-test",
|
||||
SendStatus: enums.IMMessageStatusSent,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func TestAIReplyService_AutoHandoffOnExecutionFailure(t *testing.T) {
|
||||
db := setupReplyTriggerTestDB(t)
|
||||
createReplyTriggerTestTeam(t, db, 1)
|
||||
aiAgent := createReplyTriggerTestAIAgent(t, db, true, "AI故障,正在为您转接人工客服...")
|
||||
conversation := createReplyTriggerTestConversation(t, db, aiAgent.ID)
|
||||
message := createCustomerTestMessage(t, db, conversation.ID, "帮助我")
|
||||
|
||||
mock := &mockReplyExecutor{
|
||||
runFunc: func(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error) {
|
||||
return nil, fmt.Errorf("llm connection timeout")
|
||||
},
|
||||
}
|
||||
|
||||
service := newAIReplyServiceWithExecutor(mock)
|
||||
err := service.TriggerReply(context.Background(), conversation, message, aiAgent)
|
||||
if err == nil || !strings.Contains(err.Error(), "llm connection timeout") {
|
||||
t.Fatalf("expected llm timeout error, got %v", err)
|
||||
}
|
||||
|
||||
currentConv := svc.ConversationService.Get(conversation.ID)
|
||||
if currentConv.Status != enums.IMConversationStatusPending {
|
||||
t.Fatalf("expected conversation status pending after handoff, got %v", currentConv.Status)
|
||||
}
|
||||
if currentConv.HandoffAt == nil {
|
||||
t.Fatalf("expected handoffAt to be set")
|
||||
}
|
||||
if !strings.Contains(currentConv.HandoffReason, "AI execution failure: llm connection timeout") {
|
||||
t.Fatalf("unexpected handoff reason: %q", currentConv.HandoffReason)
|
||||
}
|
||||
|
||||
sentMsg := svc.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id"))
|
||||
if sentMsg == nil {
|
||||
t.Fatalf("expected customer notice message to be sent")
|
||||
}
|
||||
if sentMsg.Content != "AI故障,正在为您转接人工客服..." {
|
||||
t.Fatalf("unexpected notice message content: %q", sentMsg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIReplyService_AutoHandoffDisabledOnExecutionFailure(t *testing.T) {
|
||||
db := setupReplyTriggerTestDB(t)
|
||||
createReplyTriggerTestTeam(t, db, 1)
|
||||
aiAgent := createReplyTriggerTestAIAgent(t, db, false, "")
|
||||
conversation := createReplyTriggerTestConversation(t, db, aiAgent.ID)
|
||||
message := createCustomerTestMessage(t, db, conversation.ID, "帮助我")
|
||||
|
||||
mock := &mockReplyExecutor{
|
||||
runFunc: func(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error) {
|
||||
return nil, fmt.Errorf("llm connection timeout")
|
||||
},
|
||||
}
|
||||
|
||||
service := newAIReplyServiceWithExecutor(mock)
|
||||
err := service.TriggerReply(context.Background(), conversation, message, aiAgent)
|
||||
if err == nil || !strings.Contains(err.Error(), "llm connection timeout") {
|
||||
t.Fatalf("expected llm timeout error, got %v", err)
|
||||
}
|
||||
|
||||
currentConv := svc.ConversationService.Get(conversation.ID)
|
||||
if currentConv.Status != enums.IMConversationStatusAIServing {
|
||||
t.Fatalf("expected conversation to stay AI serving, got %v", currentConv.Status)
|
||||
}
|
||||
if currentConv.HandoffAt != nil {
|
||||
t.Fatalf("expected handoffAt to stay nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIReplyService_AutoHandoffOnEmptyReply(t *testing.T) {
|
||||
db := setupReplyTriggerTestDB(t)
|
||||
createReplyTriggerTestTeam(t, db, 1)
|
||||
aiAgent := createReplyTriggerTestAIAgent(t, db, true, "AI无法解答,已为您转接人工客服")
|
||||
conversation := createReplyTriggerTestConversation(t, db, aiAgent.ID)
|
||||
message := createCustomerTestMessage(t, db, conversation.ID, "复杂未知问题")
|
||||
|
||||
mock := &mockReplyExecutor{
|
||||
runFunc: func(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error) {
|
||||
return &applicationruntime.RunResult{
|
||||
ReplyText: " ",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
service := newAIReplyServiceWithExecutor(mock)
|
||||
err := service.TriggerReply(context.Background(), conversation, message, aiAgent)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error on successful empty reply auto-handoff, got %v", err)
|
||||
}
|
||||
|
||||
currentConv := svc.ConversationService.Get(conversation.ID)
|
||||
if currentConv.Status != enums.IMConversationStatusPending {
|
||||
t.Fatalf("expected conversation status pending after handoff, got %v", currentConv.Status)
|
||||
}
|
||||
if currentConv.HandoffAt == nil {
|
||||
t.Fatalf("expected handoffAt to be set")
|
||||
}
|
||||
if !strings.Contains(currentConv.HandoffReason, "AI generated empty reply or fallback unavailable") {
|
||||
t.Fatalf("unexpected handoff reason: %q", currentConv.HandoffReason)
|
||||
}
|
||||
|
||||
sentMsg := svc.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id"))
|
||||
if sentMsg == nil {
|
||||
t.Fatalf("expected customer notice message to be sent")
|
||||
}
|
||||
if sentMsg.Content != "AI无法解答,已为您转接人工客服" {
|
||||
t.Fatalf("unexpected notice message content: %q", sentMsg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIReplyService_AutoHandoffDisabledOnEmptyReply(t *testing.T) {
|
||||
db := setupReplyTriggerTestDB(t)
|
||||
createReplyTriggerTestTeam(t, db, 1)
|
||||
aiAgent := createReplyTriggerTestAIAgent(t, db, false, "")
|
||||
conversation := createReplyTriggerTestConversation(t, db, aiAgent.ID)
|
||||
message := createCustomerTestMessage(t, db, conversation.ID, "复杂未知问题")
|
||||
|
||||
mock := &mockReplyExecutor{
|
||||
runFunc: func(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error) {
|
||||
return &applicationruntime.RunResult{
|
||||
ReplyText: "",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
service := newAIReplyServiceWithExecutor(mock)
|
||||
err := service.TriggerReply(context.Background(), conversation, message, aiAgent)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
currentConv := svc.ConversationService.Get(conversation.ID)
|
||||
if currentConv.Status != enums.IMConversationStatusAIServing {
|
||||
t.Fatalf("expected conversation to stay AI serving, got %v", currentConv.Status)
|
||||
}
|
||||
if currentConv.HandoffAt != nil {
|
||||
t.Fatalf("expected handoffAt to stay nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIReplyService_NormalReplyCommitted(t *testing.T) {
|
||||
db := setupReplyTriggerTestDB(t)
|
||||
aiAgent := createReplyTriggerTestAIAgent(t, db, true, "")
|
||||
conversation := createReplyTriggerTestConversation(t, db, aiAgent.ID)
|
||||
message := createCustomerTestMessage(t, db, conversation.ID, "你好")
|
||||
|
||||
mock := &mockReplyExecutor{
|
||||
runFunc: func(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error) {
|
||||
return &applicationruntime.RunResult{
|
||||
ReplyText: "您好,有什么可以帮您?",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
service := newAIReplyServiceWithExecutor(mock)
|
||||
err := service.TriggerReply(context.Background(), conversation, message, aiAgent)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error on normal reply, got %v", err)
|
||||
}
|
||||
|
||||
currentConv := svc.ConversationService.Get(conversation.ID)
|
||||
if currentConv.Status != enums.IMConversationStatusAIServing {
|
||||
t.Fatalf("expected conversation to stay AI serving, got %v", currentConv.Status)
|
||||
}
|
||||
if currentConv.HandoffAt != nil {
|
||||
t.Fatalf("expected handoffAt to stay nil")
|
||||
}
|
||||
|
||||
sentMsg := svc.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id"))
|
||||
if sentMsg == nil {
|
||||
t.Fatalf("expected reply message to be sent")
|
||||
}
|
||||
if sentMsg.Content != "您好,有什么可以帮您?" {
|
||||
t.Fatalf("unexpected reply content: %q", sentMsg.Content)
|
||||
}
|
||||
}
|
||||
6
internal/ai/runtime/reply_types.go
Normal file
6
internal/ai/runtime/reply_types.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package runtime
|
||||
|
||||
const (
|
||||
defaultAIReplyAsyncTimeoutSeconds = 180
|
||||
maxAIReplyAsyncTimeoutSeconds = 600
|
||||
)
|
||||
296
internal/ai/runtime/retrievers/knowledge_retriever.go
Normal file
296
internal/ai/runtime/retrievers/knowledge_retriever.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package retrievers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/rag"
|
||||
"agent-desk/internal/ai/runtime/traces"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
const defaultRuntimeKnowledgeContextTokens = 4000
|
||||
const defaultRuntimeKnowledgeTopK = 8
|
||||
const defaultRuntimeKnowledgeScoreThreshold = 0.3
|
||||
const defaultRuntimeKnowledgeMaxContextItems = 5
|
||||
|
||||
type KnowledgeRetriever struct {
|
||||
AIAgent models.AIAgent
|
||||
knowledgeBaseIDs []int64
|
||||
}
|
||||
|
||||
type KnowledgeRetrieveOptions struct {
|
||||
ContextMaxTokens int
|
||||
MaxContextItems int
|
||||
TopK int
|
||||
ScoreThreshold float64
|
||||
QueryPreview string
|
||||
}
|
||||
|
||||
type KnowledgeBaseRetrievePolicy struct {
|
||||
KnowledgeBaseID int64
|
||||
TopK int
|
||||
ScoreThreshold float64
|
||||
}
|
||||
|
||||
type KnowledgeRetrieveResult struct {
|
||||
KnowledgeBaseIDs []int64
|
||||
Query string
|
||||
Options KnowledgeRetrieveOptions
|
||||
Hits []rag.RetrieveResult
|
||||
ContextResults []rag.RetrieveResult
|
||||
ContextText string
|
||||
TopScore float64
|
||||
AnswerMode enums.KnowledgeAnswerMode
|
||||
Trace *rag.RetrieveTrace
|
||||
TraceItems []traces.RetrieverTraceItem
|
||||
TraceSummary traces.RetrieverTraceSummary
|
||||
Policies []KnowledgeBaseRetrievePolicy
|
||||
}
|
||||
|
||||
func NewKnowledgeRetriever(aiAgent models.AIAgent, knowledgeBaseIDs []int64) *KnowledgeRetriever {
|
||||
return &KnowledgeRetriever{
|
||||
AIAgent: aiAgent,
|
||||
knowledgeBaseIDs: append([]int64(nil), knowledgeBaseIDs...),
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultKnowledgeRetrieveOptions() KnowledgeRetrieveOptions {
|
||||
return KnowledgeRetrieveOptions{
|
||||
ContextMaxTokens: defaultRuntimeKnowledgeContextTokens,
|
||||
MaxContextItems: defaultRuntimeKnowledgeMaxContextItems,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *KnowledgeRetriever) ConfiguredKnowledgeBaseIDs() []int64 {
|
||||
return append([]int64(nil), r.knowledgeBaseIDs...)
|
||||
}
|
||||
|
||||
func (r *KnowledgeRetriever) Retrieve(ctx context.Context, query string) ([]rag.RetrieveResult, *rag.RetrieveTrace, error) {
|
||||
return r.RetrieveByOptions(ctx, DefaultKnowledgeRetrieveOptions(), query)
|
||||
}
|
||||
|
||||
func (r *KnowledgeRetriever) RetrieveByOptions(ctx context.Context, opts KnowledgeRetrieveOptions, query string) ([]rag.RetrieveResult, *rag.RetrieveTrace, error) {
|
||||
ids := r.ConfiguredKnowledgeBaseIDs()
|
||||
return rag.Retrieve.RetrieveWithTrace(ctx, rag.RetrieveRequest{
|
||||
Query: query,
|
||||
KnowledgeBaseIDs: ids,
|
||||
TopK: opts.TopK,
|
||||
ScoreThreshold: opts.ScoreThreshold,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *KnowledgeRetriever) RetrieveContext(ctx context.Context, query string) (*KnowledgeRetrieveResult, error) {
|
||||
return r.RetrieveContextByOptions(ctx, DefaultKnowledgeRetrieveOptions(), query)
|
||||
}
|
||||
|
||||
func (r *KnowledgeRetriever) RetrieveContextByOptions(ctx context.Context, opts KnowledgeRetrieveOptions, query string) (*KnowledgeRetrieveResult, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
knowledgeBaseIDs := r.ConfiguredKnowledgeBaseIDs()
|
||||
policies := r.resolvePolicies(knowledgeBaseIDs, opts)
|
||||
contextMaxTokens := opts.ContextMaxTokens
|
||||
if contextMaxTokens <= 0 {
|
||||
contextMaxTokens = defaultRuntimeKnowledgeContextTokens
|
||||
}
|
||||
maxContextItems := opts.MaxContextItems
|
||||
if maxContextItems <= 0 {
|
||||
maxContextItems = defaultRuntimeKnowledgeMaxContextItems
|
||||
}
|
||||
queryPreview := strings.TrimSpace(opts.QueryPreview)
|
||||
if queryPreview == "" {
|
||||
queryPreview = query
|
||||
}
|
||||
ret := &KnowledgeRetrieveResult{
|
||||
KnowledgeBaseIDs: append([]int64(nil), knowledgeBaseIDs...),
|
||||
Query: query,
|
||||
Options: KnowledgeRetrieveOptions{
|
||||
ContextMaxTokens: contextMaxTokens,
|
||||
MaxContextItems: maxContextItems,
|
||||
TopK: opts.TopK,
|
||||
ScoreThreshold: opts.ScoreThreshold,
|
||||
QueryPreview: queryPreview,
|
||||
},
|
||||
Policies: append([]KnowledgeBaseRetrievePolicy(nil), policies...),
|
||||
}
|
||||
if query == "" || len(knowledgeBaseIDs) == 0 {
|
||||
return ret, nil
|
||||
}
|
||||
results, trace, err := r.RetrieveByOptions(ctx, opts, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.Hits = append([]rag.RetrieveResult(nil), results...)
|
||||
ret.Trace = trace
|
||||
ret.ContextResults = rag.Retrieve.SelectContextResults(results, contextMaxTokens)
|
||||
ret.ContextResults = limitContextResults(ret.ContextResults, maxContextItems)
|
||||
ret.ContextText = strings.TrimSpace(buildContextText(ret.ContextResults))
|
||||
ret.TopScore = resolveTopScore(results)
|
||||
ret.AnswerMode = resolveRuntimeAnswerMode(knowledgeBaseIDs, results)
|
||||
ret.TraceItems = buildRetrieverTraceItems(queryPreview, results, trace)
|
||||
ret.TraceSummary = buildRetrieverTraceSummary(ret.Options, ret.Policies, ret.ContextResults, results, trace)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func limitContextResults(results []rag.RetrieveResult, maxItems int) []rag.RetrieveResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
if maxItems <= 0 || len(results) <= maxItems {
|
||||
return append([]rag.RetrieveResult(nil), results...)
|
||||
}
|
||||
return append([]rag.RetrieveResult(nil), results[:maxItems]...)
|
||||
}
|
||||
|
||||
func buildContextText(results []rag.RetrieveResult) string {
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(rag.Retrieve.BuildContext(context.Background(), results, 1<<30))
|
||||
}
|
||||
|
||||
func resolveTopScore(results []rag.RetrieveResult) float64 {
|
||||
if len(results) == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(results[0].Score)
|
||||
}
|
||||
|
||||
func (r *KnowledgeRetriever) resolvePolicies(knowledgeBaseIDs []int64, opts KnowledgeRetrieveOptions) []KnowledgeBaseRetrievePolicy {
|
||||
if len(knowledgeBaseIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
knowledgeBases := loadRuntimeKnowledgeBases(knowledgeBaseIDs)
|
||||
ret := make([]KnowledgeBaseRetrievePolicy, 0, len(knowledgeBaseIDs))
|
||||
for _, knowledgeBaseID := range knowledgeBaseIDs {
|
||||
policy := KnowledgeBaseRetrievePolicy{
|
||||
KnowledgeBaseID: knowledgeBaseID,
|
||||
TopK: defaultRuntimeKnowledgeTopK,
|
||||
ScoreThreshold: defaultRuntimeKnowledgeScoreThreshold,
|
||||
}
|
||||
if knowledgeBase, ok := knowledgeBases[knowledgeBaseID]; ok {
|
||||
if knowledgeBase.DefaultTopK > 0 {
|
||||
policy.TopK = knowledgeBase.DefaultTopK
|
||||
}
|
||||
if knowledgeBase.DefaultScoreThreshold > 0 {
|
||||
policy.ScoreThreshold = knowledgeBase.DefaultScoreThreshold
|
||||
}
|
||||
}
|
||||
if opts.TopK > 0 {
|
||||
policy.TopK = opts.TopK
|
||||
}
|
||||
if opts.ScoreThreshold > 0 {
|
||||
policy.ScoreThreshold = opts.ScoreThreshold
|
||||
}
|
||||
ret = append(ret, policy)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func resolveRuntimeAnswerMode(knowledgeBaseIDs []int64, results []rag.RetrieveResult) enums.KnowledgeAnswerMode {
|
||||
knowledgeBases := loadRuntimeKnowledgeBases(knowledgeBaseIDs)
|
||||
if len(knowledgeBases) == 0 {
|
||||
return enums.KnowledgeAnswerModeStrict
|
||||
}
|
||||
if len(results) > 0 {
|
||||
if knowledgeBase, ok := knowledgeBases[results[0].KnowledgeBaseID]; ok {
|
||||
return normalizeRuntimeAnswerMode(knowledgeBase)
|
||||
}
|
||||
}
|
||||
for _, knowledgeBaseID := range knowledgeBaseIDs {
|
||||
if knowledgeBase, ok := knowledgeBases[knowledgeBaseID]; ok {
|
||||
return normalizeRuntimeAnswerMode(knowledgeBase)
|
||||
}
|
||||
}
|
||||
return enums.KnowledgeAnswerModeStrict
|
||||
}
|
||||
|
||||
func normalizeRuntimeAnswerMode(knowledgeBase models.KnowledgeBase) enums.KnowledgeAnswerMode {
|
||||
answerMode := enums.KnowledgeAnswerMode(knowledgeBase.AnswerMode)
|
||||
if answerMode == 0 {
|
||||
answerMode = enums.KnowledgeAnswerModeStrict
|
||||
}
|
||||
return answerMode
|
||||
}
|
||||
|
||||
func loadRuntimeKnowledgeBases(ids []int64) map[int64]models.KnowledgeBase {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := repositories.KnowledgeBaseRepository.Find(sqls.DB(), sqls.NewCnd().In("id", ids))
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make(map[int64]models.KnowledgeBase, len(items))
|
||||
for _, item := range items {
|
||||
if item.Status != enums.StatusOk {
|
||||
continue
|
||||
}
|
||||
ret[item.ID] = item
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func buildRetrieverTraceItems(queryPreview string, results []rag.RetrieveResult, trace *rag.RetrieveTrace) []traces.RetrieverTraceItem {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
latencyMs := int64(0)
|
||||
if trace != nil {
|
||||
latencyMs = trace.EmbeddingMs + trace.VectorSearchMs + trace.HydrateMs
|
||||
}
|
||||
ret := make([]traces.RetrieverTraceItem, 0, len(results))
|
||||
for _, item := range results {
|
||||
ret = append(ret, traces.RetrieverTraceItem{
|
||||
Query: queryPreview,
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
DocumentID: item.DocumentID,
|
||||
DocumentTitle: item.DocumentTitle,
|
||||
Score: float64(item.Score),
|
||||
LatencyMs: latencyMs,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func buildRetrieverTraceSummary(opts KnowledgeRetrieveOptions, policies []KnowledgeBaseRetrievePolicy, contextResults []rag.RetrieveResult, results []rag.RetrieveResult, trace *rag.RetrieveTrace) traces.RetrieverTraceSummary {
|
||||
ret := traces.RetrieverTraceSummary{
|
||||
TopK: opts.TopK,
|
||||
ScoreThreshold: opts.ScoreThreshold,
|
||||
ContextMaxTokens: opts.ContextMaxTokens,
|
||||
MaxContextItems: opts.MaxContextItems,
|
||||
HitCount: len(results),
|
||||
ContextCount: len(contextResults),
|
||||
Policies: buildRetrieverPolicyTraceItems(policies),
|
||||
}
|
||||
if ret.TopK <= 0 && len(policies) == 1 {
|
||||
ret.TopK = policies[0].TopK
|
||||
}
|
||||
if ret.ScoreThreshold <= 0 && len(policies) == 1 {
|
||||
ret.ScoreThreshold = policies[0].ScoreThreshold
|
||||
}
|
||||
if trace != nil {
|
||||
ret.EmbeddingMs = trace.EmbeddingMs
|
||||
ret.VectorSearchMs = trace.VectorSearchMs
|
||||
ret.HydrateMs = trace.HydrateMs
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func buildRetrieverPolicyTraceItems(policies []KnowledgeBaseRetrievePolicy) []traces.RetrieverPolicyTraceItem {
|
||||
if len(policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make([]traces.RetrieverPolicyTraceItem, 0, len(policies))
|
||||
for _, item := range policies {
|
||||
ret = append(ret, traces.RetrieverPolicyTraceItem{
|
||||
KnowledgeBaseID: item.KnowledgeBaseID,
|
||||
TopK: item.TopK,
|
||||
ScoreThreshold: item.ScoreThreshold,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
64
internal/ai/runtime/runtime_reply_executor.go
Normal file
64
internal/ai/runtime/runtime_reply_executor.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/models"
|
||||
)
|
||||
|
||||
type runtimeReplyExecutor struct{}
|
||||
|
||||
type runtimeReplyRunInput struct {
|
||||
Conversation models.Conversation
|
||||
Message models.Message
|
||||
AIAgent models.AIAgent
|
||||
}
|
||||
|
||||
type runtimeReplyResumeInput struct {
|
||||
Conversation models.Conversation
|
||||
Message models.Message
|
||||
AIAgent models.AIAgent
|
||||
PendingInterrupt *models.ConversationInterrupt
|
||||
}
|
||||
|
||||
func newRuntimeReplyExecutor() *runtimeReplyExecutor {
|
||||
return &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,
|
||||
})
|
||||
return summary, err
|
||||
}
|
||||
|
||||
func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input runtimeReplyResumeInput) (*applicationruntime.RunResult, error) {
|
||||
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,
|
||||
},
|
||||
CheckPointID: strings.TrimSpace(input.PendingInterrupt.CheckPointID),
|
||||
ResumeData: map[string]string{
|
||||
strings.TrimSpace(input.PendingInterrupt.InterruptID): strings.TrimSpace(input.Message.Content),
|
||||
},
|
||||
})
|
||||
return summary, err
|
||||
}
|
||||
|
||||
func expiredInterruptSummary() *applicationruntime.RunResult {
|
||||
return &applicationruntime.RunResult{
|
||||
Status: "expired",
|
||||
ReplyText: graphs.ConfirmationExpiredReply,
|
||||
}
|
||||
}
|
||||
27
internal/ai/runtime/service.go
Normal file
27
internal/ai/runtime/service.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
)
|
||||
|
||||
var Service = newService()
|
||||
|
||||
func newService() *service {
|
||||
return &service{
|
||||
app: applicationruntime.NewService(),
|
||||
}
|
||||
}
|
||||
|
||||
type service struct {
|
||||
app *applicationruntime.Service
|
||||
}
|
||||
|
||||
func (s *service) Run(ctx context.Context, req applicationruntime.RunInput) (*applicationruntime.RunResult, error) {
|
||||
return s.app.Run(ctx, req)
|
||||
}
|
||||
|
||||
func (s *service) Resume(ctx context.Context, req applicationruntime.ResumeInput) (*applicationruntime.RunResult, error) {
|
||||
return s.app.Resume(ctx, req)
|
||||
}
|
||||
34
internal/ai/runtime/tooling/mcp_tool_definition.go
Normal file
34
internal/ai/runtime/tooling/mcp_tool_definition.go
Normal file
@@ -0,0 +1,34 @@
|
||||
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)
|
||||
}
|
||||
42
internal/ai/runtime/tooling/tool_result.go
Normal file
42
internal/ai/runtime/tooling/tool_result.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package tooling
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func MarshalToolResult(result ToolResult) string {
|
||||
result.Action = strings.TrimSpace(result.Action)
|
||||
result.ReplyText = strings.TrimSpace(result.ReplyText)
|
||||
buf, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func ParseToolResult(raw string) (ToolResult, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ToolResult{}, false
|
||||
}
|
||||
var result ToolResult
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return ToolResult{}, false
|
||||
}
|
||||
result.Action = strings.TrimSpace(result.Action)
|
||||
result.ReplyText = strings.TrimSpace(result.ReplyText)
|
||||
if result.Action == "" && result.ReplyText == "" && !result.Handled && !result.Terminal {
|
||||
return ToolResult{}, false
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
124
internal/ai/runtime/tooling/tool_result_reducer.go
Normal file
124
internal/ai/runtime/tooling/tool_result_reducer.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package tooling
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/mcps"
|
||||
)
|
||||
|
||||
const (
|
||||
maxToolResultSummaryChars = 2500
|
||||
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)
|
||||
}
|
||||
99
internal/ai/runtime/tooling/tool_result_reducer_test.go
Normal file
99
internal/ai/runtime/tooling/tool_result_reducer_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package tooling
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReducedToolResultSummaryTruncatesAt2500Runes(t *testing.T) {
|
||||
longText := strings.Repeat("x", 3000)
|
||||
result := &mcps.ToolCallResult{
|
||||
Content: []mcps.ToolResultContent{{
|
||||
Type: "text",
|
||||
Text: longText,
|
||||
}},
|
||||
}
|
||||
|
||||
summary := BuildReducedToolResultSummary(result)
|
||||
expectedMarker := "[tool result reduced: original_length=3000, kept_length=2500]"
|
||||
if !strings.Contains(summary, expectedMarker) {
|
||||
t.Fatalf("expected reduction marker %q in summary, got: %q", expectedMarker, summary)
|
||||
}
|
||||
|
||||
info := ParseReductionInfo(summary)
|
||||
if !info.Reduced || info.OriginalChars != 3000 || info.KeptChars != 2500 {
|
||||
t.Fatalf("unexpected ReductionInfo: %+v", info)
|
||||
}
|
||||
|
||||
// Exactly 2500 runes should not be reduced
|
||||
exactText := strings.Repeat("y", 2500)
|
||||
exactResult := &mcps.ToolCallResult{
|
||||
Content: []mcps.ToolResultContent{{
|
||||
Type: "text",
|
||||
Text: exactText,
|
||||
}},
|
||||
}
|
||||
exactSummary := BuildReducedToolResultSummary(exactResult)
|
||||
if strings.Contains(exactSummary, "[tool result reduced:") {
|
||||
t.Fatalf("2500-rune payload should not be reduced, got: %q", exactSummary)
|
||||
}
|
||||
if exactSummary != exactText {
|
||||
t.Fatalf("expected exact text, got length %d", len(exactSummary))
|
||||
}
|
||||
|
||||
// Multibyte runes test
|
||||
chineseText := strings.Repeat("测", 2600)
|
||||
chineseResult := &mcps.ToolCallResult{
|
||||
Content: []mcps.ToolResultContent{{
|
||||
Type: "text",
|
||||
Text: chineseText,
|
||||
}},
|
||||
}
|
||||
chineseSummary := BuildReducedToolResultSummary(chineseResult)
|
||||
expectedChineseMarker := "[tool result reduced: original_length=2600, kept_length=2500]"
|
||||
if !strings.Contains(chineseSummary, expectedChineseMarker) {
|
||||
t.Fatalf("expected multibyte reduction marker %q, got: %q", expectedChineseMarker, chineseSummary)
|
||||
}
|
||||
chineseInfo := ParseReductionInfo(chineseSummary)
|
||||
if !chineseInfo.Reduced || chineseInfo.OriginalChars != 2600 || chineseInfo.KeptChars != 2500 {
|
||||
t.Fatalf("unexpected chinese ReductionInfo: %+v", chineseInfo)
|
||||
}
|
||||
}
|
||||
110
internal/ai/runtime/tools/analyze_conversation_tool.go
Normal file
110
internal/ai/runtime/tools/analyze_conversation_tool.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/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 AnalyzeConversationTool struct {
|
||||
conversation models.Conversation
|
||||
}
|
||||
|
||||
func NewAnalyzeConversationTool() *AnalyzeConversationTool {
|
||||
return &AnalyzeConversationTool{}
|
||||
}
|
||||
|
||||
func (t *AnalyzeConversationTool) Spec() toolx.ToolSpec {
|
||||
return toolx.GraphAnalyzeConversation
|
||||
}
|
||||
|
||||
func (t *AnalyzeConversationTool) Name() string {
|
||||
return toolx.GraphAnalyzeConversation.Name
|
||||
}
|
||||
|
||||
func (t *AnalyzeConversationTool) Code() string {
|
||||
return toolx.GraphAnalyzeConversation.Code
|
||||
}
|
||||
|
||||
func (t *AnalyzeConversationTool) Enabled(ctx registry.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *AnalyzeConversationTool) Build(ctx registry.Context) (einotool.BaseTool, error) {
|
||||
if !t.Enabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
return &AnalyzeConversationTool{conversation: ctx.Conversation}, nil
|
||||
}
|
||||
|
||||
func (t *AnalyzeConversationTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: toolx.GraphAnalyzeConversation.Name,
|
||||
Desc: i18nx.Get("tool.graph.analyzeConversation.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: "goal",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.analyzeConversation.param.goal"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "observedIssue",
|
||||
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",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "boolean",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.needHumanHandoff"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "needQualityCheck",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "boolean",
|
||||
Description: i18nx.Get("tool.graph.analyzeConversation.param.needQualityCheck"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "additionalContext",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.analyzeConversation.param.additionalContext"),
|
||||
},
|
||||
},
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": toolx.GraphAnalyzeConversation.Code,
|
||||
"sourceType": toolx.GraphAnalyzeConversation.SourceType,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *AnalyzeConversationTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
|
||||
return graphs.NewAnalyzeConversationGraph(t.conversation).Run(ctx, argumentsInJSON)
|
||||
}
|
||||
90
internal/ai/runtime/tools/create_ticket_confirm_tool.go
Normal file
90
internal/ai/runtime/tools/create_ticket_confirm_tool.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/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)
|
||||
}
|
||||
79
internal/ai/runtime/tools/handoff_graph_tool.go
Normal file
79
internal/ai/runtime/tools/handoff_graph_tool.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/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 HandoffGraphTool struct {
|
||||
conversation models.Conversation
|
||||
aiAgent models.AIAgent
|
||||
}
|
||||
|
||||
func NewHandoffGraphTool() *HandoffGraphTool {
|
||||
return &HandoffGraphTool{}
|
||||
}
|
||||
|
||||
func (t *HandoffGraphTool) Spec() toolx.ToolSpec {
|
||||
return toolx.GraphHandoffConversation
|
||||
}
|
||||
|
||||
func (t *HandoffGraphTool) Name() string {
|
||||
return toolx.GraphHandoffConversation.Name
|
||||
}
|
||||
|
||||
func (t *HandoffGraphTool) Code() string {
|
||||
return toolx.GraphHandoffConversation.Code
|
||||
}
|
||||
|
||||
func (t *HandoffGraphTool) Enabled(ctx registry.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *HandoffGraphTool) Build(ctx registry.Context) (einotool.BaseTool, error) {
|
||||
if !t.Enabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
return &HandoffGraphTool{
|
||||
conversation: ctx.Conversation,
|
||||
aiAgent: ctx.AIAgent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *HandoffGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: toolx.GraphHandoffConversation.Name,
|
||||
Desc: i18nx.Get("tool.graph.handoffConversation.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: "reason",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.handoffConversation.param.reason"),
|
||||
},
|
||||
},
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": toolx.GraphHandoffConversation.Code,
|
||||
"sourceType": toolx.GraphHandoffConversation.SourceType,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *HandoffGraphTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
|
||||
return graphs.NewHandoffGraph(t.conversation, t.aiAgent).Run(ctx, argumentsInJSON)
|
||||
}
|
||||
52
internal/ai/runtime/tools/helper.go
Normal file
52
internal/ai/runtime/tools/helper.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
type Decision string
|
||||
|
||||
const (
|
||||
DecisionConfirm Decision = "confirm"
|
||||
DecisionCancel Decision = "cancel"
|
||||
)
|
||||
|
||||
func ParseConfirmationDecision(value string) Decision {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
confirmWords := []string{"确认", "是", "好的", "可以", "ok", "yes", "继续", "同意"}
|
||||
for _, item := range confirmWords {
|
||||
if strings.Contains(value, item) {
|
||||
return DecisionConfirm
|
||||
}
|
||||
}
|
||||
cancelWords := []string{"取消", "不用", "不需要", "算了", "no"}
|
||||
for _, item := range cancelWords {
|
||||
if strings.Contains(value, item) {
|
||||
return DecisionCancel
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NewRuntimeStaticTool(toolCode string) registry.Tool {
|
||||
switch toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) {
|
||||
case toolx.GraphTriageServiceRequest.Code:
|
||||
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:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
32
internal/ai/runtime/tools/helper_test.go
Normal file
32
internal/ai/runtime/tools/helper_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
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 {
|
||||
tool := NewRuntimeStaticTool(item)
|
||||
if tool == nil {
|
||||
t.Fatalf("expected runtime static tool for %s", item)
|
||||
}
|
||||
if tool.Spec().Code != item {
|
||||
t.Fatalf("unexpected tool code for %s: %s", item, tool.Spec().Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRuntimeStaticToolReturnsNilForUnknownTool(t *testing.T) {
|
||||
if tool := NewRuntimeStaticTool("builtin/unknown_tool"); tool != nil {
|
||||
t.Fatalf("expected nil tool for unknown tool code")
|
||||
}
|
||||
}
|
||||
110
internal/ai/runtime/tools/prepare_ticket_draft_tool.go
Normal file
110
internal/ai/runtime/tools/prepare_ticket_draft_tool.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/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)
|
||||
}
|
||||
287
internal/ai/runtime/tools/tool_search_tool.go
Normal file
287
internal/ai/runtime/tools/tool_search_tool.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/mcps"
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/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: "Search and execute allowed MCP tools. Call this tool with a query to find suitable tools, or provide toolCode and arguments to execute a specific tool.",
|
||||
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
|
||||
Version: einojsonschema.Version,
|
||||
Type: "object",
|
||||
Properties: orderedmap.New[string, *einojsonschema.Schema](orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "query",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: "Query or keyword describing the desired capability or tool intent; used when searching for candidate tools.",
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "toolCode",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: "The exact tool code to invoke (e.g. mcp_server/tool_name).",
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "arguments",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "object",
|
||||
Description: "Arguments object passed to the target tool.",
|
||||
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)
|
||||
}
|
||||
32
internal/ai/runtime/tools/tool_search_tool_test.go
Normal file
32
internal/ai/runtime/tools/tool_search_tool_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
103
internal/ai/runtime/tools/triage_service_request_tool.go
Normal file
103
internal/ai/runtime/tools/triage_service_request_tool.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/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 TriageServiceRequestTool struct {
|
||||
conversation models.Conversation
|
||||
}
|
||||
|
||||
func NewTriageServiceRequestTool() *TriageServiceRequestTool {
|
||||
return &TriageServiceRequestTool{}
|
||||
}
|
||||
|
||||
func (t *TriageServiceRequestTool) Spec() toolx.ToolSpec {
|
||||
return toolx.GraphTriageServiceRequest
|
||||
}
|
||||
|
||||
func (t *TriageServiceRequestTool) Name() string {
|
||||
return toolx.GraphTriageServiceRequest.Name
|
||||
}
|
||||
|
||||
func (t *TriageServiceRequestTool) Code() string {
|
||||
return toolx.GraphTriageServiceRequest.Code
|
||||
}
|
||||
|
||||
func (t *TriageServiceRequestTool) Enabled(ctx registry.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *TriageServiceRequestTool) Build(ctx registry.Context) (einotool.BaseTool, error) {
|
||||
if !t.Enabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
return &TriageServiceRequestTool{conversation: ctx.Conversation}, nil
|
||||
}
|
||||
|
||||
func (t *TriageServiceRequestTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: toolx.GraphTriageServiceRequest.Name,
|
||||
Desc: i18nx.Get("tool.graph.triageServiceRequest.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: "goal",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.goal"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "observedIssue",
|
||||
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",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "boolean",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.needHumanHandoff"),
|
||||
},
|
||||
},
|
||||
orderedmap.Pair[string, *einojsonschema.Schema]{
|
||||
Key: "additionalContext",
|
||||
Value: &einojsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.param.additionalContext"),
|
||||
},
|
||||
},
|
||||
)),
|
||||
}),
|
||||
Extra: map[string]any{
|
||||
"toolCode": toolx.GraphTriageServiceRequest.Code,
|
||||
"sourceType": "graph",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *TriageServiceRequestTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
|
||||
return graphs.NewTriageServiceRequestGraph(t.conversation).Run(ctx, argumentsInJSON)
|
||||
}
|
||||
29
internal/ai/runtime/traces/types.go
Normal file
29
internal/ai/runtime/traces/types.go
Normal file
@@ -0,0 +1,29 @@
|
||||
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"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
}
|
||||
|
||||
type RetrieverTraceSummary struct {
|
||||
TopK int
|
||||
ScoreThreshold float64
|
||||
ContextMaxTokens int
|
||||
MaxContextItems int
|
||||
HitCount int
|
||||
ContextCount int
|
||||
EmbeddingMs int64
|
||||
VectorSearchMs int64
|
||||
HydrateMs int64
|
||||
Policies []RetrieverPolicyTraceItem
|
||||
}
|
||||
|
||||
type RetrieverPolicyTraceItem struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"`
|
||||
TopK int `json:"topK,omitempty"`
|
||||
ScoreThreshold float64 `json:"scoreThreshold,omitempty"`
|
||||
}
|
||||
1505
internal/ai/runtime/workflow/executor.go
Normal file
1505
internal/ai/runtime/workflow/executor.go
Normal file
File diff suppressed because it is too large
Load Diff
1103
internal/ai/runtime/workflow/executor_test.go
Normal file
1103
internal/ai/runtime/workflow/executor_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user