package service import ( "context" "crypto/sha256" "fmt" "io" "net/http" "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" ) // CaptainDocumentService implements business logic for CaptainDocument operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/documents_controller.rb type CaptainDocumentService struct { documentRepo *repository.CaptainDocumentRepo llmProvider llm.Provider } // NewCaptainDocumentService creates a new CaptainDocumentService. func NewCaptainDocumentService( documentRepo *repository.CaptainDocumentRepo, llmProvider llm.Provider, ) *CaptainDocumentService { return &CaptainDocumentService{ documentRepo: documentRepo, llmProvider: llmProvider, } } // --- Request DTOs --- // CreateDocumentRequest is the DTO for creating a document. type CreateDocumentRequest struct { Name string `json:"name" validate:"required"` ExternalLink string `json:"external_link" validate:"required"` } // UpdateDocumentRequest is the DTO for updating a document. type UpdateDocumentRequest struct { Name string `json:"name"` ExternalLink string `json:"external_link"` } // --- CRUD Operations --- // Create creates a new CaptainDocument. func (s *CaptainDocumentService) Create(ctx context.Context, assistantID, accountID uint, req *CreateDocumentRequest) (*model.CaptainDocument, error) { doc := &model.CaptainDocument{ AccountID: accountID, AssistantID: assistantID, Name: req.Name, ExternalLink: req.ExternalLink, Status: model.DocumentStatusPending, } if err := s.documentRepo.Create(ctx, doc); err != nil { applogger.L().Errorf("Create captain document: %v", err) return nil, fmt.Errorf("create document: %w", err) } return doc, nil } // Get retrieves a document by ID. func (s *CaptainDocumentService) Get(ctx context.Context, id uint) (*model.CaptainDocument, error) { doc, err := s.documentRepo.GetByID(ctx, id) if err != nil { applogger.L().Errorf("Get captain document: %v", err) return nil, fmt.Errorf("get document: %w", err) } return doc, nil } // Update updates an existing document. func (s *CaptainDocumentService) Update(ctx context.Context, id uint, req *UpdateDocumentRequest) (*model.CaptainDocument, error) { doc, err := s.documentRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("document not found: %w", err) } if req.Name != "" { doc.Name = req.Name } if req.ExternalLink != "" { doc.ExternalLink = req.ExternalLink } if err := s.documentRepo.Update(ctx, doc); err != nil { applogger.L().Errorf("Update captain document: %v", err) return nil, fmt.Errorf("update document: %w", err) } return doc, nil } // Delete deletes a document by ID. func (s *CaptainDocumentService) Delete(ctx context.Context, id uint) error { if err := s.documentRepo.Delete(ctx, id); err != nil { applogger.L().Errorf("Delete captain document: %v", err) return fmt.Errorf("delete document: %w", err) } return nil } // List retrieves documents for an assistant with pagination. func (s *CaptainDocumentService) List(ctx context.Context, assistantID uint, offset, limit int) ([]model.CaptainDocument, int64, error) { docs, count, err := s.documentRepo.ListByAssistant(ctx, assistantID, offset, limit) if err != nil { applogger.L().Errorf("List captain documents: %v", err) return nil, 0, fmt.Errorf("list documents: %w", err) } return docs, count, nil } // --- Document Processing --- // ProcessDocument extracts content and generates embedding for a document. // Reference: Chatwoot Captain::DocumentProcessorJob func (s *CaptainDocumentService) ProcessDocument(ctx context.Context, id uint) error { doc, err := s.documentRepo.GetByID(ctx, id) if err != nil { return fmt.Errorf("document not found: %w", err) } // Mark status as in_progress doc.Status = model.DocumentStatusInProgress if err := s.documentRepo.Update(ctx, doc); err != nil { return fmt.Errorf("update document status: %w", err) } // Fetch content from external link content, err := s.fetchContent(ctx, doc.ExternalLink) if err != nil { doc.Status = model.DocumentStatusFailed doc.LastSyncErrorCode = "fetch_failed" s.documentRepo.Update(ctx, doc) applogger.L().Errorf("ProcessDocument fetch content: %v", err) return fmt.Errorf("fetch content: %w", err) } // Compute content fingerprint for deduplication fingerprint := computeFingerprint(content) // Update document with content doc.Content = content doc.ContentFingerprint = fingerprint doc.Status = model.DocumentStatusCompleted now := time.Now().Unix() doc.LastSyncedAt = &now doc.SyncStatus = model.DocumentSyncStatusSynced if err := s.documentRepo.Update(ctx, doc); err != nil { applogger.L().Errorf("ProcessDocument update: %v", err) return fmt.Errorf("update document: %w", err) } // Generate embedding for the document content _, err = s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{ Input: []string{content}, Model: "text-embedding-ada-002", }) if err != nil { applogger.L().Errorf("ProcessDocument embedding: %v", err) // Embedding failure does not block document processing } return nil } // SyncDocument re-fetches content from the external URL and updates the document. // Reference: Chatwoot Captain::DocumentSyncJob func (s *CaptainDocumentService) SyncDocument(ctx context.Context, id uint) error { doc, err := s.documentRepo.GetByID(ctx, id) if err != nil { return fmt.Errorf("document not found: %w", err) } nowAttempt := time.Now().Unix() doc.LastSyncAttemptedAt = &nowAttempt doc.SyncStatus = model.DocumentSyncStatusPending s.documentRepo.Update(ctx, doc) content, err := s.fetchContent(ctx, doc.ExternalLink) if err != nil { doc.SyncStatus = model.DocumentSyncStatusFailed doc.LastSyncErrorCode = "fetch_failed" s.documentRepo.Update(ctx, doc) applogger.L().Errorf("SyncDocument fetch: %v", err) return fmt.Errorf("sync content: %w", err) } fingerprint := computeFingerprint(content) // If content unchanged, mark as synced (no update needed) if fingerprint == doc.ContentFingerprint { now := time.Now().Unix() doc.LastSyncedAt = &now doc.SyncStatus = model.DocumentSyncStatusSynced s.documentRepo.Update(ctx, doc) return nil } // Content changed, update document doc.Content = content doc.ContentFingerprint = fingerprint now := time.Now().Unix() doc.LastSyncedAt = &now doc.SyncStatus = model.DocumentSyncStatusSynced doc.LastSyncErrorCode = "" if err := s.documentRepo.Update(ctx, doc); err != nil { applogger.L().Errorf("SyncDocument update: %v", err) return fmt.Errorf("update synced document: %w", err) } // Re-generate embedding for updated content _, err = s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{ Input: []string{content}, Model: "text-embedding-ada-002", }) if err != nil { applogger.L().Errorf("SyncDocument embedding: %v", err) } return nil } // fetchContent retrieves content from an external URL. func (s *CaptainDocumentService) fetchContent(ctx context.Context, url string) (string, error) { client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(url) if err != nil { return "", fmt.Errorf("HTTP GET %s: %w", url, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("HTTP GET %s returned status %d", url, resp.StatusCode) } // Limit response body to 1MB body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", fmt.Errorf("read response body: %w", err) } return strings.TrimSpace(string(body)), nil } // computeFingerprint generates a SHA256 hash of content for deduplication. func computeFingerprint(content string) string { h := sha256.New() h.Write([]byte(content)) return fmt.Sprintf("%x", h.Sum(nil)) }