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

259 lines
7.8 KiB
Go

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))
}