Phase 2: AutoReplyRule integration + AgentBot Captain type

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
This commit is contained in:
2026-07-08 15:38:08 +08:00
parent 42b8b6c7f9
commit b769b9a3e4
9 changed files with 440 additions and 17 deletions
+10 -1
View File
@@ -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),
@@ -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)
+15
View File
@@ -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
@@ -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
}
@@ -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
}
@@ -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 {
@@ -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)
}
@@ -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;
@@ -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);