diff --git a/client/faq.go b/client/faq.go index 035f596b..9ac537c0 100644 --- a/client/faq.go +++ b/client/faq.go @@ -105,6 +105,14 @@ type FAQSearchResponse struct { Code string `json:"code,omitempty"` } +// FAQEntryResponse wraps the single FAQ entry creation response. +type FAQEntryResponse struct { + Success bool `json:"success"` + Data *FAQEntry `json:"data"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + type faqSimpleResponse struct { Success bool `json:"success"` Message string `json:"message,omitempty"` @@ -165,6 +173,23 @@ func (c *Client) UpsertFAQEntries(ctx context.Context, return response.Data.TaskID, nil } +// CreateFAQEntry creates a single FAQ entry synchronously. +func (c *Client) CreateFAQEntry(ctx context.Context, + knowledgeBaseID string, payload *FAQEntryPayload, +) (*FAQEntry, error) { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entry", knowledgeBaseID) + resp, err := c.doRequest(ctx, http.MethodPost, path, payload, nil) + if err != nil { + return nil, err + } + + var response FAQEntryResponse + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + return response.Data, nil +} + // UpdateFAQEntry updates a single FAQ entry. func (c *Client) UpdateFAQEntry(ctx context.Context, knowledgeBaseID, entryID string, payload *FAQEntryPayload, diff --git a/docs/API.md b/docs/API.md index a289790b..d608467b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1535,6 +1535,7 @@ curl --location --request DELETE 'http://localhost:8080/api/v1/knowledge-bases/k | ------ | ------------------------------------------- | ------------------------ | | GET | `/knowledge-bases/:id/faq/entries` | 获取FAQ条目列表 | | POST | `/knowledge-bases/:id/faq/entries` | 批量导入FAQ条目 | +| POST | `/knowledge-bases/:id/faq/entry` | 创建单个FAQ条目 | | PUT | `/knowledge-bases/:id/faq/entries/:entry_id`| 更新单个FAQ条目 | | PUT | `/knowledge-bases/:id/faq/entries/status` | 批量更新FAQ启用状态 | | PUT | `/knowledge-bases/:id/faq/entries/tags` | 批量更新FAQ标签 | @@ -1631,6 +1632,69 @@ curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/faq/en 注:批量导入为异步操作,返回任务ID用于追踪进度。 +#### POST `/knowledge-bases/:id/faq/entry` - 创建单个FAQ条目 + +同步创建单个FAQ条目,适用于单条录入场景。会自动检查标准问和相似问是否与已有FAQ重复。 + +**请求参数**: +- `standard_question`: 标准问(必填) +- `similar_questions`: 相似问数组(可选) +- `negative_questions`: 反例问题数组(可选) +- `answers`: 答案数组(必填) +- `tag_id`: 标签ID(可选) +- `is_enabled`: 是否启用(可选,默认true) + +**请求**: + +```curl +curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/faq/entry' \ +--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \ +--header 'Content-Type: application/json' \ +--data '{ + "standard_question": "如何联系客服?", + "similar_questions": ["客服电话", "在线客服"], + "answers": ["您可以通过拨打400-xxx-xxxx联系我们的客服。"], + "tag_id": "tag-00000001", + "is_enabled": true +}' +``` + +**响应**: + +```json +{ + "data": { + "id": "faq-00000001", + "chunk_id": "chunk-00000001", + "knowledge_id": "knowledge-00000001", + "knowledge_base_id": "kb-00000001", + "tag_id": "tag-00000001", + "is_enabled": true, + "standard_question": "如何联系客服?", + "similar_questions": ["客服电话", "在线客服"], + "negative_questions": [], + "answers": ["您可以通过拨打400-xxx-xxxx联系我们的客服。"], + "index_mode": "hybrid", + "chunk_type": "faq", + "created_at": "2025-08-12T10:00:00+08:00", + "updated_at": "2025-08-12T10:00:00+08:00" + }, + "success": true +} +``` + +**错误响应**(标准问或相似问重复时): + +```json +{ + "success": false, + "error": { + "code": "BAD_REQUEST", + "message": "标准问与已有FAQ重复" + } +} +``` + #### PUT `/knowledge-bases/:id/faq/entries/:entry_id` - 更新单个FAQ条目 **请求**: diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index 17aac945..9e9ae5b7 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -154,6 +154,10 @@ export function upsertFAQEntries(kbId: string, data: { entries: any[]; mode: 'ap return post(`/api/v1/knowledge-bases/${kbId}/faq/entries`, data); } +export function createFAQEntry(kbId: string, data: any) { + return post(`/api/v1/knowledge-bases/${kbId}/faq/entry`, data); +} + export function updateFAQEntry(kbId: string, entryId: string, data: any) { return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/${entryId}`, data); } diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts index 6c838d60..e1861a8f 100644 --- a/frontend/src/utils/request.ts +++ b/frontend/src/utils/request.ts @@ -168,7 +168,16 @@ instance.interceptors.response.use( const { status, data } = error.response; // 将HTTP状态码一并抛出,方便上层判断401等场景 - return Promise.reject({ status, ...(typeof data === 'object' ? data : { message: data }) }); + // 后端返回格式: { success: false, error: { code, message, details } } + // 提取 error.message 作为顶层 message,方便前端使用 error?.message 获取 + const errorMessage = typeof data === 'object' && data?.error?.message + ? data.error.message + : (typeof data === 'object' ? data?.message : data); + return Promise.reject({ + status, + message: errorMessage, + ...(typeof data === 'object' ? data : {}) + }); } ); diff --git a/frontend/src/views/knowledge/components/FAQEntryManager.vue b/frontend/src/views/knowledge/components/FAQEntryManager.vue index 500914b5..6e9fb1f4 100644 --- a/frontend/src/views/knowledge/components/FAQEntryManager.vue +++ b/frontend/src/views/knowledge/components/FAQEntryManager.vue @@ -1087,6 +1087,7 @@ import { useRouter } from 'vue-router' import { listFAQEntries, upsertFAQEntries, + createFAQEntry, updateFAQEntry, updateFAQEntryStatusBatch, deleteFAQEntries, @@ -1908,10 +1909,7 @@ const handleSubmitEntry = async () => { tag_id: editorForm.tag_id || '', } if (editorMode.value === 'create') { - await upsertFAQEntries(props.kbId, { - entries: [payload], - mode: 'append', - }) + await createFAQEntry(props.kbId, payload) MessagePlugin.success(t('knowledgeEditor.messages.createSuccess')) } else if (currentEntryId.value) { await updateFAQEntry(props.kbId, currentEntryId.value, payload) diff --git a/internal/application/repository/chunk.go b/internal/application/repository/chunk.go index b80afdaa..7159607b 100644 --- a/internal/application/repository/chunk.go +++ b/internal/application/repository/chunk.go @@ -251,3 +251,45 @@ func (r *chunkRepository) ListAllFAQChunksByKnowledgeID( return allChunks, nil } + +// ListAllFAQChunksWithMetadataByKnowledgeBaseID lists all FAQ chunks for a knowledge base ID +// Returns ID and Metadata fields for duplicate question checking +// Uses batch query to handle large datasets +func (r *chunkRepository) ListAllFAQChunksWithMetadataByKnowledgeBaseID( + ctx context.Context, + tenantID uint64, + kbID string, +) ([]*types.Chunk, error) { + const batchSize = 1000 // 每批查询1000条 + var allChunks []*types.Chunk + offset := 0 + + for { + var batchChunks []*types.Chunk + if err := r.db.WithContext(ctx). + Select("id, metadata"). + Where("tenant_id = ? AND knowledge_base_id = ? AND chunk_type = ? AND status = ?", + tenantID, kbID, types.ChunkTypeFAQ, types.ChunkStatusIndexed). + Offset(offset). + Limit(batchSize). + Find(&batchChunks).Error; err != nil { + return nil, err + } + + // 如果没有查询到数据,说明已经查询完毕 + if len(batchChunks) == 0 { + break + } + + allChunks = append(allChunks, batchChunks...) + + // 如果返回的数据少于批次大小,说明已经是最后一批 + if len(batchChunks) < batchSize { + break + } + + offset += batchSize + } + + return allChunks, nil +} diff --git a/internal/application/service/knowledge.go b/internal/application/service/knowledge.go index 0190ce15..071264f4 100644 --- a/internal/application/service/knowledge.go +++ b/internal/application/service/knowledge.go @@ -2065,16 +2065,88 @@ func (s *knowledgeService) UpsertFAQEntries(ctx context.Context, } // calculateAppendOperations 计算Append模式下需要处理的条目,跳过已存在且内容相同的条目 +// 同时过滤掉标准问或相似问与同批次或已有知识库中重复的条目 func (s *knowledgeService) calculateAppendOperations(ctx context.Context, - tenantID uint64, knowledgeID string, entries []types.FAQEntryPayload, + tenantID uint64, kbID string, entries []types.FAQEntryPayload, ) ([]types.FAQEntryPayload, int, error) { if len(entries) == 0 { return []types.FAQEntryPayload{}, 0, nil } - return entries, 0, nil + + // 1. 查询知识库中已有的所有FAQ chunks的metadata + existingChunks, err := s.chunkRepo.ListAllFAQChunksWithMetadataByKnowledgeBaseID(ctx, tenantID, kbID) + if err != nil { + return nil, 0, fmt.Errorf("failed to list existing FAQ chunks: %w", err) + } + + // 2. 构建已存在的标准问和相似问集合 + existingQuestions := make(map[string]bool) + for _, chunk := range existingChunks { + meta, err := chunk.FAQMetadata() + if err != nil || meta == nil { + continue + } + // 添加标准问 + if meta.StandardQuestion != "" { + existingQuestions[meta.StandardQuestion] = true + } + // 添加相似问 + for _, q := range meta.SimilarQuestions { + if q != "" { + existingQuestions[q] = true + } + } + } + + // 3. 构建当前批次的标准问和相似问集合(用于批次内去重) + batchQuestions := make(map[string]bool) + entriesToProcess := make([]types.FAQEntryPayload, 0, len(entries)) + skippedCount := 0 + + for _, entry := range entries { + meta, err := sanitizeFAQEntryPayload(&entry) + if err != nil { + // 跳过无效条目 + skippedCount++ + logger.Warnf(ctx, "Skipping invalid FAQ entry: %v", err) + continue + } + + // 检查标准问是否重复(与已有或同批次) + if existingQuestions[meta.StandardQuestion] || batchQuestions[meta.StandardQuestion] { + skippedCount++ + logger.Infof(ctx, "Skipping FAQ entry with duplicate standard question: %s", meta.StandardQuestion) + continue + } + + // 检查相似问是否有重复(与已有或同批次) + hasDuplicateSimilar := false + for _, q := range meta.SimilarQuestions { + if existingQuestions[q] || batchQuestions[q] { + hasDuplicateSimilar = true + logger.Infof(ctx, "Skipping FAQ entry with duplicate similar question: %s (standard: %s)", q, meta.StandardQuestion) + break + } + } + if hasDuplicateSimilar { + skippedCount++ + continue + } + + // 将当前条目的标准问和相似问加入批次集合 + batchQuestions[meta.StandardQuestion] = true + for _, q := range meta.SimilarQuestions { + batchQuestions[q] = true + } + + entriesToProcess = append(entriesToProcess, entry) + } + + return entriesToProcess, skippedCount, nil } // calculateReplaceOperations 计算Replace模式下需要删除、创建、更新的条目 +// 同时过滤掉同批次内标准问或相似问重复的条目 func (s *knowledgeService) calculateReplaceOperations(ctx context.Context, tenantID uint64, knowledgeID string, newEntries []types.FAQEntryPayload, ) ([]types.FAQEntryPayload, []*types.Chunk, int, error) { @@ -2082,18 +2154,52 @@ func (s *knowledgeService) calculateReplaceOperations(ctx context.Context, type entryWithHash struct { entry types.FAQEntryPayload hash string + meta *types.FAQChunkMetadata } entriesWithHash := make([]entryWithHash, 0, len(newEntries)) newHashSet := make(map[string]bool) + // 用于批次内标准问和相似问去重 + batchQuestions := make(map[string]bool) + batchSkippedCount := 0 for _, entry := range newEntries { meta, err := sanitizeFAQEntryPayload(&entry) if err != nil { - return nil, nil, 0, fmt.Errorf("failed to sanitize entry: %w", err) + batchSkippedCount++ + logger.Warnf(ctx, "Skipping invalid FAQ entry in replace mode: %v", err) + continue } + + // 检查标准问是否在同批次中重复 + if batchQuestions[meta.StandardQuestion] { + batchSkippedCount++ + logger.Infof(ctx, "Skipping FAQ entry with duplicate standard question in batch: %s", meta.StandardQuestion) + continue + } + + // 检查相似问是否在同批次中重复 + hasDuplicateSimilar := false + for _, q := range meta.SimilarQuestions { + if batchQuestions[q] { + hasDuplicateSimilar = true + logger.Infof(ctx, "Skipping FAQ entry with duplicate similar question in batch: %s (standard: %s)", q, meta.StandardQuestion) + break + } + } + if hasDuplicateSimilar { + batchSkippedCount++ + continue + } + + // 将当前条目的标准问和相似问加入批次集合 + batchQuestions[meta.StandardQuestion] = true + for _, q := range meta.SimilarQuestions { + batchQuestions[q] = true + } + hash := types.CalculateFAQContentHash(meta) if hash != "" { - entriesWithHash = append(entriesWithHash, entryWithHash{entry: entry, hash: hash}) + entriesWithHash = append(entriesWithHash, entryWithHash{entry: entry, hash: hash, meta: meta}) newHashSet[hash] = true } } @@ -2126,7 +2232,7 @@ func (s *knowledgeService) calculateReplaceOperations(ctx context.Context, // 计算需要创建的条目(利用已经计算好的hash,避免重复计算) entriesToProcess := make([]types.FAQEntryPayload, 0, len(entriesWithHash)) - skippedCount := 0 + skippedCount := batchSkippedCount for _, ewh := range entriesWithHash { if existingHashMap[ewh.hash] != nil { @@ -2220,7 +2326,7 @@ func (s *knowledgeService) executeFAQImport(ctx context.Context, taskID string, } } else { // Append模式:查询已存在的条目,跳过未变化的 - entriesToProcess, skippedCount, err = s.calculateAppendOperations(ctx, tenantID, faqKnowledge.ID, payload.Entries) + entriesToProcess, skippedCount, err = s.calculateAppendOperations(ctx, tenantID, kb.ID, payload.Entries) if err != nil { return fmt.Errorf("failed to calculate append operations: %w", err) } @@ -2385,6 +2491,100 @@ func (s *knowledgeService) executeFAQImport(ctx context.Context, taskID string, return nil } +// CreateFAQEntry creates a single FAQ entry synchronously. +func (s *knowledgeService) CreateFAQEntry(ctx context.Context, + kbID string, payload *types.FAQEntryPayload, +) (*types.FAQEntry, error) { + if payload == nil { + return nil, werrors.NewBadRequestError("请求体不能为空") + } + + kb, err := s.validateFAQKnowledgeBase(ctx, kbID) + if err != nil { + return nil, err + } + kb.EnsureDefaults() + + tenantID := ctx.Value(types.TenantIDContextKey).(uint64) + + // 验证并清理输入 + meta, err := sanitizeFAQEntryPayload(payload) + if err != nil { + return nil, err + } + + // 检查标准问和相似问是否与其他条目重复 + if err := s.checkFAQQuestionDuplicate(ctx, tenantID, kb.ID, "", meta); err != nil { + return nil, err + } + + // 确保FAQ Knowledge存在 + faqKnowledge, err := s.ensureFAQKnowledge(ctx, tenantID, kb) + if err != nil { + return nil, fmt.Errorf("failed to ensure FAQ knowledge: %w", err) + } + + // 获取索引模式 + indexMode := types.FAQIndexModeQuestionOnly + if kb.FAQConfig != nil && kb.FAQConfig.IndexMode != "" { + indexMode = kb.FAQConfig.IndexMode + } + + // 获取embedding模型 + embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID) + if err != nil { + return nil, fmt.Errorf("failed to get embedding model: %w", err) + } + + // 创建chunk + isEnabled := true + if payload.IsEnabled != nil { + isEnabled = *payload.IsEnabled + } + + chunk := &types.Chunk{ + ID: uuid.New().String(), + TenantID: tenantID, + KnowledgeID: faqKnowledge.ID, + KnowledgeBaseID: kb.ID, + Content: buildFAQChunkContent(meta, indexMode), + IsEnabled: isEnabled, + ChunkType: types.ChunkTypeFAQ, + TagID: payload.TagID, + Status: int(types.ChunkStatusStored), + } + + if err := chunk.SetFAQMetadata(meta); err != nil { + return nil, fmt.Errorf("failed to set FAQ metadata: %w", err) + } + + // 保存chunk + if err := s.chunkService.CreateChunks(ctx, []*types.Chunk{chunk}); err != nil { + return nil, fmt.Errorf("failed to create chunk: %w", err) + } + + // 索引chunk + if err := s.indexFAQChunks(ctx, kb, faqKnowledge, []*types.Chunk{chunk}, embeddingModel, true, false); err != nil { + // 如果索引失败,删除已创建的chunk + _ = s.chunkService.DeleteChunk(ctx, chunk.ID) + return nil, fmt.Errorf("failed to index chunk: %w", err) + } + + // 更新chunk状态为已索引 + chunk.Status = int(types.ChunkStatusIndexed) + if err := s.chunkService.UpdateChunk(ctx, chunk); err != nil { + return nil, fmt.Errorf("failed to update chunk status: %w", err) + } + + // 转换为FAQEntry返回 + entry, err := s.chunkToFAQEntry(chunk, kb) + if err != nil { + return nil, err + } + + return entry, nil +} + // UpdateFAQEntry updates a single FAQ entry. func (s *knowledgeService) UpdateFAQEntry(ctx context.Context, kbID string, entryID string, payload *types.FAQEntryPayload, @@ -2412,6 +2612,12 @@ func (s *knowledgeService) UpdateFAQEntry(ctx context.Context, if err != nil { return err } + + // 检查标准问和相似问是否与其他条目重复 + if err := s.checkFAQQuestionDuplicate(ctx, tenantID, kb.ID, entryID, meta); err != nil { + return err + } + if existing, err := chunk.FAQMetadata(); err == nil && existing != nil { meta.Version = existing.Version + 1 } @@ -3095,6 +3301,65 @@ func buildFAQChunkContent(meta *types.FAQChunkMetadata, mode types.FAQIndexMode) return builder.String() } +// checkFAQQuestionDuplicate 检查标准问和相似问是否与知识库中其他条目重复 +// excludeChunkID 用于排除当前正在编辑的条目(更新时使用) +func (s *knowledgeService) checkFAQQuestionDuplicate( + ctx context.Context, + tenantID uint64, + kbID string, + excludeChunkID string, + meta *types.FAQChunkMetadata, +) error { + // 查询知识库中已有的所有FAQ chunks的metadata + existingChunks, err := s.chunkRepo.ListAllFAQChunksWithMetadataByKnowledgeBaseID(ctx, tenantID, kbID) + if err != nil { + return fmt.Errorf("failed to list existing FAQ chunks: %w", err) + } + + // 构建已存在的标准问和相似问集合 + for _, chunk := range existingChunks { + // 排除当前正在编辑的条目 + if chunk.ID == excludeChunkID { + continue + } + + existingMeta, err := chunk.FAQMetadata() + if err != nil || existingMeta == nil { + continue + } + + // 检查标准问是否重复 + if existingMeta.StandardQuestion == meta.StandardQuestion { + return werrors.NewBadRequestError(fmt.Sprintf("标准问「%s」已存在", meta.StandardQuestion)) + } + + // 检查当前标准问是否与已有相似问重复 + for _, q := range existingMeta.SimilarQuestions { + if q == meta.StandardQuestion { + return werrors.NewBadRequestError(fmt.Sprintf("标准问「%s」与已有相似问重复", meta.StandardQuestion)) + } + } + + // 检查当前相似问是否与已有标准问重复 + for _, q := range meta.SimilarQuestions { + if q == existingMeta.StandardQuestion { + return werrors.NewBadRequestError(fmt.Sprintf("相似问「%s」与已有标准问重复", q)) + } + } + + // 检查当前相似问是否与已有相似问重复 + for _, q := range meta.SimilarQuestions { + for _, existingQ := range existingMeta.SimilarQuestions { + if q == existingQ { + return werrors.NewBadRequestError(fmt.Sprintf("相似问「%s」已存在", q)) + } + } + } + } + + return nil +} + func sanitizeFAQEntryPayload(payload *types.FAQEntryPayload) (*types.FAQChunkMetadata, error) { meta := &types.FAQChunkMetadata{ StandardQuestion: strings.TrimSpace(payload.StandardQuestion), diff --git a/internal/handler/faq.go b/internal/handler/faq.go index bc891281..27a1b1a9 100644 --- a/internal/handler/faq.go +++ b/internal/handler/faq.go @@ -73,6 +73,29 @@ func (h *FAQHandler) UpsertEntries(c *gin.Context) { }) } +// CreateEntry creates a single FAQ entry synchronously. +func (h *FAQHandler) CreateEntry(c *gin.Context) { + ctx := c.Request.Context() + var req types.FAQEntryPayload + if err := c.ShouldBindJSON(&req); err != nil { + logger.Error(ctx, "Failed to bind FAQ entry payload", err) + c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error())) + return + } + + entry, err := h.knowledgeService.CreateFAQEntry(ctx, secutils.SanitizeForLog(c.Param("id")), &req) + if err != nil { + logger.ErrorWithFields(ctx, err, nil) + c.Error(err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": entry, + }) +} + // UpdateEntry updates a single FAQ entry. func (h *FAQHandler) UpdateEntry(c *gin.Context) { ctx := c.Request.Context() diff --git a/internal/router/router.go b/internal/router/router.go index 30342444..df9585a0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -162,6 +162,7 @@ func RegisterFAQRoutes(r *gin.RouterGroup, handler *handler.FAQHandler) { { faq.GET("/entries", handler.ListEntries) faq.POST("/entries", handler.UpsertEntries) + faq.POST("/entry", handler.CreateEntry) faq.PUT("/entries/:entry_id", handler.UpdateEntry) faq.PUT("/entries/status", handler.UpdateEntryStatusBatch) faq.PUT("/entries/tags", handler.UpdateEntryTagBatch) diff --git a/internal/types/interfaces/chunk.go b/internal/types/interfaces/chunk.go index 2558f8c1..871475ba 100644 --- a/internal/types/interfaces/chunk.go +++ b/internal/types/interfaces/chunk.go @@ -47,6 +47,9 @@ type ChunkRepository interface { // ListAllFAQChunksByKnowledgeID lists all FAQ chunks for a knowledge ID // only ID and ContentHash fields for efficiency ListAllFAQChunksByKnowledgeID(ctx context.Context, tenantID uint64, knowledgeID string) ([]*types.Chunk, error) + // ListAllFAQChunksWithMetadataByKnowledgeBaseID lists all FAQ chunks for a knowledge base ID + // returns ID and Metadata fields for duplicate question checking + ListAllFAQChunksWithMetadataByKnowledgeBaseID(ctx context.Context, tenantID uint64, kbID string) ([]*types.Chunk, error) } // ChunkService defines the interface for chunk service operations diff --git a/internal/types/interfaces/knowledge.go b/internal/types/interfaces/knowledge.go index 486126b2..d5a27b3c 100644 --- a/internal/types/interfaces/knowledge.go +++ b/internal/types/interfaces/knowledge.go @@ -79,6 +79,8 @@ type KnowledgeService interface { // UpsertFAQEntries imports or appends FAQ entries asynchronously. // Returns task ID (Knowledge ID) for tracking import progress. UpsertFAQEntries(ctx context.Context, kbID string, payload *types.FAQBatchUpsertPayload) (string, error) + // CreateFAQEntry creates a single FAQ entry synchronously. + CreateFAQEntry(ctx context.Context, kbID string, payload *types.FAQEntryPayload) (*types.FAQEntry, error) // UpdateFAQEntry updates a single FAQ entry. UpdateFAQEntry(ctx context.Context, kbID string, entryID string, payload *types.FAQEntryPayload) error // UpdateFAQEntryStatusBatch updates enable status for FAQ entries in batch.