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

293 lines
9.8 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"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"
)
// CaptainAssistantService implements business logic for CaptainAssistant operations.
// Reference: Chatwoot enterprise/app/controllers/api/v1/captain/assistants_controller.rb
type CaptainAssistantService struct {
assistantRepo *repository.CaptainAssistantRepo
inboxRepo *repository.CaptainInboxRepo
documentRepo *repository.CaptainDocumentRepo
responseRepo *repository.CaptainAssistantResponseRepo
llmProvider llm.Provider
}
// NewCaptainAssistantService creates a new CaptainAssistantService.
func NewCaptainAssistantService(
assistantRepo *repository.CaptainAssistantRepo,
inboxRepo *repository.CaptainInboxRepo,
documentRepo *repository.CaptainDocumentRepo,
responseRepo *repository.CaptainAssistantResponseRepo,
llmProvider llm.Provider,
) *CaptainAssistantService {
return &CaptainAssistantService{
assistantRepo: assistantRepo,
inboxRepo: inboxRepo,
documentRepo: documentRepo,
responseRepo: responseRepo,
llmProvider: llmProvider,
}
}
// --- Request DTOs ---
// CreateAssistantRequest is the DTO for creating an assistant.
type CreateAssistantRequest struct {
Name string `json:"name" validate:"required,min=2"`
Description string `json:"description"`
Config json.RawMessage `json:"config"`
Guardrails json.RawMessage `json:"guardrails"`
ResponseGuidelines json.RawMessage `json:"response_guidelines"`
}
// UpdateAssistantRequest is the DTO for updating an assistant.
type UpdateAssistantRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Config json.RawMessage `json:"config"`
Guardrails json.RawMessage `json:"guardrails"`
ResponseGuidelines json.RawMessage `json:"response_guidelines"`
Status string `json:"status"`
}
// --- CRUD Operations ---
// Create creates a new CaptainAssistant.
func (s *CaptainAssistantService) Create(ctx context.Context, accountID uint, req *CreateAssistantRequest) (*model.CaptainAssistant, error) {
config := req.Config
if len(config) == 0 || string(config) == "null" {
defaultCfg := model.DefaultAssistantConfig()
data, err := json.Marshal(defaultCfg)
if err != nil {
return nil, fmt.Errorf("marshal default config: %w", err)
}
config = data
}
assistant := &model.CaptainAssistant{
AccountID: accountID,
Name: req.Name,
Description: req.Description,
Config: config,
Guardrails: req.Guardrails,
ResponseGuidelines: req.ResponseGuidelines,
Status: model.AssistantStatusActive,
}
if err := s.assistantRepo.Create(ctx, assistant); err != nil {
applogger.L().Errorf("Create captain assistant: %v", err)
return nil, fmt.Errorf("create assistant: %w", err)
}
return assistant, nil
}
// Get retrieves an assistant by ID.
func (s *CaptainAssistantService) Get(ctx context.Context, id uint) (*model.CaptainAssistant, error) {
assistant, err := s.assistantRepo.GetByID(ctx, id)
if err != nil {
applogger.L().Errorf("Get captain assistant: %v", err)
return nil, fmt.Errorf("get assistant: %w", err)
}
return assistant, nil
}
// Update updates an existing assistant.
func (s *CaptainAssistantService) Update(ctx context.Context, id uint, req *UpdateAssistantRequest) (*model.CaptainAssistant, error) {
assistant, err := s.assistantRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("assistant not found: %w", err)
}
if req.Name != "" {
assistant.Name = req.Name
}
if req.Description != "" {
assistant.Description = req.Description
}
if len(req.Config) > 0 && string(req.Config) != "null" {
assistant.Config = req.Config
}
if len(req.Guardrails) > 0 && string(req.Guardrails) != "null" {
assistant.Guardrails = req.Guardrails
}
if len(req.ResponseGuidelines) > 0 && string(req.ResponseGuidelines) != "null" {
assistant.ResponseGuidelines = req.ResponseGuidelines
}
if req.Status != "" {
assistant.Status = model.AssistantStatus(req.Status)
}
if err := s.assistantRepo.Update(ctx, assistant); err != nil {
applogger.L().Errorf("Update captain assistant: %v", err)
return nil, fmt.Errorf("update assistant: %w", err)
}
return assistant, nil
}
// Delete deletes an assistant by ID.
func (s *CaptainAssistantService) Delete(ctx context.Context, id uint) error {
if err := s.assistantRepo.Delete(ctx, id); err != nil {
applogger.L().Errorf("Delete captain assistant: %v", err)
return fmt.Errorf("delete assistant: %w", err)
}
return nil
}
// List retrieves assistants for an account with pagination.
func (s *CaptainAssistantService) List(ctx context.Context, accountID uint, offset, limit int) ([]model.CaptainAssistant, int64, error) {
assistants, count, err := s.assistantRepo.ListByAccount(ctx, accountID, offset, limit)
if err != nil {
applogger.L().Errorf("List captain assistants: %v", err)
return nil, 0, fmt.Errorf("list assistants: %w", err)
}
return assistants, count, nil
}
// --- Config Management ---
// GetConfig reads and parses the assistant's JSONB config.
func (s *CaptainAssistantService) GetConfig(ctx context.Context, id uint) (*model.AssistantConfig, error) {
assistant, err := s.assistantRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("assistant not found: %w", err)
}
return assistant.GetConfig()
}
// SetConfig updates the assistant's JSONB config.
func (s *CaptainAssistantService) SetConfig(ctx context.Context, id uint, cfg *model.AssistantConfig) error {
assistant, err := s.assistantRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("assistant not found: %w", err)
}
if err := assistant.SetConfig(cfg); err != nil {
return fmt.Errorf("set config: %w", err)
}
if err := s.assistantRepo.Update(ctx, assistant); err != nil {
applogger.L().Errorf("SetConfig captain assistant: %v", err)
return fmt.Errorf("update assistant config: %w", err)
}
return nil
}
// --- Inbox Association ---
// AssociateInbox binds an assistant to an inbox.
func (s *CaptainAssistantService) AssociateInbox(ctx context.Context, assistantID, inboxID, accountID uint) error {
// Verify assistant exists and belongs to account
assistant, err := s.assistantRepo.GetByID(ctx, assistantID)
if err != nil {
return fmt.Errorf("assistant not found: %w", err)
}
if assistant.AccountID != accountID {
return fmt.Errorf("assistant does not belong to account")
}
ci := &model.CaptainInbox{
AssistantID: assistantID,
InboxID: inboxID,
AccountID: accountID,
}
if err := s.inboxRepo.Create(ctx, ci); err != nil {
applogger.L().Errorf("AssociateInbox: %v", err)
return fmt.Errorf("associate inbox: %w", err)
}
return nil
}
// DissociateInbox unbinds an assistant from an inbox.
func (s *CaptainAssistantService) DissociateInbox(ctx context.Context, assistantID, inboxID uint) error {
if err := s.inboxRepo.Delete(ctx, assistantID, inboxID); err != nil {
applogger.L().Errorf("DissociateInbox: %v", err)
return fmt.Errorf("dissociate inbox: %w", err)
}
return nil
}
// --- Document Management ---
// AddDocument creates a document linked to the assistant.
func (s *CaptainAssistantService) AddDocument(ctx context.Context, assistantID uint, doc *model.CaptainDocument) error {
doc.AssistantID = assistantID
if err := s.documentRepo.Create(ctx, doc); err != nil {
applogger.L().Errorf("AddDocument: %v", err)
return fmt.Errorf("add document: %w", err)
}
return nil
}
// RemoveDocument removes a document from the assistant.
func (s *CaptainAssistantService) RemoveDocument(ctx context.Context, documentID uint) error {
if err := s.documentRepo.Delete(ctx, documentID); err != nil {
applogger.L().Errorf("RemoveDocument: %v", err)
return fmt.Errorf("remove document: %w", err)
}
return nil
}
// --- RAG Response Generation ---
// GenerateResponse calls the LLM provider to generate a response based on RAG context.
// Reference: Chatwoot Captain::Assistant::ResponseGenerator
func (s *CaptainAssistantService) GenerateResponse(ctx context.Context, assistantID uint, query string) (string, error) {
assistant, err := s.assistantRepo.GetByID(ctx, assistantID)
if err != nil {
return "", fmt.Errorf("assistant not found: %w", err)
}
// Build system prompt from assistant config and response guidelines
cfg, _ := assistant.GetConfig()
systemPrompt := buildSystemPrompt(assistant, cfg)
// Build messages for LLM
messages := []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: query},
}
req := llm.ChatRequest{
Model: cfg.Model,
Messages: messages,
Temperature: cfg.Temperature,
MaxTokens: 1024,
}
resp, err := s.llmProvider.ChatCompletion(ctx, req)
if err != nil {
applogger.L().Errorf("GenerateResponse LLM call: %v", err)
return "", fmt.Errorf("llm generation failed: %w", err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("no response from LLM")
}
return resp.Choices[0].Message.Content, nil
}
// buildSystemPrompt constructs the system prompt from assistant config and guidelines.
func buildSystemPrompt(assistant *model.CaptainAssistant, cfg *model.AssistantConfig) string {
prompt := fmt.Sprintf("You are %s, an AI assistant.", assistant.Name)
if cfg.ProductName != "" {
prompt += fmt.Sprintf(" You represent the product: %s.", cfg.ProductName)
}
if cfg.Instructions != "" {
prompt += fmt.Sprintf("\nInstructions: %s", cfg.Instructions)
}
if len(assistant.ResponseGuidelines) > 0 && string(assistant.ResponseGuidelines) != "null" {
prompt += fmt.Sprintf("\nResponse Guidelines: %s", string(assistant.ResponseGuidelines))
}
if len(assistant.Guardrails) > 0 && string(assistant.Guardrails) != "null" {
prompt += fmt.Sprintf("\nGuardrails: %s", string(assistant.Guardrails))
}
return prompt
}