package service import ( "context" "crypto/sha256" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" "github.com/pgvector/pgvector-go" "gorm.io/gorm" ) // CaptainDocumentService implements business logic for CaptainDocument operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/documents_controller.rb type CaptainDocumentService struct { documentRepo *repository.CaptainDocumentRepo assistantRepo *repository.CaptainAssistantRepo responseRepo *repository.CaptainAssistantResponseRepo llmProvider llm.Provider syncBackend CaptainDocumentSyncBackend crawlBackend CaptainDocumentCrawlBackend pageParser CaptainDocumentPageParserBackend faqBackend CaptainDocumentFAQBackend embeddings CaptainDocumentEmbeddingBackend worker *worker.WorkerPool } type CaptainDocumentSyncBackend interface { SyncCaptainDocument(ctx context.Context, doc *model.CaptainDocument) (*CaptainDocumentSyncResult, error) } type CaptainDocumentSyncResult struct { Content string Title string ErrorCode string } type CaptainDocumentCrawlBackend interface { CrawlCaptainDocument(ctx context.Context, doc *model.CaptainDocument) (*CaptainDocumentCrawlResult, error) } type CaptainDocumentCrawlResult struct { PageLinks []string ErrorCode string } type CaptainDocumentPageParserBackend interface { ParseCaptainDocumentPage(ctx context.Context, pageLink string) (*CaptainDocumentSyncResult, error) } type CaptainDocumentFAQBackend interface { GenerateCaptainDocumentFAQs(ctx context.Context, doc *model.CaptainDocument) ([]CaptainDocumentFAQ, error) } type CaptainDocumentFAQ struct { Question string Answer string } type CaptainDocumentEmbeddingBackend interface { GenerateCaptainEmbedding(ctx context.Context, accountID uint, content string) (pgvector.Vector, error) } // NewCaptainDocumentService creates a new CaptainDocumentService. func NewCaptainDocumentService( documentRepo *repository.CaptainDocumentRepo, llmProvider llm.Provider, assistantRepo ...*repository.CaptainAssistantRepo, ) *CaptainDocumentService { s := &CaptainDocumentService{ documentRepo: documentRepo, llmProvider: llmProvider, } if len(assistantRepo) > 0 { s.assistantRepo = assistantRepo[0] } return s } func (s *CaptainDocumentService) SetSyncBackend(syncBackend CaptainDocumentSyncBackend) { s.syncBackend = syncBackend } func (s *CaptainDocumentService) SetCrawlBackend(crawlBackend CaptainDocumentCrawlBackend) { s.crawlBackend = crawlBackend } func (s *CaptainDocumentService) SetPageParserBackend(pageParser CaptainDocumentPageParserBackend) { s.pageParser = pageParser } func (s *CaptainDocumentService) SetResponseRepo(responseRepo *repository.CaptainAssistantResponseRepo) { s.responseRepo = responseRepo } func (s *CaptainDocumentService) SetFAQBackend(faqBackend CaptainDocumentFAQBackend) { s.faqBackend = faqBackend } func (s *CaptainDocumentService) SetEmbeddingBackend(embeddingBackend CaptainDocumentEmbeddingBackend) { s.embeddings = embeddingBackend } func (s *CaptainDocumentService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp RegisterCaptainDocumentJobs(wp, s) } // --- 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"` AssistantID uint `json:"assistant_id"` } // UpdateDocumentRequest is the DTO for updating a document. type UpdateDocumentRequest struct { Name string `json:"name"` ExternalLink string `json:"external_link"` } type ListDocumentsRequest struct { AssistantID uint Page int PerPage int Filter string Source string Sort string SearchKey string } // --- CRUD Operations --- // Create creates a new CaptainDocument. func (s *CaptainDocumentService) Create(ctx context.Context, assistantID, accountID uint, req *CreateDocumentRequest) (*model.CaptainDocument, error) { if s.assistantRepo != nil { if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } } 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) } if created, err := s.documentRepo.GetByAccountAndID(ctx, accountID, doc.ID); err == nil { if enqueueErr := s.enqueueDocumentCrawl(ctx, accountID, created.ID); enqueueErr != nil { return nil, enqueueErr } return created, nil } if enqueueErr := s.enqueueDocumentCrawl(ctx, accountID, doc.ID); enqueueErr != nil { return nil, enqueueErr } 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 } func (s *CaptainDocumentService) GetByAccount(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) { doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, 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 } func (s *CaptainDocumentService) DeleteByAccount(ctx context.Context, accountID, id uint) error { if _, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id); err != nil { return fmt.Errorf("document not found: %w", err) } if err := s.documentRepo.DeleteByAccount(ctx, accountID, 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 } func (s *CaptainDocumentService) ListByAccount(ctx context.Context, accountID uint, req ListDocumentsRequest) ([]model.CaptainDocument, int64, int, error) { page := req.Page if page <= 0 { page = 1 } perPage := req.PerPage if perPage <= 0 { perPage = 25 } docs, count, err := s.documentRepo.ListByAccount(ctx, accountID, repository.CaptainDocumentListFilters{ AssistantID: req.AssistantID, Source: req.Source, Filter: req.Filter, SearchKey: req.SearchKey, Sort: req.Sort, Offset: (page - 1) * perPage, Limit: perPage, }) if err != nil { applogger.L().Errorf("List captain documents: %v", err) return nil, 0, page, fmt.Errorf("list documents: %w", err) } return docs, count, page, nil } func (s *CaptainDocumentService) MarkSyncing(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) { doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { return nil, fmt.Errorf("document not found: %w", err) } now := time.Now().Unix() doc.LastSyncAttemptedAt = &now doc.SyncStatus = model.DocumentSyncStatusPending doc.LastSyncErrorCode = "" if err := s.documentRepo.Update(ctx, doc); err != nil { return nil, fmt.Errorf("mark document syncing: %w", err) } return s.documentRepo.GetByAccountAndID(ctx, accountID, id) } func (s *CaptainDocumentService) RequestSyncDocumentByAccount(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) { doc, err := s.MarkSyncing(ctx, accountID, id) if err != nil { return nil, err } if s.worker == nil { return doc, nil } if _, err := s.worker.Enqueue(ctx, TaskTypeCaptainDocumentSync, captainDocumentSyncJob{AccountID: accountID, DocumentID: id}, worker.WithQueue("low"), worker.WithMaxAttempts(3)); err != nil { return nil, err } return doc, nil } func (s *CaptainDocumentService) RequestCrawlDocumentByAccount(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) { doc, err := s.MarkSyncing(ctx, accountID, id) if err != nil { return nil, err } doc.Status = model.DocumentStatusInProgress if err := s.documentRepo.Update(ctx, doc); err != nil { return nil, fmt.Errorf("mark document crawling: %w", err) } if err := s.enqueueDocumentCrawl(ctx, accountID, id); err != nil { return nil, err } return s.documentRepo.GetByAccountAndID(ctx, accountID, id) } func (s *CaptainDocumentService) enqueueDocumentCrawl(ctx context.Context, accountID, id uint) error { if s.worker == nil { return nil } _, err := s.worker.Enqueue(ctx, TaskTypeCaptainDocumentCrawl, captainDocumentCrawlJob{AccountID: accountID, DocumentID: id}, worker.WithQueue("low"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("captain:document_crawl:%d:%d", accountID, id)), ) return err } func (s *CaptainDocumentService) ScheduleDueDocumentSyncs(ctx context.Context, now time.Time) (int, error) { if s.worker == nil { return 0, nil } docs, err := s.documentRepo.ListDueForAutoSync(ctx, now, 24*time.Hour, 10*time.Minute, 1000) if err != nil { return 0, fmt.Errorf("list due captain documents: %w", err) } enqueued := 0 for _, doc := range docs { if !s.captainDocumentAutoSyncEnabled(ctx, doc.AccountID) { continue } if _, err := s.worker.Enqueue(ctx, TaskTypeCaptainDocumentSync, captainDocumentSyncJob{AccountID: doc.AccountID, DocumentID: doc.ID}, worker.WithQueue("purgable"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("captain:document_sync:auto:%d:%d:%d", doc.AccountID, doc.ID, now.UTC().Truncate(24*time.Hour).Unix())), ); err != nil { return enqueued, err } enqueued++ } return enqueued, nil } func (s *CaptainDocumentService) captainDocumentAutoSyncEnabled(ctx context.Context, accountID uint) bool { var account model.Account if err := s.documentRepo.DB().WithContext(ctx).Select("feature_flags").First(&account, accountID).Error; err != nil { return false } return featureFlagStringEnabled(account.FeatureFlags, "captain_document_auto_sync") } func (s *CaptainDocumentService) CrawlDocumentByAccount(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) { doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { return nil, fmt.Errorf("document not found: %w", err) } if s.crawlBackend == nil { return s.markDocumentSyncFailed(ctx, accountID, id, "crawl_disabled") } result, err := s.crawlBackend.CrawlCaptainDocument(ctx, doc) if err != nil { updated, markErr := s.markDocumentSyncFailed(ctx, accountID, id, "crawl_error") if markErr != nil { return nil, markErr } return updated, fmt.Errorf("crawl document: %w", err) } if result == nil { return s.markDocumentSyncFailed(ctx, accountID, id, "crawl_error") } if result.ErrorCode != "" { return s.markDocumentSyncFailed(ctx, accountID, id, result.ErrorCode) } if s.worker == nil { return doc, fmt.Errorf("worker pool required for document crawl parser fan-out") } links := normalizedUniqueLinks(append(result.PageLinks, doc.ExternalLink)) for _, link := range links { if _, err := s.worker.Enqueue(ctx, TaskTypeCaptainDocumentPageCrawlParse, captainDocumentPageCrawlParseJob{AccountID: accountID, AssistantID: doc.AssistantID, PageLink: link}, worker.WithQueue("low"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("captain:document_page_crawl_parse:%d:%d:%s", accountID, doc.AssistantID, link)), ); err != nil { return doc, err } } return s.documentRepo.GetByAccountAndID(ctx, accountID, id) } func (s *CaptainDocumentService) ParseCrawledPage(ctx context.Context, accountID, assistantID uint, pageLink string) (*model.CaptainDocument, error) { pageLink = normalizeCaptainDocumentLink(pageLink) if accountID == 0 || assistantID == 0 || pageLink == "" { return nil, fmt.Errorf("invalid captain page crawl payload: account_id=%d assistant_id=%d page_link=%q", accountID, assistantID, pageLink) } if s.assistantRepo != nil { if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } } doc, err := s.documentRepo.FindByExternalLink(ctx, assistantID, pageLink) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } if s.pageParser == nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("page crawl parser disabled") } return s.markDocumentSyncFailed(ctx, accountID, doc.ID, "crawl_disabled") } result, parseErr := s.pageParser.ParseCaptainDocumentPage(ctx, pageLink) if parseErr != nil { if err == nil { _, _ = s.markDocumentSyncFailed(ctx, accountID, doc.ID, "fetch_failed") } return nil, fmt.Errorf("parse crawled page: %w", parseErr) } if result == nil || result.ErrorCode != "" || strings.TrimSpace(result.Content) == "" { code := "content_empty" if result != nil && result.ErrorCode != "" { code = result.ErrorCode } if err == nil { return s.markDocumentSyncFailed(ctx, accountID, doc.ID, code) } return nil, fmt.Errorf("parse crawled page failed: %s", code) } if errors.Is(err, gorm.ErrRecordNotFound) { doc = &model.CaptainDocument{AccountID: accountID, AssistantID: assistantID, ExternalLink: pageLink} } doc.Name = strings.TrimSpace(result.Title) if doc.Name == "" { doc.Name = pageLink } doc.Content = strings.TrimSpace(result.Content) doc.ContentFingerprint = computeFingerprint(doc.Content) doc.Status = model.DocumentStatusCompleted doc.SyncStatus = model.DocumentSyncStatusSynced doc.LastSyncErrorCode = "" now := time.Now().Unix() doc.LastSyncedAt = &now doc.LastSyncAttemptedAt = &now if doc.ID == 0 { if err := s.documentRepo.Create(ctx, doc); err != nil { return nil, fmt.Errorf("create crawled document: %w", err) } } else if err := s.documentRepo.Update(ctx, doc); err != nil { return nil, fmt.Errorf("update crawled document: %w", err) } if err := s.enqueueDocumentResponseBuilder(ctx, accountID, doc.ID); err != nil { return nil, err } return s.documentRepo.GetByAccountAndID(ctx, accountID, doc.ID) } func (s *CaptainDocumentService) SyncDocumentByAccount(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) { doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { return nil, fmt.Errorf("document not found: %w", err) } if err := s.markDocumentSyncStarted(ctx, doc); err != nil { return nil, err } if s.syncBackend == nil { return s.markDocumentSyncFailed(ctx, accountID, id, "sync_disabled") } result, err := s.syncBackend.SyncCaptainDocument(ctx, doc) if err != nil { updated, markErr := s.markDocumentSyncFailed(ctx, accountID, id, "sync_error") if markErr != nil { return nil, markErr } return updated, fmt.Errorf("sync document: %w", err) } if result == nil { return s.markDocumentSyncFailed(ctx, accountID, id, "sync_error") } if result.ErrorCode != "" { return s.markDocumentSyncFailed(ctx, accountID, id, result.ErrorCode) } if strings.TrimSpace(result.Content) == "" { return s.markDocumentSyncFailed(ctx, accountID, id, "content_empty") } doc.Content = strings.TrimSpace(result.Content) if strings.TrimSpace(result.Title) != "" { doc.Name = strings.TrimSpace(result.Title) } doc.ContentFingerprint = computeFingerprint(doc.Content) doc.Status = model.DocumentStatusCompleted doc.SyncStatus = model.DocumentSyncStatusSynced doc.LastSyncErrorCode = "" now := time.Now().Unix() doc.LastSyncedAt = &now doc.LastSyncAttemptedAt = &now if err := s.documentRepo.Update(ctx, doc); err != nil { return nil, fmt.Errorf("update synced document: %w", err) } if err := s.enqueueDocumentResponseBuilder(ctx, accountID, id); err != nil { return nil, err } return s.documentRepo.GetByAccountAndID(ctx, accountID, id) } func (s *CaptainDocumentService) enqueueDocumentResponseBuilder(ctx context.Context, accountID, id uint) error { if s.worker == nil || s.responseRepo == nil { return nil } _, err := s.worker.Enqueue(ctx, TaskTypeCaptainDocumentResponseBuilder, captainDocumentResponseBuilderJob{AccountID: accountID, DocumentID: id}, worker.WithQueue("low"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("captain:document_response_builder:%d:%d", accountID, id)), ) return err } func (s *CaptainDocumentService) BuildResponsesForDocumentByAccount(ctx context.Context, accountID, id uint) ([]model.CaptainAssistantResponse, error) { if s.responseRepo == nil { return nil, fmt.Errorf("captain response repository is required") } doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { return nil, fmt.Errorf("document not found: %w", err) } if strings.TrimSpace(doc.Content) == "" || doc.Status != model.DocumentStatusCompleted { return nil, fmt.Errorf("document is not ready for response building") } if err := s.responseRepo.DB().WithContext(ctx). Where("account_id = ? AND documentable_id = ? AND documentable_type IN ? AND edited = ?", accountID, doc.ID, []string{"Captain::Document", "CaptainDocument"}, false). Delete(&model.CaptainAssistantResponse{}).Error; err != nil { return nil, fmt.Errorf("reset previous document responses: %w", err) } if s.faqBackend == nil { return nil, fmt.Errorf("faq generation disabled") } faqs, err := s.faqBackend.GenerateCaptainDocumentFAQs(ctx, doc) if err != nil { return nil, fmt.Errorf("generate document faqs: %w", err) } created := make([]model.CaptainAssistantResponse, 0, len(faqs)) for _, faq := range faqs { question := strings.TrimSpace(faq.Question) answer := strings.TrimSpace(faq.Answer) if question == "" || answer == "" { continue } documentID := doc.ID resp := &model.CaptainAssistantResponse{ AccountID: accountID, AssistantID: doc.AssistantID, DocumentableID: &documentID, DocumentableType: "Captain::Document", Question: question, Answer: answer, Status: model.ResponseStatusApproved, Edited: false, } if err := s.responseRepo.Create(ctx, resp); err != nil { return created, fmt.Errorf("create document response: %w", err) } created = append(created, *resp) if err := s.enqueueResponseEmbedding(ctx, accountID, resp.ID, fmt.Sprintf("%s: %s", question, answer)); err != nil { return created, err } } return created, nil } func (s *CaptainDocumentService) enqueueResponseEmbedding(ctx context.Context, accountID, responseID uint, content string) error { if s.worker == nil { return nil } _, err := s.worker.Enqueue(ctx, TaskTypeCaptainLLMUpdateEmbedding, captainLLMUpdateEmbeddingJob{AccountID: accountID, ResponseID: responseID, Content: content}, worker.WithQueue("low"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("captain:llm_update_embedding:response:%d:%d", accountID, responseID)), ) return err } func (s *CaptainDocumentService) UpdateAssistantResponseEmbeddingByAccount(ctx context.Context, accountID, responseID uint, content string) (*model.CaptainAssistantResponse, error) { if s.responseRepo == nil { return nil, fmt.Errorf("captain response repository is required") } resp, err := s.responseRepo.GetByAccountAndID(ctx, accountID, responseID) if err != nil { return nil, fmt.Errorf("response not found: %w", err) } if strings.TrimSpace(content) == "" { content = fmt.Sprintf("%s: %s", resp.Question, resp.Answer) } embedding, err := s.generateResponseEmbedding(ctx, accountID, content) if err != nil { return nil, err } resp.Embedding = embedding if s.responseRepo.DB().Dialector != nil && s.responseRepo.DB().Dialector.Name() == "sqlite" { if err := s.responseRepo.DB().WithContext(ctx).Omit("Embedding").Save(resp).Error; err != nil { return nil, fmt.Errorf("update response embedding: %w", err) } return resp, nil } if err := s.responseRepo.Update(ctx, resp); err != nil { return nil, fmt.Errorf("update response embedding: %w", err) } return s.responseRepo.GetByAccountAndID(ctx, accountID, responseID) } func (s *CaptainDocumentService) generateResponseEmbedding(ctx context.Context, accountID uint, content string) (pgvector.Vector, error) { if s.embeddings != nil { return s.embeddings.GenerateCaptainEmbedding(ctx, accountID, content) } if s.llmProvider == nil { return pgvector.Vector{}, fmt.Errorf("embedding generation disabled") } result, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{Model: "text-embedding-3-small", Input: []string{content}}) if err != nil { return pgvector.Vector{}, fmt.Errorf("generate response embedding: %w", err) } if len(result.Data) == 0 { return pgvector.Vector{}, fmt.Errorf("no embedding returned") } values := make([]float32, len(result.Data[0].Embedding)) for i, value := range result.Data[0].Embedding { values[i] = float32(value) } return pgvector.NewVector(values), nil } func (s *CaptainDocumentService) markDocumentSyncStarted(ctx context.Context, doc *model.CaptainDocument) error { now := time.Now().Unix() doc.LastSyncAttemptedAt = &now doc.SyncStatus = model.DocumentSyncStatusPending doc.LastSyncErrorCode = "" if err := s.documentRepo.Update(ctx, doc); err != nil { return fmt.Errorf("mark document syncing: %w", err) } return nil } func (s *CaptainDocumentService) markDocumentSyncFailed(ctx context.Context, accountID, id uint, code string) (*model.CaptainDocument, error) { doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { return nil, fmt.Errorf("document not found: %w", err) } now := time.Now().Unix() doc.SyncStatus = model.DocumentSyncStatusFailed doc.LastSyncAttemptedAt = &now doc.LastSyncErrorCode = code if err := s.documentRepo.Update(ctx, doc); err != nil { return nil, fmt.Errorf("mark document sync failed: %w", err) } return s.documentRepo.GetByAccountAndID(ctx, accountID, id) } // --- 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() normalized := strings.Join(strings.Fields(content), " ") h.Write([]byte(normalized)) return fmt.Sprintf("%x", h.Sum(nil)) } func normalizedUniqueLinks(rawLinks []string) []string { seen := make(map[string]struct{}, len(rawLinks)) links := make([]string, 0, len(rawLinks)) for _, rawLink := range rawLinks { link := normalizeCaptainDocumentLink(rawLink) if link == "" { continue } if _, ok := seen[link]; ok { continue } seen[link] = struct{}{} links = append(links, link) } return links } func normalizeCaptainDocumentLink(rawLink string) string { return strings.TrimRight(strings.TrimSpace(rawLink), "/") }