Files
gochat/internal/service/auto_reply_rule_service.go
T
2026-06-04 15:44:48 +08:00

486 lines
16 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
)
// AutoReplyRuleService manages Captain auto-reply rules: CRUD + rule matching + auto-reply execution.
// Reference: M12 PRD §Captain AI — Auto-Reply Rules
//
// Auto-reply workflow:
// 1. When a new incoming message arrives, check active rules for the inbox/account
// 2. Evaluate rule conditions against the message content and context
// 3. If matched, compose reply (static text or LLM-generated) and send
// --- CRUD DTOs ---
type CreateAutoReplyRuleRequest struct {
AssistantID uint `json:"assistant_id" validate:"required"`
InboxID *uint `json:"inbox_id,omitempty"`
Name string `json:"name" validate:"required,min=1"`
Description string `json:"description,omitempty"`
Mode string `json:"mode" validate:"required,oneof=static llm mixed"` // static, llm, mixed
Priority int `json:"priority,omitempty"`
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
ResponseText string `json:"response_text,omitempty"`
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
DelaySeconds int `json:"delay_seconds,omitempty"`
OneTimeOnly *bool `json:"one_time_only,omitempty"`
}
type UpdateAutoReplyRuleRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Status *model.AutoReplyRuleStatus `json:"status,omitempty"`
Mode *model.AutoReplyRuleMode `json:"mode,omitempty"`
Priority *int `json:"priority,omitempty"`
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
ResponseText *string `json:"response_text,omitempty"`
LLMPromptOverride *string `json:"llm_prompt_override,omitempty"`
DelaySeconds *int `json:"delay_seconds,omitempty"`
OneTimeOnly *bool `json:"one_time_only,omitempty"`
}
type AutoReplyRuleResult struct {
ID uint `json:"id"`
AccountID uint `json:"account_id"`
AssistantID uint `json:"assistant_id"`
InboxID *uint `json:"inbox_id,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status model.AutoReplyRuleStatus `json:"status"`
Mode model.AutoReplyRuleMode `json:"mode"`
Priority int `json:"priority"`
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
ResponseText string `json:"response_text,omitempty"`
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
DelaySeconds int `json:"delay_seconds"`
OneTimeOnly bool `json:"one_time_only"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// --- 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
}
type AutoReplyMatchResult struct {
Rule *model.CaptainAutoReplyRule `json:"rule"`
ReplyContent string `json:"reply_content"`
ReplyMode model.AutoReplyRuleMode `json:"reply_mode"`
ShouldReply bool `json:"should_reply"`
}
// AutoReplyRuleService provides CRUD + evaluation + execution for auto-reply rules.
type AutoReplyRuleService struct {
ruleRepo *repository.CaptainAutoReplyRuleRepo
assistantRepo *repository.CaptainAssistantRepo
conversationRepo *repository.ConversationRepo
llmProvider llm.Provider
promptBuilder *SystemPromptBuilder
}
// NewAutoReplyRuleService creates a new AutoReplyRuleService.
func NewAutoReplyRuleService(
ruleRepo *repository.CaptainAutoReplyRuleRepo,
assistantRepo *repository.CaptainAssistantRepo,
conversationRepo *repository.ConversationRepo,
llmProvider llm.Provider,
) *AutoReplyRuleService {
return &AutoReplyRuleService{
ruleRepo: ruleRepo,
assistantRepo: assistantRepo,
conversationRepo: conversationRepo,
llmProvider: llmProvider,
promptBuilder: NewSystemPromptBuilder(),
}
}
// --- CRUD Operations ---
// CreateRule creates a new auto-reply rule.
func (s *AutoReplyRuleService) CreateRule(ctx context.Context, accountID uint, req *CreateAutoReplyRuleRequest) (*AutoReplyRuleResult, error) {
// Validate assistant exists
assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID)
if err != nil {
return nil, fmt.Errorf("assistant not found: %w", err)
}
if assistant.AccountID != accountID {
return nil, fmt.Errorf("assistant does not belong to account %d", accountID)
}
// Validate mode-specific fields
mode := model.AutoReplyRuleMode(req.Mode)
switch mode {
case model.AutoReplyRuleModeStatic, model.AutoReplyRuleModeMixed:
if req.ResponseText == "" {
return nil, fmt.Errorf("response_text is required for %s mode", mode)
}
case model.AutoReplyRuleModeLLM:
// LLM mode can work without static text, just needs the assistant
}
// Marshal conditions to JSON
conditionsJSON, err := json.Marshal(req.Conditions)
if err != nil {
return nil, fmt.Errorf("invalid conditions format: %w", err)
}
oneTimeOnly := true
if req.OneTimeOnly != nil {
oneTimeOnly = *req.OneTimeOnly
}
rule := &model.CaptainAutoReplyRule{
AccountID: accountID,
AssistantID: req.AssistantID,
InboxID: req.InboxID,
Name: req.Name,
Description: req.Description,
Status: model.AutoReplyRuleStatusDraft, // new rules start as draft
Mode: mode,
Priority: req.Priority,
Conditions: conditionsJSON,
ResponseText: req.ResponseText,
LLMPromptOverride: req.LLMPromptOverride,
DelaySeconds: req.DelaySeconds,
OneTimeOnly: oneTimeOnly,
}
if err := s.ruleRepo.Create(ctx, rule); err != nil {
applogger.L().Errorf("CreateRule: %v", err)
return nil, fmt.Errorf("create rule failed: %w", err)
}
return ruleToResult(rule), nil
}
// GetRule retrieves a rule by ID.
func (s *AutoReplyRuleService) GetRule(ctx context.Context, accountID, ruleID uint) (*AutoReplyRuleResult, error) {
rule, err := s.ruleRepo.GetByID(ctx, ruleID)
if err != nil {
return nil, fmt.Errorf("rule not found: %w", err)
}
if rule.AccountID != accountID {
return nil, fmt.Errorf("rule does not belong to account %d", accountID)
}
return ruleToResult(rule), nil
}
// UpdateRule updates an existing rule.
func (s *AutoReplyRuleService) UpdateRule(ctx context.Context, accountID, ruleID uint, req *UpdateAutoReplyRuleRequest) (*AutoReplyRuleResult, error) {
rule, err := s.ruleRepo.GetByID(ctx, ruleID)
if err != nil {
return nil, fmt.Errorf("rule not found: %w", err)
}
if rule.AccountID != accountID {
return nil, fmt.Errorf("rule does not belong to account %d", accountID)
}
// Apply partial updates
if req.Name != nil {
rule.Name = *req.Name
}
if req.Description != nil {
rule.Description = *req.Description
}
if req.Status != nil {
rule.Status = *req.Status
}
if req.Mode != nil {
rule.Mode = *req.Mode
}
if req.Priority != nil {
rule.Priority = *req.Priority
}
if req.Conditions != nil {
conditionsJSON, err := json.Marshal(req.Conditions)
if err != nil {
return nil, fmt.Errorf("invalid conditions format: %w", err)
}
rule.Conditions = conditionsJSON
}
if req.ResponseText != nil {
rule.ResponseText = *req.ResponseText
}
if req.LLMPromptOverride != nil {
rule.LLMPromptOverride = *req.LLMPromptOverride
}
if req.DelaySeconds != nil {
rule.DelaySeconds = *req.DelaySeconds
}
if req.OneTimeOnly != nil {
rule.OneTimeOnly = *req.OneTimeOnly
}
if err := s.ruleRepo.Update(ctx, rule); err != nil {
applogger.L().Errorf("UpdateRule: %v", err)
return nil, fmt.Errorf("update rule failed: %w", err)
}
return ruleToResult(rule), nil
}
// DeleteRule deletes a rule.
func (s *AutoReplyRuleService) DeleteRule(ctx context.Context, accountID, ruleID uint) error {
rule, err := s.ruleRepo.GetByID(ctx, ruleID)
if err != nil {
return fmt.Errorf("rule not found: %w", err)
}
if rule.AccountID != accountID {
return fmt.Errorf("rule does not belong to account %d", accountID)
}
return s.ruleRepo.Delete(ctx, ruleID)
}
// ListRules lists all rules for an account.
func (s *AutoReplyRuleService) ListRules(ctx context.Context, accountID uint, offset, limit int) ([]AutoReplyRuleResult, int64, error) {
rules, count, err := s.ruleRepo.ListByAccount(ctx, accountID, offset, limit)
if err != nil {
return nil, 0, fmt.Errorf("list rules failed: %w", err)
}
results := make([]AutoReplyRuleResult, len(rules))
for i, r := range rules {
results[i] = *ruleToResult(&r)
}
return results, count, nil
}
// --- Auto-Reply Evaluation & Execution ---
// Reference: M12 PRD §Captain AI — Auto-Reply Processing
// EvaluateRules checks if any active auto-reply rules match the incoming message context.
// Returns the highest-priority matching rule and its composed reply content.
func (s *AutoReplyRuleService) EvaluateRules(ctx context.Context, evalCtx *AutoReplyEvaluationContext) (*AutoReplyMatchResult, error) {
// Find active rules for this inbox/account
rules, err := s.ruleRepo.FindActiveByInbox(ctx, evalCtx.AccountID, evalCtx.InboxID)
if err != nil {
applogger.L().Errorf("EvaluateRules find rules: %v", err)
return nil, fmt.Errorf("find active rules: %w", err)
}
if len(rules) == 0 {
return &AutoReplyMatchResult{ShouldReply: false}, nil
}
// Evaluate rules in priority order (already sorted)
for _, rule := range rules {
matched, err := s.matchConditions(ctx, &rule, evalCtx)
if err != nil {
applogger.L().Warnf("EvaluateRules match rule %d: %v", rule.ID, err)
continue
}
if matched {
// Compose reply based on rule mode
replyContent, err := s.composeReply(ctx, &rule, evalCtx)
if err != nil {
applogger.L().Errorf("EvaluateRules compose reply: %v", err)
continue
}
return &AutoReplyMatchResult{
Rule: &rule,
ReplyContent: replyContent,
ReplyMode: rule.Mode,
ShouldReply: true,
}, nil
}
}
return &AutoReplyMatchResult{ShouldReply: false}, nil
}
// matchConditions evaluates all conditions of a rule against the evaluation context.
// All conditions must match (AND logic).
func (s *AutoReplyRuleService) matchConditions(ctx context.Context, rule *model.CaptainAutoReplyRule, evalCtx *AutoReplyEvaluationContext) (bool, error) {
conditions, err := rule.GetConditions()
if err != nil {
return false, fmt.Errorf("parse conditions: %w", err)
}
// If no conditions, the rule matches everything (catch-all)
if len(conditions) == 0 {
return true, nil
}
for _, cond := range conditions {
matched, err := matchSingleCondition(cond, evalCtx)
if err != nil {
return false, err
}
if !matched {
return false, nil // AND logic: one failure = rule doesn't match
}
}
return true, nil
}
// matchSingleCondition evaluates one condition against the evaluation context.
func matchSingleCondition(cond model.AutoReplyCondition, evalCtx *AutoReplyEvaluationContext) (bool, error) {
// Get the field value from context
var fieldValue string
switch cond.Field {
case "message_content":
fieldValue = evalCtx.MessageContent
case "sender_type":
fieldValue = evalCtx.SenderType
case "conversation_status":
fieldValue = evalCtx.ConversationStatus
case "language":
fieldValue = evalCtx.Language
case "keywords":
// keywords condition: check if any of the keywords (comma-separated in value) appear
keywords := strings.Split(cond.Value, ",")
for _, kw := range keywords {
if strings.Contains(strings.ToLower(evalCtx.MessageContent), strings.TrimSpace(strings.ToLower(kw))) {
return true, nil
}
}
return false, nil
default:
return false, fmt.Errorf("unknown condition field: %s", cond.Field)
}
// Apply operator
switch cond.Operator {
case "contains":
return strings.Contains(strings.ToLower(fieldValue), strings.ToLower(cond.Value)), nil
case "equals":
return strings.EqualFold(fieldValue, cond.Value), nil
case "starts_with":
return strings.HasPrefix(strings.ToLower(fieldValue), strings.ToLower(cond.Value)), nil
case "regex":
re, err := regexp.Compile(cond.Value)
if err != nil {
return false, fmt.Errorf("invalid regex: %w", err)
}
return re.MatchString(fieldValue), nil
case "language_is":
return strings.EqualFold(evalCtx.Language, cond.Value), nil
default:
return false, fmt.Errorf("unknown condition operator: %s", cond.Operator)
}
}
// composeReply generates the auto-reply content based on the rule mode.
func (s *AutoReplyRuleService) composeReply(ctx context.Context, rule *model.CaptainAutoReplyRule, evalCtx *AutoReplyEvaluationContext) (string, error) {
switch rule.Mode {
case model.AutoReplyRuleModeStatic:
return rule.ResponseText, nil
case model.AutoReplyRuleModeLLM:
return s.composeLLMReply(ctx, rule, evalCtx)
case model.AutoReplyRuleModeMixed:
// Static intro + LLM contextual body
llmBody, err := s.composeLLMReply(ctx, rule, evalCtx)
if err != nil {
// Fallback to static-only if LLM fails
applogger.L().Warnf("LLM compose failed for mixed mode rule %d, using static only: %v", rule.ID, err)
return rule.ResponseText, nil
}
return rule.ResponseText + "\n\n" + llmBody, nil
default:
return "", fmt.Errorf("unknown rule mode: %s", rule.Mode)
}
}
// composeLLMReply uses LLM to generate a contextual reply based on rule and conversation context.
func (s *AutoReplyRuleService) composeLLMReply(ctx context.Context, rule *model.CaptainAutoReplyRule, evalCtx *AutoReplyEvaluationContext) (string, error) {
// Get assistant for prompt building
assistant, err := s.assistantRepo.GetByID(ctx, rule.AssistantID)
if err != nil {
return "", fmt.Errorf("assistant not found: %w", err)
}
cfg, _ := assistant.GetConfig()
// Build system prompt
systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg)
if rule.LLMPromptOverride != "" {
systemPrompt += "\n\nAdditional Instructions: " + rule.LLMPromptOverride
}
systemPrompt += "\n\nYou are generating an auto-reply for a customer message. Be helpful, professional, and concise. Do not mention that this is an automated response."
// Build conversation context
var contextBuilder strings.Builder
contextBuilder.WriteString("Current message from customer:\n")
contextBuilder.WriteString(evalCtx.MessageContent)
contextBuilder.WriteString("\n\nPrevious messages:\n")
for _, msg := range evalCtx.PreviousMessages {
contextBuilder.WriteString(msg + "\n")
}
modelName := cfg.Model
if modelName == "" {
modelName = "gpt-4"
}
temperature := cfg.Temperature
if temperature == 0 {
temperature = 0.7
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: modelName,
Messages: []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: contextBuilder.String()},
},
Temperature: temperature,
MaxTokens: 512,
})
if err != nil {
return "", fmt.Errorf("LLM auto-reply generation failed: %w", err)
}
if len(llmResp.Choices) == 0 {
return "", fmt.Errorf("no LLM response")
}
return llmResp.Choices[0].Message.Content, nil
}
// --- Helper ---
func ruleToResult(rule *model.CaptainAutoReplyRule) *AutoReplyRuleResult {
conditions, _ := rule.GetConditions()
return &AutoReplyRuleResult{
ID: rule.ID,
AccountID: rule.AccountID,
AssistantID: rule.AssistantID,
InboxID: rule.InboxID,
Name: rule.Name,
Description: rule.Description,
Status: rule.Status,
Mode: rule.Mode,
Priority: rule.Priority,
Conditions: conditions,
ResponseText: rule.ResponseText,
LLMPromptOverride: rule.LLMPromptOverride,
DelaySeconds: rule.DelaySeconds,
OneTimeOnly: rule.OneTimeOnly,
CreatedAt: rule.CreatedAt,
UpdatedAt: rule.UpdatedAt,
}
}