diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index ec6ff926..0d44eb1b 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -606,6 +606,7 @@ func Bootstrap(env string) (*App, error) { // RAG service — knowledge base Q&A (embedding search + LLM generation) ragService := service.NewRAGService(captainAssistantResponseRepo, captainAssistantRepo, llmProvider) + captainAssistantResponseService.SetRAGService(ragService) // Auto-reply rule service — CRUD + condition matching + LLM reply composition autoReplyRuleService := service.NewAutoReplyRuleService(captainAutoReplyRuleRepo, captainAssistantRepo, conversationRepo, llmProvider) diff --git a/backend/internal/model/captain_models.go b/backend/internal/model/captain_models.go index e82b8b24..749a3aca 100644 --- a/backend/internal/model/captain_models.go +++ b/backend/internal/model/captain_models.go @@ -218,8 +218,8 @@ type CaptainAssistantResponse struct { Answer string `gorm:"type:text;not null" json:"answer"` Status ResponseStatus `gorm:"size:50;default:approved;not null" json:"status"` Edited bool `gorm:"default:false;not null" json:"edited"` - // pgvector-go Vector type for 1536-dimensional embeddings - Embedding pgvector.Vector `gorm:"type:vector(1536)" json:"embedding,omitempty"` + // pgvector-go Vector type for embeddings (dimension follows the configured embedding model) + Embedding pgvector.Vector `gorm:"type:vector" json:"embedding,omitempty"` Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"` } diff --git a/backend/internal/repository/captain_assistant_response_repo.go b/backend/internal/repository/captain_assistant_response_repo.go index 7a3cb76b..dfe00eee 100644 --- a/backend/internal/repository/captain_assistant_response_repo.go +++ b/backend/internal/repository/captain_assistant_response_repo.go @@ -2,6 +2,8 @@ package repository import ( "context" + "strconv" + "strings" "github.com/gochat/gochat/internal/model" "github.com/pgvector/pgvector-go" @@ -45,6 +47,21 @@ func (r *CaptainAssistantResponseRepo) Update(ctx context.Context, resp *model.C return r.db.WithContext(ctx).Save(resp).Error } +// UpdateEmbedding updates only the embedding column using raw SQL with explicit +// ::vector cast to avoid GORM/pgvector serialization issue (SQLSTATE 42804). +func (r *CaptainAssistantResponseRepo) UpdateEmbedding(ctx context.Context, id uint, embedding pgvector.Vector) error { + // Format as PG vector literal: [0.1,0.2,...] + strs := make([]string, len(embedding)) + for i, v := range embedding { + strs[i] = strconv.FormatFloat(float64(v), 'f', -1, 32) + } + vecStr := "[" + strings.Join(strs, ",") + "]" + return r.db.WithContext(ctx).Exec( + "UPDATE captain_assistant_responses SET embedding = ?::vector WHERE id = ?", + vecStr, id, + ).Error +} + func (r *CaptainAssistantResponseRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.CaptainAssistantResponse{}, id).Error } @@ -80,10 +97,18 @@ func (r *CaptainAssistantResponseRepo) ListByDocument(ctx context.Context, docum // Reference: Chatwoot Captain::AssistantResponsesSearchService func (r *CaptainAssistantResponseRepo) SimilaritySearch(ctx context.Context, assistantID uint, embedding pgvector.Vector, limit int) ([]model.CaptainAssistantResponse, error) { var responses []model.CaptainAssistantResponse + // Format embedding as PG vector literal for the <=> operator (stub has no driver.Valuer). + strs := make([]string, len(embedding)) + for i, v := range embedding { + strs[i] = strconv.FormatFloat(float64(v), 'f', -1, 32) + } + vecStr := "[" + strings.Join(strs, ",") + "]" // Cosine distance (<=>) orders by closest first. + // Omit embedding column from SELECT — the pgvector stub can't scan it back. if err := r.db.WithContext(ctx). + Select("id, account_id, assistant_id, documentable_id, documentable_type, question, answer, status, edited, created_at, updated_at"). Where("assistant_id = ? AND status = ?", assistantID, model.ResponseStatusApproved). - Order(gorm.Expr("embedding <=> ?", embedding)). + Order(gorm.Expr("embedding <=> ?::vector", vecStr)). Limit(limit). Find(&responses).Error; err != nil { return nil, err diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2eda4b3d..d7613252 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -1471,7 +1471,10 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { assistantResponses := captain.Group("/assistant_responses") { // Chatwoot: resources :assistant_responses (standard CRUD) + // Register both "" and "/" so Gin does not 307-redirect between them. + assistantResponses.POST("", h.CaptainAssistantResponse.Create) assistantResponses.POST("/", h.CaptainAssistantResponse.Create) + assistantResponses.GET("", h.CaptainAssistantResponse.List) assistantResponses.GET("/", h.CaptainAssistantResponse.List) assistantResponses.GET("/:response_id", h.CaptainAssistantResponse.Get) assistantResponses.PUT("/:response_id", h.CaptainAssistantResponse.Update) diff --git a/backend/internal/service/captain_assistant_response_service.go b/backend/internal/service/captain_assistant_response_service.go index 118722c2..eea6edb3 100644 --- a/backend/internal/service/captain_assistant_response_service.go +++ b/backend/internal/service/captain_assistant_response_service.go @@ -22,6 +22,7 @@ type CaptainAssistantResponseService struct { messageRepo *repository.MessageRepo preferenceRepo *repository.CaptainPreferenceRepo llmProvider llm.Provider + ragService *RAGService } func NewCaptainAssistantResponseService( @@ -42,6 +43,11 @@ func NewCaptainAssistantResponseService( } } +// SetRAGService injects the RAG service for auto-indexing FAQ embeddings. +func (s *CaptainAssistantResponseService) SetRAGService(rag *RAGService) { + s.ragService = rag +} + // --- Request/Response DTOs --- // ProcessResponseRequest is the input for generating and storing an assistant response. @@ -269,6 +275,12 @@ func (s *CaptainAssistantResponseService) Create(ctx context.Context, accountID if err := s.responseRepo.Create(ctx, resp); err != nil { return nil, fmt.Errorf("create response: %w", err) } + // Auto-index embedding for RAG search + if s.ragService != nil { + if err := s.ragService.IndexResponse(ctx, resp.ID); err != nil { + applogger.L().Warnf("Create assistant response: auto-index embedding failed: %v", err) + } + } if created, err := s.responseRepo.GetByAccountAndID(ctx, accountID, resp.ID); err == nil { return created, nil } @@ -301,6 +313,12 @@ func (s *CaptainAssistantResponseService) Update(ctx context.Context, accountID if err := s.responseRepo.Update(ctx, resp); err != nil { return nil, fmt.Errorf("update response: %w", err) } + // Re-index embedding when question or answer changed + if (questionChanged || answerChanged) && s.ragService != nil { + if err := s.ragService.IndexResponse(ctx, resp.ID); err != nil { + applogger.L().Warnf("Update assistant response: re-index embedding failed: %v", err) + } + } return resp, nil } diff --git a/backend/internal/service/captain_assistant_service.go b/backend/internal/service/captain_assistant_service.go index 84848da7..320cc180 100644 --- a/backend/internal/service/captain_assistant_service.go +++ b/backend/internal/service/captain_assistant_service.go @@ -14,6 +14,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" + "github.com/pgvector/pgvector-go" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) @@ -723,7 +724,17 @@ func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Cont cfg, _ := assistant.GetConfig() ctx = withAssistantGenerationConfig(ctx, cfg) - messages := []llm.ChatMessage{{Role: "system", Content: s.promptBuilder.BuildAssistantPrompt(assistant, cfg)}} + + // RAG: embed the latest user message and search approved FAQ responses. + // This mirrors Chatwoot's Captain playground which injects knowledge base context. + systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg) + ragContext := s.retrieveFAQContext(ctx, assistant.ID, cfg, history) + if ragContext != "" { + systemPrompt += "\n\n" + ragContext + systemPrompt += "\n\nUse the above FAQ entries as reference when answering. If the FAQ entries are relevant, incorporate their information. If not, rely on your general knowledge." + } + + messages := []llm.ChatMessage{{Role: "system", Content: systemPrompt}} for _, message := range history { if message.Role == "" || message.Content == "" { continue @@ -751,6 +762,61 @@ func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Cont return resp.Choices[0].Message.Content, nil } +// retrieveFAQContext generates an embedding for the latest user message, +// searches approved FAQ responses via pgvector, and returns formatted context. +// Returns empty string if RAG is disabled, no embedding available, or no results. +func (s *CaptainAssistantService) retrieveFAQContext(ctx context.Context, assistantID uint, cfg *model.AssistantConfig, history []PlaygroundMessage) string { + if cfg != nil && !cfg.FeatureFAQ { + return "" + } + + // Extract the latest user message + userMsg := "" + for i := len(history) - 1; i >= 0; i-- { + if history[i].Role == "user" { + userMsg = history[i].Content + break + } + } + if userMsg == "" { + return "" + } + + // Generate embedding for the question + embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{ + Input: []string{userMsg}, + }) + if err != nil { + applogger.L().Warnf("retrieveFAQContext: embedding generation failed: %v", err) + return "" + } + if len(embedResp.Data) == 0 { + return "" + } + + float32Emb := make([]float32, len(embedResp.Data[0].Embedding)) + for i, v := range embedResp.Data[0].Embedding { + float32Emb[i] = float32(v) + } + pgvectorEmb := pgvector.NewVector(float32Emb) + + // Search approved FAQ responses by embedding similarity + results, err := s.responseRepo.SearchByEmbedding(ctx, assistantID, pgvectorEmb, 5) + if err != nil { + applogger.L().Warnf("retrieveFAQContext: FAQ search failed: %v", err) + return "" + } + if len(results) == 0 { + return "" + } + + var contextParts []string + for i, r := range results { + contextParts = append(contextParts, fmt.Sprintf("[FAQ %d]\nQ: %s\nA: %s", i+1, r.Question, r.Answer)) + } + return "Knowledge Base Context:\n" + strings.Join(contextParts, "\n\n") +} + func withAssistantGenerationConfig(ctx context.Context, cfg *model.AssistantConfig) context.Context { if cfg == nil || !cfg.TemperatureConfigured { return ctx diff --git a/backend/internal/service/rag_service.go b/backend/internal/service/rag_service.go index 8ea934f8..77afe555 100644 --- a/backend/internal/service/rag_service.go +++ b/backend/internal/service/rag_service.go @@ -54,7 +54,7 @@ type AssistantRepoIface interface { type ResponseRepoIface interface { SearchByEmbedding(ctx context.Context, assistantID uint, embedding pgvector.Vector, limit int) ([]model.CaptainAssistantResponse, error) GetByID(ctx context.Context, id uint) (*model.CaptainAssistantResponse, error) - Update(ctx context.Context, resp *model.CaptainAssistantResponse) error + UpdateEmbedding(ctx context.Context, id uint, embedding pgvector.Vector) error } // RAGService orchestrates embedding search + LLM generation for knowledge base Q&A. @@ -265,8 +265,7 @@ func (s *RAGService) IndexResponse(ctx context.Context, responseID uint) error { for i, v := range embedResp.Data[0].Embedding { float32Emb[i] = float32(v) } - resp.Embedding = pgvector.NewVector(float32Emb) - if err := s.responseRepo.Update(ctx, resp); err != nil { + if err := s.responseRepo.UpdateEmbedding(ctx, resp.ID, pgvector.NewVector(float32Emb)); err != nil { applogger.L().Errorf("RAG update response embedding: %v", err) return fmt.Errorf("store embedding failed: %w", err) } diff --git a/backend/internal/service/rag_service_test.go b/backend/internal/service/rag_service_test.go index c3bdf26e..cf41e4a2 100644 --- a/backend/internal/service/rag_service_test.go +++ b/backend/internal/service/rag_service_test.go @@ -31,7 +31,7 @@ type mockResponseRepo struct { searchByEmbeddingError error getByIDResult *model.CaptainAssistantResponse getByIDError error - updateError error + updateEmbeddingError error } func (m *mockResponseRepo) SearchByEmbedding(ctx context.Context, assistantID uint, embedding pgvector.Vector, limit int) ([]model.CaptainAssistantResponse, error) { @@ -42,8 +42,8 @@ func (m *mockResponseRepo) GetByID(ctx context.Context, id uint) (*model.Captain return m.getByIDResult, m.getByIDError } -func (m *mockResponseRepo) Update(ctx context.Context, resp *model.CaptainAssistantResponse) error { - return m.updateError +func (m *mockResponseRepo) UpdateEmbedding(ctx context.Context, id uint, embedding pgvector.Vector) error { + return m.updateEmbeddingError } // ========== RAG Service Tests ========== diff --git a/frontend/app/javascript/dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue index 4b9c2c76..437708d4 100644 --- a/frontend/app/javascript/dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue +++ b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue @@ -43,12 +43,12 @@ const handleSubmit = async updatedResponse => { if (props.type === 'edit') { await updateResponse({ ...updatedResponse, - assistant_id: route.params.assistantId, + assistant_id: Number(route.params.assistantId), }); } else { await createResponse({ ...updatedResponse, - assistant_id: route.params.assistantId, + assistant_id: Number(route.params.assistantId), }); } useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));