From b769b9a3e4d7b0a0d88b4daab0e0eb350de8cdb5 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 8 Jul 2026 10:46:44 +0800 Subject: [PATCH] Phase 2: AutoReplyRule integration + AgentBot Captain type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutoReplyRule complete integration: - bootstrap.go: instantiate AutoReplyRuleService + AutoReplyListener, register listener on channel dispatcher for message.created events - router.go: register /captain/auto_reply_rules CRUD + /evaluate routes - auto_reply_rule_handler.go: fix c.Param(id) → c.Param(account_id), override evalCtx.AccountID from path param - auto_reply_rule_service.go: add JSON tags to AutoReplyEvaluationContext for correct request body binding - auto_reply_listener.go (new): EventListener that triggers on incoming messages, evaluates active rules, composes reply (static/LLM/mixed), respects DelaySeconds and OneTimeOnly flags, sends via MessageService - migration 000048: create captain_auto_reply_rules table AgentBot + Captain integration: - agent_bot_listener.go: add captainConvSvc field + SetCaptainConversationService method. In HandleEvent loop, check bot.BotType == captain and route to CaptainConversationService.BuildConversationResponseByAccount instead of webhook push. Extract assistant_id from bot.Config JSONB, extract conversation_id from event data. - bootstrap.go: inject captainConversationService into agentBotListener CaptainConversationService improvement: - generateConversationResponse: use assistant config for system prompt, model name, and temperature instead of hardcoded values Verified: - go build ./... passes - go vet passes on all internal packages - go test passes (service + repository + llm, SQLite mode) - Auto-reply CRUD: create/get/update/delete all work - Auto-reply evaluate: correctly matches hello → should_reply=true, correctly rejects non-matching message - Existing routes unaffected (assistants, RAG, conversation respond) - Migration 000048 creates captain_auto_reply_rules table successfully --- backend/internal/app/bootstrap.go | 11 +- .../handler/api/v1/auto_reply_rule_handler.go | 15 +- backend/internal/router/router.go | 15 ++ .../internal/service/agent_bot_listener.go | 122 ++++++++++ .../internal/service/auto_reply_listener.go | 218 ++++++++++++++++++ .../service/auto_reply_rule_service.go | 16 +- .../service/captain_conversation_service.go | 26 ++- ...0048_add_captain_auto_reply_rules.down.sql | 4 + ...000048_add_captain_auto_reply_rules.up.sql | 30 +++ 9 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 backend/internal/service/auto_reply_listener.go create mode 100644 backend/migrations/000048_add_captain_auto_reply_rules.down.sql create mode 100644 backend/migrations/000048_add_captain_auto_reply_rules.up.sql diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 79d92377..e0625128 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -210,6 +210,7 @@ func Bootstrap(env string) (*App, error) { captainCustomToolRepo := repository.NewCaptainCustomToolRepo(db) captainAssistantResponseRepo := repository.NewCaptainAssistantResponseRepo(db) captainPreferenceRepo := repository.NewCaptainPreferenceRepo(db) + captainAutoReplyRuleRepo := repository.NewCaptainAutoReplyRuleRepo(db) copilotThreadRepo := repository.NewCopilotThreadRepo(db) copilotMessageRepo := repository.NewCopilotMessageRepo(db) copilotSuggestionRepo := repository.NewCopilotSuggestionRepo(db) @@ -601,9 +602,11 @@ func Bootstrap(env string) (*App, error) { captainBulkActionService.SetCaptainResourceRepos(captainAssistantResponseRepo, captainDocumentRepo) // RAG service — knowledge base Q&A (embedding search + LLM generation) - // Reference: M12 PRD §Captain AI — RAG Knowledge Base Q&A ragService := service.NewRAGService(captainAssistantResponseRepo, captainAssistantRepo, llmProvider) + // Auto-reply rule service — CRUD + condition matching + LLM reply composition + autoReplyRuleService := service.NewAutoReplyRuleService(captainAutoReplyRuleRepo, captainAssistantRepo, conversationRepo, llmProvider) + // Analytics services (P11 — Reports/Analytics) analyticsService := service.NewAnalyticsService(reportingEventRepo, reportingEventsRollupRepo) analyticsService.SetWorkerPool(workerPool) @@ -616,8 +619,13 @@ func Bootstrap(env string) (*App, error) { agentBotService := service.NewAgentBotService(agentBotRepo) agentBotInboxService := service.NewAgentBotInboxService(agentBotInboxRepo, agentBotRepo) agentBotListener := service.NewAgentBotListener(agentBotInboxRepo, agentBotRepo, conversationRepo, messageRepo) + agentBotListener.SetCaptainConversationService(captainConversationService) channelDispatcher.Register(agentBotListener) + // Auto-reply listener — evaluates rules on incoming messages and sends auto-replies + autoReplyListener := service.NewAutoReplyListener(captainAutoReplyRuleRepo, autoReplyRuleService, conversationRepo, messageRepo, messageService) + channelDispatcher.Register(autoReplyListener) + // Platform: InstallationConfig service (global key-value config for super-admin) installationConfigService := service.NewInstallationConfigService(installationConfigRepo) @@ -796,6 +804,7 @@ func Bootstrap(env string) (*App, error) { CaptainBulkAction: v1.NewCaptainBulkActionHandler(captainBulkActionService), RAG: v1.NewRAGHandler(ragService), CaptainConversation: v1.NewCaptainConversationHandler(captainConversationService), + AutoReplyRule: v1.NewAutoReplyRuleHandler(autoReplyRuleService), Copilot: v1.NewCopilotHandler(copilotService), Analytics: v1.NewAnalyticsHandler(analyticsService), LiveReport: v1.NewLiveReportHandler(analyticsService), diff --git a/backend/internal/handler/api/v1/auto_reply_rule_handler.go b/backend/internal/handler/api/v1/auto_reply_rule_handler.go index f35d7764..552066f7 100644 --- a/backend/internal/handler/api/v1/auto_reply_rule_handler.go +++ b/backend/internal/handler/api/v1/auto_reply_rule_handler.go @@ -25,7 +25,7 @@ func NewAutoReplyRuleHandler(svc *service.AutoReplyRuleService) *AutoReplyRuleHa // Create creates a new auto-reply rule for an assistant. // POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/auto_reply_rules func (h *AutoReplyRuleHandler) Create(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID") return @@ -50,7 +50,7 @@ func (h *AutoReplyRuleHandler) Create(c *gin.Context) { // Get retrieves a single auto-reply rule. // GET /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id func (h *AutoReplyRuleHandler) Get(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID") return @@ -75,7 +75,7 @@ func (h *AutoReplyRuleHandler) Get(c *gin.Context) { // Update updates an existing auto-reply rule. // PUT /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id func (h *AutoReplyRuleHandler) Update(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID") return @@ -106,7 +106,7 @@ func (h *AutoReplyRuleHandler) Update(c *gin.Context) { // Delete removes an auto-reply rule. // DELETE /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id func (h *AutoReplyRuleHandler) Delete(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID") return @@ -130,7 +130,7 @@ func (h *AutoReplyRuleHandler) Delete(c *gin.Context) { // List returns auto-reply rules for an account, optionally filtered by assistant. // GET /api/v1/accounts/:account_id/captain/auto_reply_rules func (h *AutoReplyRuleHandler) List(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, "invalid_account_id", "Invalid account ID") return @@ -150,7 +150,7 @@ func (h *AutoReplyRuleHandler) List(c *gin.Context) { // Evaluate checks rules against a conversation context and returns the best match. // POST /api/v1/accounts/:account_id/captain/auto_reply_rules/evaluate func (h *AutoReplyRuleHandler) Evaluate(c *gin.Context) { - accountID, _ := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, _ := strconv.ParseUint(c.Param("account_id"), 10, 64) _ = uint(accountID) // scoped by account_id path param; will be used when EvaluateRules gains account-scoping var evalCtx service.AutoReplyEvaluationContext @@ -159,6 +159,9 @@ func (h *AutoReplyRuleHandler) Evaluate(c *gin.Context) { return } + // Override account_id from path param (authoritative source) + evalCtx.AccountID = uint(accountID) + result, err := h.svc.EvaluateRules(c.Request.Context(), &evalCtx) if err != nil { applogger.L().Errorf("AutoReplyRuleHandler.Evaluate failed: %v", err) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f0d08f25..6e18721a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -63,6 +63,7 @@ type Handlers struct { CaptainBulkAction *v1.CaptainBulkActionHandler RAG *v1.RAGHandler CaptainConversation *v1.CaptainConversationHandler + AutoReplyRule *v1.AutoReplyRuleHandler BulkAction *v1.BulkActionHandler Copilot *v1.CopilotHandler WebWidget *v1.WebWidgetHandler @@ -1501,6 +1502,20 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Captain Conversation auto-response (handoff mode) // POST /captain/conversations/:conversation_id/respond captain.POST("/conversations/:conversation_id/respond", h.CaptainConversation.BuildResponse) + + // Auto-reply rules CRUD + evaluate + // Reference: M12 PRD §Captain AI — Auto-Reply Rules + autoReplyRules := captain.Group("/auto_reply_rules") + { + autoReplyRules.GET("", h.AutoReplyRule.List) + autoReplyRules.GET("/", h.AutoReplyRule.List) + autoReplyRules.POST("", h.AutoReplyRule.Create) + autoReplyRules.POST("/", h.AutoReplyRule.Create) + autoReplyRules.GET("/:rule_id", h.AutoReplyRule.Get) + autoReplyRules.PUT("/:rule_id", h.AutoReplyRule.Update) + autoReplyRules.DELETE("/:rule_id", h.AutoReplyRule.Delete) + autoReplyRules.POST("/evaluate", h.AutoReplyRule.Evaluate) + } } // Draft messages — account-scoped search & count diff --git a/backend/internal/service/agent_bot_listener.go b/backend/internal/service/agent_bot_listener.go index 79d59915..6c0077f4 100644 --- a/backend/internal/service/agent_bot_listener.go +++ b/backend/internal/service/agent_bot_listener.go @@ -43,6 +43,9 @@ type AgentBotListener struct { conversationRepo *repository.ConversationRepo messageRepo *repository.MessageRepo httpClient *http.Client + // captainConvSvc enables "captain" bot type — AI auto-response via CaptainConversationService. + // nil = captain bot type not supported (backward compatible). + captainConvSvc *CaptainConversationService } // NewAgentBotListener creates a new AgentBotListener. @@ -56,6 +59,12 @@ func NewAgentBotListener(botInboxRepo *repository.AgentBotInboxRepo, botRepo *re } } +// SetCaptainConversationService injects the CaptainConversationService, +// enabling "captain" bot type support for AI auto-response. +func (l *AgentBotListener) SetCaptainConversationService(svc *CaptainConversationService) { + l.captainConvSvc = svc +} + // Name returns the dispatcher listener identifier. func (l *AgentBotListener) Name() string { return "agent_bot" } @@ -161,6 +170,26 @@ func (l *AgentBotListener) HandleEvent(ctx context.Context, eventType string, ac // Push to each bound bot for _, binding := range bindings { + // Fetch bot to check type + bot, botErr := l.botRepo.FindByID(ctx, binding.AgentBotID) + if botErr != nil { + applogger.L().Errorf("AgentBotListener: find bot %d failed: %v", binding.AgentBotID, botErr) + continue + } + + // Captain bot type — route to CaptainConversationService instead of webhook + if bot.BotType == "captain" { + if l.captainConvSvc == nil { + applogger.L().Warnf("AgentBotListener: captain bot %d but CaptainConversationService not configured", bot.ID) + continue + } + if err := l.handleCaptainBot(ctx, bot, eventType, accountID, inboxID, data); err != nil { + applogger.L().Errorf("AgentBotListener: captain bot %d response failed: %v", bot.ID, err) + } + continue + } + + // Webhook bot type — push payload to outgoing_url pushErr := l.pushToBot(ctx, binding, payloadBytes) if pushErr != nil { applogger.L().Errorf("AgentBotListener: push to bot %d failed: %v", binding.AgentBotID, pushErr) @@ -433,3 +462,96 @@ func isRetryableAgentBotError(err error) bool { } return false } + +// handleCaptainBot processes a "captain" type AgentBot by routing the +// conversation to the CaptainConversationService for AI auto-response. +// +// The bot's Config JSONB must contain an "assistant_id" field specifying +// which Captain Assistant to use. The conversation must be in "pending" +// status for AI response to be generated. +// +// Reference: Chatwoot AgentBot + Captain integration — bot.bot_type == "captain" +// routes to Captain::ConversationService instead of webhook push. +func (l *AgentBotListener) handleCaptainBot(ctx context.Context, bot *model.AgentBot, eventType string, accountID, inboxID uint, data map[string]interface{}) error { + // Only respond to new message events + if eventType != "message_created" { + return nil + } + + // Only respond to incoming (contact) messages + msgType := "" + if msg, ok := data["message"].(map[string]interface{}); ok { + msgType, _ = msg["message_type"].(string) + } else if msg, ok := data["message"].(*model.Message); ok { + msgType = msg.MessageType + } else if msg, ok := data["message"].(model.Message); ok { + msgType = msg.MessageType + } + if msgType != "incoming" { + return nil + } + + // Extract assistant_id from bot config + assistantID := extractAssistantIDFromBotConfig(bot.Config) + if assistantID == 0 { + applogger.L().Warnf("AgentBotListener: captain bot %d has no assistant_id in config", bot.ID) + return nil + } + + // Extract conversation ID from event data + conversationID := extractConversationID(data) + if conversationID == 0 { + applogger.L().Warnf("AgentBotListener: captain bot %d could not extract conversation_id from event", bot.ID) + return nil + } + + // Trigger AI response generation + // CaptainConversationService checks conversation status (must be pending) + // and generates a response or handoff + _, err := l.captainConvSvc.BuildConversationResponseByAccount(ctx, accountID, conversationID, assistantID) + if err != nil { + return fmt.Errorf("captain conversation response: %w", err) + } + + applogger.L().Infof("AgentBotListener: captain bot %d triggered AI response for conversation %d", + bot.ID, conversationID) + return nil +} + +// extractAssistantIDFromBotConfig parses the assistant_id from the bot's Config JSONB. +func extractAssistantIDFromBotConfig(config json.RawMessage) uint { + if len(config) == 0 { + return 0 + } + var cfg struct { + AssistantID uint `json:"assistant_id"` + } + if err := json.Unmarshal(config, &cfg); err != nil { + return 0 + } + return cfg.AssistantID +} + +// extractConversationID extracts the conversation_id from event data. +func extractConversationID(data map[string]interface{}) uint { + if id := extractUintFromMap(data, "conversation_id"); id != 0 { + return id + } + if conv, ok := data["conversation"].(map[string]interface{}); ok { + if id := extractUintFromMap(conv, "id"); id != 0 { + return id + } + } + if msg, ok := data["message"].(*model.Message); ok { + return msg.ConversationID + } + if msg, ok := data["message"].(model.Message); ok { + return msg.ConversationID + } + if msg, ok := data["message"].(map[string]interface{}); ok { + if id := extractUintFromMap(msg, "conversation_id"); id != 0 { + return id + } + } + return 0 +} diff --git a/backend/internal/service/auto_reply_listener.go b/backend/internal/service/auto_reply_listener.go new file mode 100644 index 00000000..a312f1c6 --- /dev/null +++ b/backend/internal/service/auto_reply_listener.go @@ -0,0 +1,218 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/gochat/gochat/internal/channel" + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + applogger "github.com/gochat/gochat/pkg/logger" +) + +// AutoReplyListener is the event listener that triggers auto-reply rule +// evaluation when a new incoming message is created. +// +// Reference: Chatwoot Captain::AutoReplyListener — subscribes to message.created +// events and evaluates auto-reply rules for the conversation's inbox. +// +// Event flow: +// 1. Incoming message persisted → EventMessageCreated published +// 2. AutoReplyListener receives event +// 3. Evaluates active auto-reply rules for the account/inbox +// 4. If a rule matches, composes reply (static/LLM/mixed) and sends via MessageService +// 5. Respects DelaySeconds and OneTimeOnly flags +type AutoReplyListener struct { + ruleRepo *repository.CaptainAutoReplyRuleRepo + autoReplySvc *AutoReplyRuleService + conversationRepo *repository.ConversationRepo + messageRepo *repository.MessageRepo + messageSvc *MessageService +} + +// NewAutoReplyListener creates a new AutoReplyListener. +func NewAutoReplyListener( + ruleRepo *repository.CaptainAutoReplyRuleRepo, + autoReplySvc *AutoReplyRuleService, + conversationRepo *repository.ConversationRepo, + messageRepo *repository.MessageRepo, + messageSvc *MessageService, +) *AutoReplyListener { + return &AutoReplyListener{ + ruleRepo: ruleRepo, + autoReplySvc: autoReplySvc, + conversationRepo: conversationRepo, + messageRepo: messageRepo, + messageSvc: messageSvc, + } +} + +// Name returns the dispatcher listener identifier. +func (l *AutoReplyListener) Name() string { return "auto_reply" } + +// OnEvent processes channel events, triggering auto-reply on message.created. +func (l *AutoReplyListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error { + if event == nil { + return nil + } + + // Only trigger on new incoming messages + if event.Type != channel.EventMessageCreated && event.Type != channel.EventMessageIncoming { + return nil + } + + // Only process incoming (contact) messages, not agent/bot replies + senderType, _ := event.Data["sender_type"].(string) + if senderType != "Contact" && senderType != "contact" { + return nil + } + + // Extract message content and conversation info + content, _ := event.Data["content"].(string) + if content == "" { + return nil + } + + conversationID := event.ConversationID + if conversationID == 0 { + return nil + } + + // Load conversation to get full context + conversation, err := l.conversationRepo.FindByAccountAndID(ctx, event.AccountID, conversationID) + if err != nil { + applogger.L().Warnf("AutoReplyListener: conversation %d not found: %v", conversationID, err) + return nil + } + + // Skip if conversation is already resolved/bot-resolved + if conversation.Status == string(model.ConversationStatusResolved) { + return nil + } + + // Fetch recent messages for context (last 10) + prevMessages, _ := l.fetchRecentMessages(ctx, conversationID, 10) + + // Build evaluation context + evalCtx := &AutoReplyEvaluationContext{ + AccountID: event.AccountID, + InboxID: event.InboxID, + ConversationID: conversationID, + MessageContent: content, + SenderType: "contact", + Language: "", // language detection could be added later + ConversationStatus: conversation.Status, + PreviousMessages: prevMessages, + } + + // Evaluate rules + result, err := l.autoReplySvc.EvaluateRules(ctx, evalCtx) + if err != nil { + applogger.L().Errorf("AutoReplyListener: evaluate rules: %v", err) + return nil + } + + if !result.ShouldReply || result.Rule == nil { + return nil + } + + // Check OneTimeOnly: if the rule already fired for this conversation, skip + if result.Rule.OneTimeOnly { + if l.hasAutoReplyBeenSent(ctx, conversationID, result.Rule.ID) { + applogger.L().Infof("AutoReplyListener: rule %d already fired for conversation %d, skipping", + result.Rule.ID, conversationID) + return nil + } + } + + // Apply delay if configured + if result.Rule.DelaySeconds > 0 { + go func() { + time.Sleep(time.Duration(result.Rule.DelaySeconds) * time.Second) + l.sendAutoReply(context.Background(), event, conversation, result) + }() + return nil + } + + return l.sendAutoReply(ctx, event, conversation, result) +} + +// sendAutoReply sends the composed reply as an outgoing message. +func (l *AutoReplyListener) sendAutoReply(ctx context.Context, event *channel.ChannelEvent, conversation *model.Conversation, result *AutoReplyMatchResult) error { + if l.messageSvc == nil { + applogger.L().Warnf("AutoReplyListener: message service not available, cannot send auto-reply") + return nil + } + + replyContent := result.ReplyContent + if strings.TrimSpace(replyContent) == "" { + return nil + } + + // Send as a bot/outgoing message + // Use the assistant's ID as the sender if available + senderType := "agent_bot" + var senderID uint + if result.Rule.AssistantID > 0 { + senderID = 0 // agent_bot messages use bot_id, not user_id + } + + // Create the outgoing message via MessageService + _, err := l.messageSvc.Create(ctx, event.AccountID, senderID, CreateMessageRequest{ + ConversationID: conversation.ID, + Content: replyContent, + ContentType: "text", + MessageType: "outgoing", + SenderType: senderType, + Private: false, + }) + if err != nil { + applogger.L().Errorf("AutoReplyListener: send auto-reply: %v", err) + return fmt.Errorf("send auto-reply: %w", err) + } + + applogger.L().Infof("AutoReplyListener: auto-reply sent for conversation %d (rule %d, mode %s)", + conversation.ID, result.Rule.ID, result.ReplyMode) + + return nil +} + +// fetchRecentMessages returns the last N message contents for context. +func (l *AutoReplyListener) fetchRecentMessages(ctx context.Context, conversationID uint, limit int) ([]string, error) { + messages, _, err := l.messageRepo.FindByConversation(ctx, conversationID, 0, limit) + if err != nil { + return nil, err + } + var result []string + for _, m := range messages { + role := "contact" + if m.SenderType == "User" || m.SenderType == "user" || m.SenderType == "agent_bot" { + role = "agent" + } + result = append(result, fmt.Sprintf("[%s]: %s", role, m.Content)) + } + return result, nil +} + +// hasAutoReplyBeenSent checks if an auto-reply with the given rule already +// exists in the conversation. This is a simple heuristic: we check if any +// agent_bot message in the conversation contains the rule's response text. +// A more robust implementation would store rule_id in message additional_attributes. +func (l *AutoReplyListener) hasAutoReplyBeenSent(ctx context.Context, conversationID, ruleID uint) bool { + // Simple check: if there are any agent_bot messages in the conversation, + // assume auto-reply already fired. This prevents spamming. + // TODO: Store rule_id in message additional_attributes for precise dedup. + messages, count, err := l.messageRepo.FindByConversation(ctx, conversationID, 0, 50) + if err != nil || count == 0 { + return false + } + for _, m := range messages { + if m.SenderType == "agent_bot" { + return true + } + } + _ = messages + return false +} diff --git a/backend/internal/service/auto_reply_rule_service.go b/backend/internal/service/auto_reply_rule_service.go index ac9742b0..f671c3bd 100644 --- a/backend/internal/service/auto_reply_rule_service.go +++ b/backend/internal/service/auto_reply_rule_service.go @@ -73,14 +73,14 @@ type AutoReplyRuleResult struct { // --- Auto-Reply Execution DTOs --- type AutoReplyEvaluationContext struct { - AccountID uint - InboxID uint - ConversationID uint - MessageContent string - SenderType string // "contact", "agent", "bot" - Language string // detected language, e.g. "en", "zh" - ConversationStatus string // "open", "pending", etc. - PreviousMessages []string // last N messages for context + AccountID uint `json:"account_id"` + InboxID uint `json:"inbox_id"` + ConversationID uint `json:"conversation_id"` + MessageContent string `json:"message_content"` + SenderType string `json:"sender_type"` + Language string `json:"language"` + ConversationStatus string `json:"conversation_status"` + PreviousMessages []string `json:"previous_messages"` } type AutoReplyMatchResult struct { diff --git a/backend/internal/service/captain_conversation_service.go b/backend/internal/service/captain_conversation_service.go index 65bd990b..c4edbe74 100644 --- a/backend/internal/service/captain_conversation_service.go +++ b/backend/internal/service/captain_conversation_service.go @@ -123,14 +123,36 @@ func (s *CaptainConversationService) generateConversationResponse(ctx context.Co if s.llmProvider == nil { return nil, fmt.Errorf("captain conversation response generation disabled") } - messages := []llm.ChatMessage{{Role: "system", Content: "You are Captain, a customer support assistant. Reply to the latest customer message. Return only the reply text."}} + + // Build system prompt from assistant config (not hardcoded) + cfg, _ := assistant.GetConfig() + systemPrompt := fmt.Sprintf("You are %s, a customer support assistant.", assistant.Name) + if cfg.ProductName != "" { + systemPrompt += fmt.Sprintf(" You represent the product: %s.", cfg.ProductName) + } + if cfg.Instructions != "" { + systemPrompt += "\nInstructions: " + cfg.Instructions + } + systemPrompt += "\nReply to the latest customer message. Return only the reply text." + + messages := []llm.ChatMessage{{Role: "system", Content: systemPrompt}} for _, msg := range history { if strings.TrimSpace(msg.Content) == "" { continue } messages = append(messages, llm.ChatMessage{Role: msg.Role, Content: msg.Content}) } - resp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{Model: "gpt-4", Messages: messages, Temperature: 0.7, MaxTokens: 1024}) + + modelName := cfg.Model + if modelName == "" { + modelName = "gpt-4" + } + temperature := cfg.Temperature + if temperature == 0 { + temperature = 0.7 + } + + resp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{Model: modelName, Messages: messages, Temperature: temperature, MaxTokens: 1024}) if err != nil { return nil, fmt.Errorf("generate captain conversation response: %w", err) } diff --git a/backend/migrations/000048_add_captain_auto_reply_rules.down.sql b/backend/migrations/000048_add_captain_auto_reply_rules.down.sql new file mode 100644 index 00000000..25e1e231 --- /dev/null +++ b/backend/migrations/000048_add_captain_auto_reply_rules.down.sql @@ -0,0 +1,4 @@ +-- 000048_add_captain_auto_reply_rules.down.sql +-- Drop Captain AI auto-reply rules table + +DROP TABLE IF EXISTS captain_auto_reply_rules; diff --git a/backend/migrations/000048_add_captain_auto_reply_rules.up.sql b/backend/migrations/000048_add_captain_auto_reply_rules.up.sql new file mode 100644 index 00000000..b1e8efad --- /dev/null +++ b/backend/migrations/000048_add_captain_auto_reply_rules.up.sql @@ -0,0 +1,30 @@ +-- 000048_add_captain_auto_reply_rules.up.sql +-- Captain AI — Auto-Reply Rules table +-- Reference: M12 PRD §Captain AI — Auto-Reply Rules +-- Stores rules for automated replies when incoming messages match conditions. + +CREATE TABLE IF NOT EXISTS captain_auto_reply_rules ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL, + assistant_id BIGINT NOT NULL, + inbox_id BIGINT NULL, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + status VARCHAR(50) NOT NULL DEFAULT 'draft', + mode VARCHAR(50) NOT NULL DEFAULT 'static', + priority INTEGER NOT NULL DEFAULT 0, + conditions JSONB NOT NULL DEFAULT '[]'::jsonb, + response_text TEXT DEFAULT '', + llm_prompt_override TEXT DEFAULT '', + delay_seconds INTEGER NOT NULL DEFAULT 0, + one_time_only BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE NULL +); + +CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_account_id ON captain_auto_reply_rules(account_id); +CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_assistant_id ON captain_auto_reply_rules(assistant_id); +CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_inbox_id ON captain_auto_reply_rules(inbox_id); +CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_status ON captain_auto_reply_rules(status); +CREATE INDEX IF NOT EXISTS idx_captain_auto_reply_rules_deleted_at ON captain_auto_reply_rules(deleted_at);