486 lines
16 KiB
Go
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,
|
|
}
|
|
} |