259 lines
7.8 KiB
Go
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))
|
|
} |