fix(captain): 试验场不查 FAQ 知识库 + 添加常见问题报 400
三个问题修复:
1. 添加常见问题报 400 (assistant_id 类型不匹配)
- CreateResponseDialog.vue: route.params.assistantId 是字符串,
传给后端 uint 字段导致 JSON 反序列化失败
- 修复: Number(route.params.assistantId) 转为数字
- 同时修复 POST /assistant_responses 无尾部斜杠 307 重定向问题
2. 试验场 playground 不做 RAG 检索
- generatePlaygroundLLMResponse 只构建 system prompt + 对话历史,
从不查 FAQ 知识库
- 新增 retrieveFAQContext(): embed 用户问题 → pgvector 搜索 approved
FAQ → 注入 system prompt
- 受 feature_faq 配置开关控制
3. FAQ embedding 无法写入 (pgvector 序列化 + 维度问题)
- pgvector stub 无 driver.Valuer, GORM Save() 报 SQLSTATE 42804
- 新增 UpdateEmbedding() 用 ?::vector 原始 SQL 绕过
- SimilaritySearch 排除 embedding 列 + 手动格式化向量字面量
- embedding 列从 vector(1536) 改为 vector (跟随模型维度)
- FAQ 创建/更新时自动索引 embedding (SetRAGService 注入)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
+2
-2
@@ -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`));
|
||||
|
||||
Reference in New Issue
Block a user