Files
ai-agent/internal/ai/runtime/reply_service.go
T

668 lines
21 KiB
Go

package runtime
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"cs-agent/internal/models"
"cs-agent/internal/pkg/dto"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/toolx"
"cs-agent/internal/repositories"
svc "cs-agent/internal/services"
"github.com/mlogclub/simple/common/strs"
"github.com/mlogclub/simple/sqls"
)
var AIReplyService = newAIReplyService()
func init() {
svc.TriggerAIReplyAsyncHook = AIReplyService.TriggerReplyAsync
}
func newAIReplyService() *aiReplyService {
return &aiReplyService{}
}
type aiReplyService struct{}
type aiReplyTraceData struct {
Status string `json:"status"`
RuntimeLatencyMs int64 `json:"runtimeLatencyMs,omitempty"`
RecheckMs int64 `json:"recheckMs,omitempty"`
CommitMs int64 `json:"commitMs,omitempty"`
FinalAction string `json:"finalAction,omitempty"`
ResumeSource string `json:"resumeSource,omitempty"`
ReplySent bool `json:"replySent,omitempty"`
ReplyMessageID int64 `json:"replyMessageId,omitempty"`
Runtime json.RawMessage `json:"runtime,omitempty"`
}
const (
defaultAIReplyAsyncTimeoutSeconds = 180
maxAIReplyAsyncTimeoutSeconds = 600
)
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(context.Background(), timeout)
defer cancel()
if err := s.TriggerReply(ctx, conversation, message, *aiAgent); err != nil {
slog.Error("failed to trigger ai reply",
"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) {
startedAt := time.Now()
trace := &aiReplyTraceData{Status: "started"}
var summary *Summary
if err := ctx.Err(); err != nil {
return err
}
if message.SenderType != enums.IMSenderTypeCustomer {
return nil
}
if conversation.HandoffAt != nil || conversation.CurrentAssigneeID > 0 {
return nil
}
if aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly {
return nil
}
if strs.IsBlank(message.Content) {
return nil
}
defer func() {
s.writeRunLog(startedAt, message, conversation, aiAgent, message.Content, retErr, trace, summary)
}()
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
return s.resumePendingInterrupt(ctx, conversation, message, aiAgent, pendingInterrupt, trace, &summary)
}
if s.shouldHandoffByQuestion(message.Content, aiAgent) {
return s.handoffConversation(conversation, aiAgent, "用户主动要求人工")
}
if aiAgent.ServiceMode != enums.IMConversationServiceModeAIOnly &&
aiAgent.MaxAIReplyRounds > 0 &&
conversation.AIReplyRounds >= aiAgent.MaxAIReplyRounds {
return s.handoffConversation(conversation, aiAgent, "达到AI最大回复轮次")
}
aiConfig := svc.AIConfigService.Get(aiAgent.AIConfigID)
if aiConfig == nil {
return fmt.Errorf("ai config is nil")
}
runtimeStartedAt := time.Now()
var err error
summary, err = Service.Run(ctx, Request{
Conversation: &conversation,
UserMessage: &message,
AIAgent: &aiAgent,
AIConfig: aiConfig,
})
trace.RuntimeLatencyMs = time.Since(runtimeStartedAt).Milliseconds()
if err != nil {
trace.Status = "runtime_error"
trace.FinalAction = "error"
if summary != nil {
trace.Runtime = json.RawMessage(summary.TraceData)
}
return err
}
trace.Status = "runtime_prepared"
trace.FinalAction = toRunLogFinalAction(summary)
if summary != nil && strings.TrimSpace(summary.TraceData) != "" {
trace.Runtime = json.RawMessage(summary.TraceData)
}
if summary != nil && summary.Interrupted {
return s.handleInterruptedSummary(conversation, message, aiAgent, summary, trace)
}
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
replyMessage, err := s.sendAIReply(conversation, message, aiAgent, summary.ReplyText, trace, "ai_reply")
if err != nil {
return err
}
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
return err
}
trace.ReplySent = replyMessage != nil
}
return nil
}
func (s *aiReplyService) resumePendingInterrupt(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent,
pendingInterrupt *models.ConversationInterrupt, trace *aiReplyTraceData, summaryRef **Summary) error {
if pendingInterrupt == nil {
return nil
}
aiConfig := svc.AIConfigService.Get(aiAgent.AIConfigID)
if aiConfig == nil {
return fmt.Errorf("ai config is nil")
}
runtimeStartedAt := time.Now()
trace.ResumeSource = "pending_interrupt"
summary, err := Service.Resume(ctx, ResumeRequest{
Conversation: &conversation,
AIAgent: &aiAgent,
AIConfig: aiConfig,
CheckPointID: strings.TrimSpace(pendingInterrupt.CheckPointID),
ResumeData: map[string]any{
strings.TrimSpace(pendingInterrupt.InterruptID): strings.TrimSpace(message.Content),
},
})
trace.RuntimeLatencyMs = time.Since(runtimeStartedAt).Milliseconds()
*summaryRef = summary
if err != nil {
if isCheckpointMissingError(err) {
summary = &Summary{
Status: "expired",
ReplyText: "本次确认已失效,请重新发起。",
}
*summaryRef = summary
trace.Status = "interrupt_expired"
trace.FinalAction = "expired"
replyMessage, expireErr := s.sendAIReply(conversation, message, aiAgent, summary.ReplyText, trace, "ai_interrupt_expired")
if expireErr != nil {
return expireErr
}
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
return err
}
lastResumeMessageID := int64(0)
if replyMessage != nil {
lastResumeMessageID = replyMessage.ID
}
if expireMarkErr := svc.ConversationInterruptService.MarkExpired(pendingInterrupt.ID, lastResumeMessageID); expireMarkErr != nil {
return expireMarkErr
}
return nil
}
trace.Status = "runtime_error"
trace.FinalAction = "error"
if summary != nil {
trace.Runtime = json.RawMessage(summary.TraceData)
}
return err
}
trace.Status = "runtime_prepared"
trace.FinalAction = toRunLogFinalAction(summary)
if summary != nil && strings.TrimSpace(summary.TraceData) != "" {
trace.Runtime = json.RawMessage(summary.TraceData)
}
if summary != nil && summary.Interrupted {
return s.handleInterruptedResume(conversation, message, aiAgent, pendingInterrupt, summary, trace)
}
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
replyMessage, err := s.sendAIReply(conversation, message, aiAgent, summary.ReplyText, trace, "ai_resume")
if err != nil {
return err
}
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
return err
}
replyMessageID := int64(0)
if replyMessage != nil {
replyMessageID = replyMessage.ID
}
if isCancellationReply(summary.ReplyText) {
return svc.ConversationInterruptService.MarkCancelled(pendingInterrupt.ID, replyMessageID)
}
return svc.ConversationInterruptService.MarkResolved(pendingInterrupt.ID, replyMessageID)
}
return svc.ConversationInterruptService.MarkResolved(pendingInterrupt.ID, 0)
}
func (s *aiReplyService) handleInterruptedSummary(conversation models.Conversation, message models.Message, aiAgent models.AIAgent,
summary *Summary, trace *aiReplyTraceData) error {
pending := buildConversationInterrupt(conversation, message, aiAgent, summary)
if err := svc.ConversationInterruptService.CreateOrUpdatePending(pending); err != nil {
return err
}
pending = svc.ConversationInterruptService.GetByCheckPointID(summary.CheckPointID)
replyText := resolveInterruptPrompt(summary)
replyMessage, err := s.sendAIReply(conversation, message, aiAgent, replyText, trace, "ai_interrupt")
if err != nil {
return err
}
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
return err
}
if replyMessage != nil && pending != nil {
return svc.ConversationInterruptService.MarkPendingAgain(pending.ID, pending.InterruptID, replyText, replyMessage.ID)
}
return nil
}
func (s *aiReplyService) handleInterruptedResume(conversation models.Conversation, message models.Message, aiAgent models.AIAgent,
pendingInterrupt *models.ConversationInterrupt, summary *Summary, trace *aiReplyTraceData) error {
if pendingInterrupt == nil {
return nil
}
replyText := resolveInterruptPrompt(summary)
replyMessage, err := s.sendAIReply(conversation, message, aiAgent, replyText, trace, "ai_interrupt_resume")
if err != nil {
return err
}
if err := s.incrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
return err
}
if replyMessage != nil {
return svc.ConversationInterruptService.MarkPendingAgain(pendingInterrupt.ID, firstInterruptID(summary), replyText, replyMessage.ID)
}
return nil
}
func (s *aiReplyService) sendAIReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent,
replyText string, trace *aiReplyTraceData, clientPrefix string) (*models.Message, error) {
replyText = strings.TrimSpace(replyText)
if replyText == "" {
return nil, nil
}
commitStartedAt := time.Now()
replyMessage, err := svc.MessageService.SendAIMessage(conversation.ID, aiAgent.ID,
fmt.Sprintf("%s_%d", strings.TrimSpace(clientPrefix), message.ID), enums.IMMessageTypeText, replyText, "", s.buildAIPrincipal(aiAgent))
if trace != nil {
trace.CommitMs = time.Since(commitStartedAt).Milliseconds()
trace.ReplySent = err == nil && replyMessage != nil
if replyMessage != nil {
trace.ReplyMessageID = replyMessage.ID
}
}
return replyMessage, err
}
func (s *aiReplyService) shouldHandoffByQuestion(question string, aiAgent models.AIAgent) bool {
if aiAgent.ServiceMode == enums.IMConversationServiceModeAIOnly {
return false
}
normalized := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(question)), " ", "")
if normalized == "" {
return false
}
keywords := []string{"转人工", "人工客服"}
for _, keyword := range keywords {
if strings.Contains(normalized, keyword) {
return true
}
}
return false
}
func (s *aiReplyService) writeRunLog(startedAt time.Time, message models.Message, conversation models.Conversation, aiAgent models.AIAgent,
question string, runErr error, trace *aiReplyTraceData, summary *Summary) {
errorMessage := ""
if runErr != nil {
errorMessage = runErr.Error()
} else if summary != nil && strings.TrimSpace(summary.ErrorMessage) != "" {
errorMessage = strings.TrimSpace(summary.ErrorMessage)
}
traceData := buildAIReplyTraceData(trace)
plannedAction, plannedToolCode, planReason := buildRunLogPlan(summary)
logItem := &models.AgentRunLog{
ConversationID: conversation.ID,
MessageID: message.ID,
AIAgentID: aiAgent.ID,
AIConfigID: aiAgent.AIConfigID,
UserMessage: strings.TrimSpace(question),
PlannedAction: plannedAction,
PlannedSkillCode: strings.TrimSpace(summaryPlannedSkillCode(summary)),
PlannedSkillName: strings.TrimSpace(summaryPlannedSkillName(summary)),
SkillRouteTrace: strings.TrimSpace(summarySkillRouteTrace(summary)),
ToolSearchTrace: extractToolSearchTrace(summary),
PlannedToolCode: plannedToolCode,
PlanReason: planReason,
InterruptType: firstInterruptType(summary),
ResumeSource: runLogResumeSource(trace),
FinalAction: toRunLogFinalAction(summary),
FinalStatus: runLogFinalStatus(summary),
ReplyText: buildRunLogReplyText(summary),
ErrorMessage: errorMessage,
LatencyMs: time.Since(startedAt).Milliseconds(),
TraceData: traceData,
CreatedAt: time.Now(),
}
if err := svc.AgentRunLogService.Create(logItem); err != nil {
slog.Warn("create agent run log failed",
"message_id", message.ID,
"conversation_id", logItem.ConversationID,
"ai_agent_id", aiAgent.ID,
"error", err)
}
}
func buildAIReplyTraceData(trace *aiReplyTraceData) string {
if trace == nil {
return ""
}
data, err := json.Marshal(trace)
if err != nil {
return ""
}
return string(data)
}
func buildRunLogPlan(summary *Summary) (plannedAction, plannedToolCode, planReason string) {
if summary == nil {
return "", "", ""
}
if skillCode := strings.TrimSpace(summaryPlannedSkillCode(summary)); skillCode != "" {
reason := strings.TrimSpace(summary.PlanReason)
if reason == "" {
reason = "skill_selected"
}
return "skill", "", reason
}
if strings.TrimSpace(summary.Status) == "expired" {
return "interrupt", "", "pending interrupt checkpoint expired"
}
if summary.Interrupted {
return "tool", summaryPrimaryToolCode(summary), "agent interrupted and is waiting for user confirmation"
}
if len(summary.InvokedToolCodes) > 0 {
toolCode := summaryPrimaryToolCode(summary)
reason := "agent invoked MCP tool"
if toolCode != "" && toolCode != firstInvokedToolCode(summary) {
reason = "agent invoked dynamic tool via tool_search"
}
return "tool", toolCode, reason
}
if strings.TrimSpace(summary.ReplyText) != "" {
return "reply", "", "agent replied directly"
}
if strings.TrimSpace(summary.ErrorMessage) != "" {
return "error", "", "runtime execution failed"
}
return "fallback", "", "runtime produced empty reply"
}
func toRunLogFinalAction(summary *Summary) string {
if summary == nil {
return ""
}
if skillCode := strings.TrimSpace(summaryPlannedSkillCode(summary)); skillCode != "" && strings.TrimSpace(summary.ReplyText) != "" {
return "skill"
}
switch strings.TrimSpace(summary.Status) {
case "completed":
return "reply"
case "fallback":
return "fallback"
case "error":
return "error"
case "interrupted":
return "interrupted"
case "expired":
return "expired"
default:
return strings.TrimSpace(summary.Status)
}
}
func buildRunLogReplyText(summary *Summary) string {
if summary == nil {
return ""
}
return strings.TrimSpace(summary.ReplyText)
}
func summaryPlannedSkillCode(summary *Summary) string {
if summary == nil {
return ""
}
return strings.TrimSpace(summary.PlannedSkillCode)
}
func summaryPlannedSkillName(summary *Summary) string {
if summary == nil {
return ""
}
return strings.TrimSpace(summary.PlannedSkillName)
}
func summarySkillRouteTrace(summary *Summary) string {
if summary == nil {
return ""
}
return strings.TrimSpace(summary.SkillRouteTrace)
}
func runLogResumeSource(trace *aiReplyTraceData) string {
if trace == nil {
return ""
}
return strings.TrimSpace(trace.ResumeSource)
}
func runLogFinalStatus(summary *Summary) string {
if summary == nil {
return ""
}
return strings.TrimSpace(summary.Status)
}
func (s *aiReplyService) 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 buildConversationInterrupt(conversation models.Conversation, message models.Message, aiAgent models.AIAgent, summary *Summary) *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.SourceMessageID = message.ID
item.InterruptID = firstInterruptID(summary)
item.InterruptType = firstInterruptType(summary)
item.Status = "pending"
item.PromptText = resolveInterruptPrompt(summary)
item.UpdatedAt = now
return item
}
func resolveInterruptPrompt(summary *Summary) string {
if summary == nil || len(summary.Interrupts) == 0 {
return "请继续补充信息后再试。"
}
if prompt := extractInterruptMessage(summary.Interrupts[0].InfoPreview); prompt != "" {
return prompt
}
if prompt := strings.TrimSpace(summary.Interrupts[0].InfoPreview); prompt != "" {
return prompt
}
return "请继续补充信息后再试。"
}
func extractInterruptMessage(infoPreview string) string {
infoPreview = strings.TrimSpace(infoPreview)
if infoPreview == "" {
return ""
}
payload := make(map[string]any)
if err := json.Unmarshal([]byte(infoPreview), &payload); err != nil {
return ""
}
if message, ok := payload["message"].(string); ok {
return strings.TrimSpace(message)
}
return ""
}
func firstInterruptID(summary *Summary) string {
if summary == nil || len(summary.Interrupts) == 0 {
return ""
}
return strings.TrimSpace(summary.Interrupts[0].ID)
}
func firstInterruptType(summary *Summary) string {
if summary == nil || len(summary.Interrupts) == 0 {
return ""
}
return strings.TrimSpace(summary.Interrupts[0].Type)
}
func firstInvokedToolCode(summary *Summary) string {
if summary == nil {
return ""
}
if len(summary.InvokedToolCodes) > 0 {
return strings.TrimSpace(summary.InvokedToolCodes[0])
}
return ""
}
func summaryPrimaryToolCode(summary *Summary) string {
if summary == nil {
return ""
}
toolCode := firstInvokedToolCode(summary)
if toolCode != toolx.BuiltinToolSearchToolCode {
return toolCode
}
if targetToolCode := firstToolSearchTargetToolCode(summary); targetToolCode != "" {
return targetToolCode
}
return toolCode
}
func extractToolSearchTrace(summary *Summary) string {
if summary == nil {
return ""
}
trace := parseRuntimeTraceData(summary.TraceData)
if len(trace.ToolSearch.Items) == 0 || len(trace.ToolSearch.Raw) == 0 {
return ""
}
return string(trace.ToolSearch.Raw)
}
func firstToolSearchTargetToolCode(summary *Summary) string {
trace := parseRuntimeTraceData(summary.TraceData)
for _, item := range trace.ToolSearch.Items {
toolCode := strings.TrimSpace(item.TargetToolCode)
if toolCode != "" {
return toolCode
}
}
return ""
}
type runtimeTraceProjection struct {
ToolSearch struct {
Raw json.RawMessage `json:"-"`
Items []struct {
TargetToolCode string `json:"targetToolCode"`
} `json:"items"`
} `json:"toolSearch"`
}
func parseRuntimeTraceData(raw string) runtimeTraceProjection {
raw = strings.TrimSpace(raw)
if raw == "" {
return runtimeTraceProjection{}
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return runtimeTraceProjection{}
}
var trace runtimeTraceProjection
if toolSearchRaw, ok := payload["toolSearch"]; ok && len(toolSearchRaw) > 0 {
trace.ToolSearch.Raw = append(json.RawMessage(nil), toolSearchRaw...)
_ = json.Unmarshal(toolSearchRaw, &trace.ToolSearch)
}
return trace
}
func isCancellationReply(replyText string) bool {
replyText = strings.TrimSpace(replyText)
return strings.Contains(replyText, "已取消本次工单创建")
}
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")
}
func (s *aiReplyService) handoffConversation(conversation models.Conversation, aiAgent models.AIAgent, reason string) error {
now := time.Now()
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, map[string]any{
"handoff_at": now,
"handoff_reason": strings.TrimSpace(reason),
"status": enums.IMConversationStatusPending,
"current_team_id": 0,
"current_assignee_id": 0,
"update_user_id": 0,
"update_user_name": aiAgent.Name,
"updated_at": now,
}); err != nil {
return err
}
return svc.ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", strings.TrimSpace(reason))
}); err != nil {
return err
}
if _, err := svc.MessageService.SendAIMessage(conversation.ID, aiAgent.ID, fmt.Sprintf("ai_handoff_%d", conversation.LastMessageID), enums.IMMessageTypeText, "已为你转接人工客服,请稍候。", "", s.buildAIPrincipal(aiAgent)); err != nil {
return err
}
if _, err := svc.ConversationDispatchService.DispatchConversation(conversation.ID); err != nil {
slog.Warn("auto dispatch conversation after ai handoff failed",
"conversation_id", conversation.ID,
"ai_agent_id", aiAgent.ID,
"error", err)
}
return nil
}
func (s *aiReplyService) 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,
}
}