Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
2122 lines
64 KiB
Go
2122 lines
64 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/application/service/retriever"
|
|
werrors "github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ListFAQEntries lists FAQ entries under a FAQ knowledge base.
|
|
func (s *knowledgeService) ListFAQEntries(ctx context.Context,
|
|
kbID string, page *types.Pagination, tagUUIDs []string, legacyTagSeqID int64, keyword string, searchField string, sortOrder string,
|
|
) (*types.PageResult, error) {
|
|
if page == nil {
|
|
page = &types.Pagination{}
|
|
}
|
|
keyword = strings.TrimSpace(keyword)
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if this is a shared knowledge base access
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
effectiveTenantID := tenantID
|
|
|
|
// If the kb belongs to a different tenant, check for shared access
|
|
if kb.TenantID != tenantID {
|
|
// Get user ID from context
|
|
userIDVal := ctx.Value(types.UserIDContextKey)
|
|
if userIDVal == nil {
|
|
return nil, werrors.NewForbiddenError("无权访问该知识库")
|
|
}
|
|
_ = userIDVal.(string) // userID retained only for legacy log fields
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|
|
|
// Check if the caller's tenant has at least viewer permission via org sharing.
|
|
hasPermission, err := s.kbShareService.HasTenantKBPermission(ctx, kbID, tenantID, callerTenantRole, types.OrgRoleViewer)
|
|
if err != nil || !hasPermission {
|
|
return nil, werrors.NewForbiddenError("无权访问该知识库")
|
|
}
|
|
|
|
// Use the source tenant ID for data access
|
|
sourceTenantID, err := s.kbShareService.GetKBSourceTenant(ctx, kbID)
|
|
if err != nil {
|
|
return nil, werrors.NewForbiddenError("无权访问该知识库")
|
|
}
|
|
effectiveTenantID = sourceTenantID
|
|
}
|
|
|
|
faqKnowledge, err := s.findFAQKnowledge(ctx, effectiveTenantID, kb.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if faqKnowledge == nil {
|
|
return types.NewPageResult(0, page, []*types.FAQEntry{}), nil
|
|
}
|
|
|
|
if len(tagUUIDs) == 0 && legacyTagSeqID > 0 {
|
|
tag, err := s.tagRepo.GetBySeqID(ctx, effectiveTenantID, legacyTagSeqID)
|
|
if err != nil {
|
|
return nil, werrors.NewNotFoundError("标签不存在")
|
|
}
|
|
tagUUIDs = []string{tag.ID}
|
|
}
|
|
|
|
chunkType := []types.ChunkType{types.ChunkTypeFAQ}
|
|
chunks, total, err := s.chunkRepo.ListPagedChunksByKnowledgeID(
|
|
ctx, effectiveTenantID, faqKnowledge.ID, page, chunkType, tagUUIDs, keyword, searchField, sortOrder, types.KnowledgeTypeFAQ,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build tag ID to name and seq_id mapping for all unique tag IDs (batch query)
|
|
tagNameMap := make(map[string]string)
|
|
tagSeqIDMap := make(map[string]int64)
|
|
tagIDs := make([]string, 0)
|
|
tagIDSet := make(map[string]struct{})
|
|
for _, chunk := range chunks {
|
|
if chunk.TagID != "" {
|
|
if _, exists := tagIDSet[chunk.TagID]; !exists {
|
|
tagIDSet[chunk.TagID] = struct{}{}
|
|
tagIDs = append(tagIDs, chunk.TagID)
|
|
}
|
|
}
|
|
}
|
|
if len(tagIDs) > 0 {
|
|
tags, err := s.tagRepo.GetByIDs(ctx, effectiveTenantID, tagIDs)
|
|
if err == nil {
|
|
for _, tag := range tags {
|
|
tagNameMap[tag.ID] = tag.Name
|
|
tagSeqIDMap[tag.ID] = tag.SeqID
|
|
}
|
|
}
|
|
}
|
|
|
|
kb.EnsureDefaults()
|
|
entries := make([]*types.FAQEntry, 0, len(chunks))
|
|
for _, chunk := range chunks {
|
|
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Set tag name from mapping
|
|
if chunk.TagID != "" {
|
|
entry.TagName = tagNameMap[chunk.TagID]
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
return types.NewPageResult(total, page, entries), nil
|
|
}
|
|
|
|
// faqCreateIndexBudget caps the indexing step of a single interactive FAQ
|
|
// create. Indexing embeds inline and the embedding call retries with
|
|
// exponential backoff, so a degraded embedding service can stretch one create
|
|
// past ten seconds — long enough for an impatient caller to resend and pile up
|
|
// concurrent creates. Failing fast is the better trade here: the caller can
|
|
// retry a clear error, whereas a request left hanging invites duplicates.
|
|
// Background and bulk indexing keep the full retry budget.
|
|
const faqCreateIndexBudget = 5 * time.Second
|
|
|
|
// 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
|
|
}
|
|
|
|
// 解析 TagID
|
|
tagID, err := s.resolveTagID(ctx, kbID, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 同一标准问的并发创建必须串行:下面的重复校验只看得到已落库的条目,
|
|
// 拦不住还在索引中的兄弟请求(上游超时重试就会造出这种并发)。
|
|
releaseGuard, err := s.acquireFAQCreateGuard(ctx, tenantID, kb.ID, meta.StandardQuestion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer releaseGuard()
|
|
|
|
// 检查标准问和相似问是否与其他条目重复
|
|
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
|
|
}
|
|
// 默认可推荐
|
|
flags := types.ChunkFlagRecommended
|
|
if payload.IsRecommended != nil && !*payload.IsRecommended {
|
|
flags = 0
|
|
}
|
|
|
|
chunk := &types.Chunk{
|
|
ID: uuid.New().String(),
|
|
TenantID: tenantID,
|
|
KnowledgeID: faqKnowledge.ID,
|
|
KnowledgeBaseID: kb.ID,
|
|
Content: buildFAQChunkContent(meta, indexMode),
|
|
IsEnabled: isEnabled,
|
|
Flags: flags,
|
|
ChunkType: types.ChunkTypeFAQ,
|
|
TagID: tagID, // 使用解析后的 TagID
|
|
Status: int(types.ChunkStatusStored),
|
|
}
|
|
// 如果指定了 ID(用于数据迁移),设置 SeqID
|
|
if payload.ID != nil && *payload.ID > 0 {
|
|
chunk.SeqID = *payload.ID
|
|
}
|
|
|
|
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:交互式创建给索引步骤设硬上限,避免 embedding 抖动把请求拖长
|
|
indexCtx, cancelIndex := context.WithTimeout(ctx, faqCreateIndexBudget)
|
|
indexErr := s.indexFAQChunks(indexCtx, kb, faqKnowledge, []*types.Chunk{chunk}, embeddingModel, true, false)
|
|
cancelIndex()
|
|
if indexErr != nil {
|
|
// 如果索引失败,删除已创建的chunk。回滚失败会留下一条 stored 状态的
|
|
// 残留:它不出现在列表里,却会被重复校验命中,因此必须告警而非静默。
|
|
if delErr := s.chunkService.DeleteChunk(ctx, chunk.ID); delErr != nil {
|
|
logger.Errorf(ctx,
|
|
"CreateFAQEntry: rollback failed, chunk %s left in stored state: %v", chunk.ID, delErr)
|
|
}
|
|
return nil, fmt.Errorf("failed to index chunk: %w", indexErr)
|
|
}
|
|
|
|
// 更新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)
|
|
}
|
|
|
|
// Build tag seq_id map for conversion
|
|
tagSeqIDMap := make(map[string]int64)
|
|
if chunk.TagID != "" {
|
|
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
|
|
if tagErr == nil && tag != nil {
|
|
tagSeqIDMap[tag.ID] = tag.SeqID
|
|
}
|
|
}
|
|
|
|
// 转换为FAQEntry返回
|
|
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 查询TagName
|
|
if chunk.TagID != "" {
|
|
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
|
|
if tagErr == nil && tag != nil {
|
|
entry.TagName = tag.Name
|
|
}
|
|
}
|
|
recordKBActivity(ctx, s.audit, tenantID, kb.ID, types.AuditActionKnowledgeCreated,
|
|
"faq_entry", chunk.ID, types.AuditOutcomeSuccess,
|
|
map[string]any{"entry_id": chunk.SeqID, "source_type": "faq"})
|
|
|
|
return entry, nil
|
|
}
|
|
|
|
// GetFAQEntry retrieves a single FAQ entry by seq_id.
|
|
func (s *knowledgeService) GetFAQEntry(ctx context.Context,
|
|
kbID string, entrySeqID int64,
|
|
) (*types.FAQEntry, error) {
|
|
if entrySeqID <= 0 {
|
|
return nil, werrors.NewBadRequestError("条目ID不能为空")
|
|
}
|
|
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kb.EnsureDefaults()
|
|
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
// 获取chunk by seq_id
|
|
chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, entrySeqID)
|
|
if err != nil {
|
|
return nil, werrors.NewNotFoundError("FAQ条目不存在")
|
|
}
|
|
|
|
// 验证chunk属于当前知识库
|
|
if chunk.KnowledgeBaseID != kb.ID || chunk.TenantID != tenantID {
|
|
return nil, werrors.NewNotFoundError("FAQ条目不存在")
|
|
}
|
|
|
|
// 验证是FAQ类型
|
|
if chunk.ChunkType != types.ChunkTypeFAQ {
|
|
return nil, werrors.NewNotFoundError("FAQ条目不存在")
|
|
}
|
|
|
|
// Build tag seq_id map for conversion
|
|
tagSeqIDMap := make(map[string]int64)
|
|
if chunk.TagID != "" {
|
|
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
|
|
if tagErr == nil && tag != nil {
|
|
tagSeqIDMap[tag.ID] = tag.SeqID
|
|
}
|
|
}
|
|
|
|
// 转换为FAQEntry返回
|
|
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 查询TagName
|
|
if chunk.TagID != "" {
|
|
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
|
|
if tagErr == nil && tag != nil {
|
|
entry.TagName = tag.Name
|
|
}
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
// UpdateFAQEntry updates a single FAQ entry.
|
|
func (s *knowledgeService) UpdateFAQEntry(ctx context.Context,
|
|
kbID string, entrySeqID int64, 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)
|
|
|
|
chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, entrySeqID)
|
|
if err != nil {
|
|
return nil, werrors.NewNotFoundError("FAQ条目不存在")
|
|
}
|
|
if chunk.KnowledgeBaseID != kb.ID {
|
|
return nil, werrors.NewForbiddenError("无权操作该 FAQ 条目")
|
|
}
|
|
if chunk.ChunkType != types.ChunkTypeFAQ {
|
|
return nil, werrors.NewBadRequestError("仅支持更新 FAQ 条目")
|
|
}
|
|
meta, err := sanitizeFAQEntryPayload(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 检查标准问和相似问是否与其他条目重复
|
|
if err := s.checkFAQQuestionDuplicate(ctx, tenantID, kb.ID, chunk.ID, meta); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 获取旧的相似问列表,用于增量更新
|
|
var oldSimilarQuestions []string
|
|
var oldStandardQuestion string
|
|
var oldAnswers []string
|
|
questionIndexMode := types.FAQQuestionIndexModeCombined
|
|
if kb.FAQConfig != nil && kb.FAQConfig.QuestionIndexMode != "" {
|
|
questionIndexMode = kb.FAQConfig.QuestionIndexMode
|
|
}
|
|
if existing, err := chunk.FAQMetadata(); err == nil && existing != nil {
|
|
meta.Version = existing.Version + 1
|
|
// 保存旧的内容用于增量比较
|
|
if questionIndexMode == types.FAQQuestionIndexModeSeparate {
|
|
oldSimilarQuestions = existing.SimilarQuestions
|
|
oldStandardQuestion = existing.StandardQuestion
|
|
oldAnswers = existing.Answers
|
|
}
|
|
}
|
|
if err := chunk.SetFAQMetadata(meta); err != nil {
|
|
return nil, err
|
|
}
|
|
// 获取索引模式
|
|
indexMode := types.FAQIndexModeQuestionOnly
|
|
if kb.FAQConfig != nil && kb.FAQConfig.IndexMode != "" {
|
|
indexMode = kb.FAQConfig.IndexMode
|
|
}
|
|
chunk.Content = buildFAQChunkContent(meta, indexMode)
|
|
|
|
// Convert tag seq_id to UUID
|
|
if payload.TagID > 0 {
|
|
tag, tagErr := s.tagRepo.GetBySeqID(ctx, tenantID, payload.TagID)
|
|
if tagErr != nil {
|
|
return nil, werrors.NewNotFoundError("标签不存在")
|
|
}
|
|
chunk.TagID = tag.ID
|
|
} else {
|
|
chunk.TagID = ""
|
|
}
|
|
|
|
if payload.IsEnabled != nil {
|
|
chunk.IsEnabled = *payload.IsEnabled
|
|
}
|
|
// 处理推荐状态
|
|
if payload.IsRecommended != nil {
|
|
if *payload.IsRecommended {
|
|
chunk.Flags = chunk.Flags.SetFlag(types.ChunkFlagRecommended)
|
|
} else {
|
|
chunk.Flags = chunk.Flags.ClearFlag(types.ChunkFlagRecommended)
|
|
}
|
|
}
|
|
chunk.UpdatedAt = time.Now()
|
|
if err := s.chunkService.UpdateChunk(ctx, chunk); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Note: We don't need to call BatchUpdateChunkEnabledStatus here because
|
|
// indexFAQChunks will delete old vectors and re-insert with the latest chunk data
|
|
// (including the updated is_enabled status). Calling both would cause version conflicts.
|
|
|
|
faqKnowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, chunk.KnowledgeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 增量索引优化:只对变化的内容进行索引操作
|
|
if questionIndexMode == types.FAQQuestionIndexModeSeparate && len(oldSimilarQuestions) > 0 {
|
|
// 分别索引模式下的增量更新
|
|
if err := s.incrementalIndexFAQEntry(ctx, kb, faqKnowledge, chunk, embeddingModel,
|
|
oldStandardQuestion, oldSimilarQuestions, oldAnswers, meta); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// Combined 模式或首次创建,使用全量索引
|
|
// 增量删除:只删除被移除的相似问索引
|
|
oldSimilarQuestionCount := len(oldSimilarQuestions)
|
|
newSimilarQuestionCount := len(meta.SimilarQuestions)
|
|
if questionIndexMode == types.FAQQuestionIndexModeSeparate && oldSimilarQuestionCount > newSimilarQuestionCount {
|
|
retrieveEngine, engineErr := retriever.CreateRetrieveEngineForKB(
|
|
ctx, s.retrieveEngine, s.ownership, types.MustTenantIDFromContext(ctx), kb.VectorStoreID)
|
|
if engineErr == nil {
|
|
sourceIDsToDelete := make([]string, 0, oldSimilarQuestionCount-newSimilarQuestionCount)
|
|
for i := newSimilarQuestionCount; i < oldSimilarQuestionCount; i++ {
|
|
sourceIDsToDelete = append(sourceIDsToDelete, fmt.Sprintf("%s-%d", chunk.ID, i))
|
|
}
|
|
if len(sourceIDsToDelete) > 0 {
|
|
logger.Debugf(ctx, "UpdateFAQEntry: incremental delete %d obsolete source IDs", len(sourceIDsToDelete))
|
|
if delErr := retrieveEngine.DeleteBySourceIDList(ctx, sourceIDsToDelete, embeddingModel.GetDimensions(), types.KnowledgeTypeFAQ); delErr != nil {
|
|
logger.Warnf(ctx, "UpdateFAQEntry: failed to delete obsolete source IDs: %v", delErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 使用 needDelete=false,因为 EFPutDocument 会自动覆盖相同 SourceID 的文档
|
|
if err := s.indexFAQChunks(ctx, kb, faqKnowledge, []*types.Chunk{chunk}, embeddingModel, false, false); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Build tag seq_id map for conversion
|
|
tagSeqIDMap := make(map[string]int64)
|
|
if chunk.TagID != "" {
|
|
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
|
|
if tagErr == nil && tag != nil {
|
|
tagSeqIDMap[tag.ID] = tag.SeqID
|
|
}
|
|
}
|
|
|
|
// 转换为FAQEntry返回
|
|
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 查询TagName
|
|
if chunk.TagID != "" {
|
|
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
|
|
if tagErr == nil && tag != nil {
|
|
entry.TagName = tag.Name
|
|
}
|
|
}
|
|
recordKBActivity(ctx, s.audit, tenantID, kb.ID, types.AuditActionKnowledgeUpdated,
|
|
"faq_entry", chunk.ID, types.AuditOutcomeSuccess,
|
|
map[string]any{"entry_id": chunk.SeqID, "source_type": "faq"})
|
|
|
|
return entry, nil
|
|
}
|
|
|
|
// AddSimilarQuestions adds similar questions to a FAQ entry.
|
|
// This will append the new questions to the existing similar questions list.
|
|
func (s *knowledgeService) AddSimilarQuestions(ctx context.Context,
|
|
kbID string, entrySeqID int64, questions []string,
|
|
) (*types.FAQEntry, error) {
|
|
if len(questions) == 0 {
|
|
return nil, werrors.NewBadRequestError("相似问列表不能为空")
|
|
}
|
|
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kb.EnsureDefaults()
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
// Get existing FAQ entry
|
|
chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, entrySeqID)
|
|
if err != nil {
|
|
return nil, werrors.NewNotFoundError("FAQ条目不存在")
|
|
}
|
|
if chunk.KnowledgeBaseID != kb.ID {
|
|
return nil, werrors.NewForbiddenError("无权操作该 FAQ 条目")
|
|
}
|
|
if chunk.ChunkType != types.ChunkTypeFAQ {
|
|
return nil, werrors.NewBadRequestError("仅支持更新 FAQ 条目")
|
|
}
|
|
|
|
// Get existing metadata
|
|
meta, err := chunk.FAQMetadata()
|
|
if err != nil || meta == nil {
|
|
return nil, werrors.NewBadRequestError("获取 FAQ 元数据失败")
|
|
}
|
|
|
|
// Deduplicate and sanitize new questions
|
|
existingSet := make(map[string]struct{})
|
|
for _, q := range meta.SimilarQuestions {
|
|
existingSet[q] = struct{}{}
|
|
}
|
|
// Also add standard question to prevent duplicates
|
|
existingSet[meta.StandardQuestion] = struct{}{}
|
|
|
|
newQuestions := make([]string, 0, len(questions))
|
|
for _, q := range questions {
|
|
q = strings.TrimSpace(q)
|
|
if q == "" {
|
|
continue
|
|
}
|
|
if _, exists := existingSet[q]; exists {
|
|
continue
|
|
}
|
|
existingSet[q] = struct{}{}
|
|
newQuestions = append(newQuestions, q)
|
|
}
|
|
|
|
if len(newQuestions) == 0 {
|
|
// No new questions to add, return current entry
|
|
tagSeqIDMap := make(map[string]int64)
|
|
if chunk.TagID != "" {
|
|
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
|
|
if tagErr == nil && tag != nil {
|
|
tagSeqIDMap[tag.ID] = tag.SeqID
|
|
}
|
|
}
|
|
return s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
|
|
}
|
|
|
|
// Check for duplicates with other entries
|
|
tempMeta := &types.FAQChunkMetadata{
|
|
StandardQuestion: meta.StandardQuestion,
|
|
SimilarQuestions: append(meta.SimilarQuestions, newQuestions...),
|
|
}
|
|
if err := s.checkFAQQuestionDuplicate(ctx, tenantID, kb.ID, chunk.ID, tempMeta); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update metadata
|
|
oldSimilarQuestions := meta.SimilarQuestions
|
|
meta.SimilarQuestions = append(meta.SimilarQuestions, newQuestions...)
|
|
meta.Version++
|
|
|
|
if err := chunk.SetFAQMetadata(meta); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update chunk content
|
|
indexMode := types.FAQIndexModeQuestionOnly
|
|
if kb.FAQConfig != nil && kb.FAQConfig.IndexMode != "" {
|
|
indexMode = kb.FAQConfig.IndexMode
|
|
}
|
|
chunk.Content = buildFAQChunkContent(meta, indexMode)
|
|
chunk.UpdatedAt = time.Now()
|
|
|
|
if err := s.chunkService.UpdateChunk(ctx, chunk); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Index new similar questions
|
|
faqKnowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, chunk.KnowledgeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
questionIndexMode := types.FAQQuestionIndexModeCombined
|
|
if kb.FAQConfig != nil && kb.FAQConfig.QuestionIndexMode != "" {
|
|
questionIndexMode = kb.FAQConfig.QuestionIndexMode
|
|
}
|
|
|
|
if questionIndexMode == types.FAQQuestionIndexModeSeparate {
|
|
// Only index the new similar questions
|
|
if err := s.incrementalIndexFAQEntry(ctx, kb, faqKnowledge, chunk, embeddingModel,
|
|
meta.StandardQuestion, oldSimilarQuestions, meta.Answers, meta); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// Combined mode, re-index the whole entry
|
|
if err := s.indexFAQChunks(ctx, kb, faqKnowledge, []*types.Chunk{chunk}, embeddingModel, false, false); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Build response
|
|
tagSeqIDMap := make(map[string]int64)
|
|
if chunk.TagID != "" {
|
|
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
|
|
if tagErr == nil && tag != nil {
|
|
tagSeqIDMap[tag.ID] = tag.SeqID
|
|
}
|
|
}
|
|
|
|
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if chunk.TagID != "" {
|
|
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
|
|
if tagErr == nil && tag != nil {
|
|
entry.TagName = tag.Name
|
|
}
|
|
}
|
|
|
|
return entry, nil
|
|
}
|
|
|
|
// UpdateFAQEntryStatus updates enable status for a FAQ entry.
|
|
func (s *knowledgeService) UpdateFAQEntryStatus(ctx context.Context,
|
|
kbID string, entryID string, isEnabled bool,
|
|
) error {
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
chunk, err := s.chunkRepo.GetChunkByID(ctx, tenantID, entryID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
|
|
return werrors.NewBadRequestError("仅支持更新 FAQ 条目")
|
|
}
|
|
if chunk.IsEnabled == isEnabled {
|
|
return nil
|
|
}
|
|
chunk.IsEnabled = isEnabled
|
|
chunk.UpdatedAt = time.Now()
|
|
if err := s.chunkService.UpdateChunk(ctx, chunk); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Sync update to retriever engines
|
|
chunkStatusMap := map[string]bool{chunk.ID: isEnabled}
|
|
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
|
|
ctx, s.retrieveEngine, s.ownership, tenantID, kb.VectorStoreID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := retrieveEngine.BatchUpdateChunkEnabledStatus(ctx, chunkStatusMap); err != nil {
|
|
return err
|
|
}
|
|
recordKBActivity(ctx, s.audit, tenantID, kb.ID, types.AuditActionKnowledgeUpdated,
|
|
"faq_entry", chunk.ID, types.AuditOutcomeSuccess,
|
|
map[string]any{"entry_id": chunk.SeqID, "changed_fields": []string{"enabled"}})
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in batch.
|
|
// This is the unified API for batch updating FAQ entry fields.
|
|
// Supports two modes:
|
|
// 1. By entry seq_id: use ByID field
|
|
// 2. By Tag seq_id: use ByTag field to apply the same update to all entries under a tag
|
|
func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context,
|
|
kbID string, req *types.FAQEntryFieldsBatchUpdate,
|
|
) error {
|
|
if req == nil || (len(req.ByID) == 0 && len(req.ByTag) == 0) {
|
|
return nil
|
|
}
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
enabledUpdates := make(map[string]bool)
|
|
tagUpdates := make(map[string]string)
|
|
|
|
// Convert exclude seq_ids to UUIDs
|
|
excludeUUIDs := make([]string, 0, len(req.ExcludeIDs))
|
|
if len(req.ExcludeIDs) > 0 {
|
|
excludeChunks, err := s.chunkRepo.ListChunksBySeqID(ctx, tenantID, req.ExcludeIDs)
|
|
if err == nil {
|
|
for _, c := range excludeChunks {
|
|
excludeUUIDs = append(excludeUUIDs, c.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle ByTag updates first (by tag seq_id)
|
|
if len(req.ByTag) > 0 {
|
|
for tagSeqID, update := range req.ByTag {
|
|
// Convert tag seq_id to UUID
|
|
tag, err := s.tagRepo.GetBySeqID(ctx, tenantID, tagSeqID)
|
|
if err != nil {
|
|
return werrors.NewNotFoundError(fmt.Sprintf("标签 %d 不存在", tagSeqID))
|
|
}
|
|
|
|
var setFlags, clearFlags types.ChunkFlags
|
|
|
|
// Handle IsRecommended
|
|
if update.IsRecommended != nil {
|
|
if *update.IsRecommended {
|
|
setFlags = types.ChunkFlagRecommended
|
|
} else {
|
|
clearFlags = types.ChunkFlagRecommended
|
|
}
|
|
}
|
|
|
|
// Convert new tag seq_id to UUID if provided
|
|
var newTagUUID *string
|
|
if update.TagID != nil {
|
|
if *update.TagID > 0 {
|
|
newTag, err := s.tagRepo.GetBySeqID(ctx, tenantID, *update.TagID)
|
|
if err != nil {
|
|
return werrors.NewNotFoundError(fmt.Sprintf("标签 %d 不存在", *update.TagID))
|
|
}
|
|
newTagUUID = &newTag.ID
|
|
} else {
|
|
emptyStr := ""
|
|
newTagUUID = &emptyStr
|
|
}
|
|
}
|
|
|
|
// Update all chunks with this tag
|
|
affectedIDs, err := s.chunkRepo.UpdateChunkFieldsByTagID(
|
|
ctx, tenantID, kb.ID, tag.ID,
|
|
update.IsEnabled, setFlags, clearFlags, newTagUUID, excludeUUIDs,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Collect affected IDs for retriever sync
|
|
if len(affectedIDs) > 0 {
|
|
if update.IsEnabled != nil {
|
|
for _, id := range affectedIDs {
|
|
enabledUpdates[id] = *update.IsEnabled
|
|
}
|
|
}
|
|
if newTagUUID != nil {
|
|
for _, id := range affectedIDs {
|
|
tagUpdates[id] = *newTagUUID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle ByID updates (by entry seq_id)
|
|
if len(req.ByID) > 0 {
|
|
entrySeqIDs := make([]int64, 0, len(req.ByID))
|
|
for entrySeqID := range req.ByID {
|
|
entrySeqIDs = append(entrySeqIDs, entrySeqID)
|
|
}
|
|
chunks, err := s.chunkRepo.ListChunksBySeqID(ctx, tenantID, entrySeqIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build chunk seq_id to chunk map
|
|
chunkBySeqID := make(map[int64]*types.Chunk)
|
|
for _, chunk := range chunks {
|
|
chunkBySeqID[chunk.SeqID] = chunk
|
|
}
|
|
|
|
setFlags := make(map[string]types.ChunkFlags)
|
|
clearFlags := make(map[string]types.ChunkFlags)
|
|
chunksToUpdate := make([]*types.Chunk, 0)
|
|
|
|
for entrySeqID, update := range req.ByID {
|
|
chunk, exists := chunkBySeqID[entrySeqID]
|
|
if !exists {
|
|
continue
|
|
}
|
|
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
|
|
continue
|
|
}
|
|
|
|
needUpdate := false
|
|
|
|
// Handle IsEnabled
|
|
if update.IsEnabled != nil && chunk.IsEnabled != *update.IsEnabled {
|
|
chunk.IsEnabled = *update.IsEnabled
|
|
enabledUpdates[chunk.ID] = *update.IsEnabled
|
|
needUpdate = true
|
|
}
|
|
|
|
// Handle IsRecommended (via Flags)
|
|
if update.IsRecommended != nil {
|
|
currentRecommended := chunk.Flags.HasFlag(types.ChunkFlagRecommended)
|
|
if currentRecommended != *update.IsRecommended {
|
|
if *update.IsRecommended {
|
|
setFlags[chunk.ID] = types.ChunkFlagRecommended
|
|
} else {
|
|
clearFlags[chunk.ID] = types.ChunkFlagRecommended
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle TagID (convert seq_id to UUID)
|
|
if update.TagID != nil {
|
|
var newTagID string
|
|
if *update.TagID > 0 {
|
|
newTag, err := s.tagRepo.GetBySeqID(ctx, tenantID, *update.TagID)
|
|
if err != nil {
|
|
return werrors.NewNotFoundError(fmt.Sprintf("标签 %d 不存在", *update.TagID))
|
|
}
|
|
newTagID = newTag.ID
|
|
}
|
|
if chunk.TagID != newTagID {
|
|
chunk.TagID = newTagID
|
|
tagUpdates[chunk.ID] = newTagID
|
|
needUpdate = true
|
|
}
|
|
}
|
|
|
|
if needUpdate {
|
|
chunk.UpdatedAt = time.Now()
|
|
chunksToUpdate = append(chunksToUpdate, chunk)
|
|
}
|
|
}
|
|
|
|
// Batch update chunks (for IsEnabled and TagID)
|
|
if len(chunksToUpdate) > 0 {
|
|
if err := s.chunkRepo.UpdateChunks(ctx, chunksToUpdate); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Batch update flags (for IsRecommended)
|
|
if len(setFlags) > 0 || len(clearFlags) > 0 {
|
|
if err := s.chunkRepo.UpdateChunkFlagsBatch(ctx, tenantID, kb.ID, setFlags, clearFlags); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sync to retriever engines
|
|
if len(enabledUpdates) > 0 || len(tagUpdates) > 0 {
|
|
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
|
|
ctx, s.retrieveEngine, s.ownership, tenantID, kb.VectorStoreID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(enabledUpdates) > 0 {
|
|
if err := retrieveEngine.BatchUpdateChunkEnabledStatus(ctx, enabledUpdates); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(tagUpdates) > 0 {
|
|
if err := retrieveEngine.BatchUpdateChunkTagID(ctx, tagUpdates); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
recordKBActivity(ctx, s.audit, tenantID, kb.ID, types.AuditActionKnowledgeUpdated,
|
|
"faq_entry", "", types.AuditOutcomeSuccess,
|
|
map[string]any{"count": len(req.ByID), "tag_groups": len(req.ByTag), "batch": true})
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateFAQEntryTag updates the tag assigned to an FAQ entry.
|
|
func (s *knowledgeService) UpdateFAQEntryTag(ctx context.Context, kbID string, entryID string, tagID *string) error {
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
chunk, err := s.chunkRepo.GetChunkByID(ctx, tenantID, entryID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
|
|
return werrors.NewBadRequestError("仅支持更新 FAQ 条目标签")
|
|
}
|
|
|
|
var resolvedTagID string
|
|
if tagID != nil && *tagID != "" {
|
|
tag, err := s.tagRepo.GetByID(ctx, tenantID, *tagID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.KnowledgeBaseID != kb.ID {
|
|
return werrors.NewBadRequestError("标签不属于当前知识库")
|
|
}
|
|
resolvedTagID = tag.ID
|
|
}
|
|
|
|
// Check if tag actually changed
|
|
if chunk.TagID == resolvedTagID {
|
|
return nil
|
|
}
|
|
|
|
chunk.TagID = resolvedTagID
|
|
chunk.UpdatedAt = time.Now()
|
|
if err := s.chunkRepo.UpdateChunk(ctx, chunk); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Sync tag update to retriever engines
|
|
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
|
|
ctx, s.retrieveEngine, s.ownership, tenantID, kb.VectorStoreID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return retrieveEngine.BatchUpdateChunkTagID(ctx, map[string]string{chunk.ID: resolvedTagID})
|
|
}
|
|
|
|
// UpdateFAQEntryTagBatch updates tags for FAQ entries in batch.
|
|
// Key: entry seq_id, Value: tag seq_id (nil to remove tag)
|
|
func (s *knowledgeService) UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[int64]*int64) error {
|
|
if len(updates) == 0 {
|
|
return nil
|
|
}
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
// Get all chunks in batch by seq_id
|
|
entrySeqIDs := make([]int64, 0, len(updates))
|
|
for entrySeqID := range updates {
|
|
entrySeqIDs = append(entrySeqIDs, entrySeqID)
|
|
}
|
|
chunks, err := s.chunkRepo.ListChunksBySeqID(ctx, tenantID, entrySeqIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build chunk seq_id to chunk map
|
|
chunkBySeqID := make(map[int64]*types.Chunk)
|
|
for _, chunk := range chunks {
|
|
chunkBySeqID[chunk.SeqID] = chunk
|
|
}
|
|
|
|
// Build tag seq_id set for validation
|
|
tagSeqIDSet := make(map[int64]bool)
|
|
for _, tagSeqID := range updates {
|
|
if tagSeqID != nil && *tagSeqID > 0 {
|
|
tagSeqIDSet[*tagSeqID] = true
|
|
}
|
|
}
|
|
|
|
// Validate all tags in batch by seq_id
|
|
tagMap := make(map[int64]*types.KnowledgeTag)
|
|
if len(tagSeqIDSet) > 0 {
|
|
tagSeqIDs := make([]int64, 0, len(tagSeqIDSet))
|
|
for tagSeqID := range tagSeqIDSet {
|
|
tagSeqIDs = append(tagSeqIDs, tagSeqID)
|
|
}
|
|
tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, tagSeqIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, tag := range tags {
|
|
if tag.KnowledgeBaseID != kb.ID {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("标签 %d 不属于当前知识库", tag.SeqID))
|
|
}
|
|
tagMap[tag.SeqID] = tag
|
|
}
|
|
}
|
|
|
|
// Update chunks
|
|
chunksToUpdate := make([]*types.Chunk, 0)
|
|
for entrySeqID, tagSeqID := range updates {
|
|
chunk, exists := chunkBySeqID[entrySeqID]
|
|
if !exists {
|
|
continue
|
|
}
|
|
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
|
|
continue
|
|
}
|
|
|
|
var resolvedTagID string
|
|
if tagSeqID != nil && *tagSeqID > 0 {
|
|
tag, ok := tagMap[*tagSeqID]
|
|
if !ok {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("标签 %d 不存在", *tagSeqID))
|
|
}
|
|
resolvedTagID = tag.ID
|
|
}
|
|
|
|
chunk.TagID = resolvedTagID
|
|
chunk.UpdatedAt = time.Now()
|
|
chunksToUpdate = append(chunksToUpdate, chunk)
|
|
}
|
|
|
|
if len(chunksToUpdate) > 0 {
|
|
if err := s.chunkRepo.UpdateChunks(ctx, chunksToUpdate); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Sync tag updates to retriever engines
|
|
tagUpdates := make(map[string]string)
|
|
for _, chunk := range chunksToUpdate {
|
|
tagUpdates[chunk.ID] = chunk.TagID
|
|
}
|
|
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
|
|
ctx, s.retrieveEngine, s.ownership, tenantID, kb.VectorStoreID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := retrieveEngine.BatchUpdateChunkTagID(ctx, tagUpdates); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SearchFAQEntries searches FAQ entries using hybrid search.
|
|
func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
|
|
kbID string, req *types.FAQSearchRequest,
|
|
) ([]*types.FAQEntry, error) {
|
|
// Validate FAQ knowledge base
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set default values
|
|
if req.VectorThreshold <= 0 {
|
|
req.VectorThreshold = 0.7
|
|
}
|
|
if req.MatchCount <= 0 {
|
|
req.MatchCount = 10
|
|
}
|
|
if req.MatchCount > 50 {
|
|
req.MatchCount = 50
|
|
}
|
|
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
// Convert tag seq_ids to UUIDs
|
|
var firstPriorityTagUUIDs, secondPriorityTagUUIDs []string
|
|
firstPrioritySeqIDSet := make(map[int64]struct{})
|
|
secondPrioritySeqIDSet := make(map[int64]struct{})
|
|
|
|
if len(req.FirstPriorityTagIDs) > 0 {
|
|
tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, req.FirstPriorityTagIDs)
|
|
if err == nil {
|
|
firstPriorityTagUUIDs = make([]string, 0, len(tags))
|
|
for _, tag := range tags {
|
|
firstPriorityTagUUIDs = append(firstPriorityTagUUIDs, tag.ID)
|
|
firstPrioritySeqIDSet[tag.SeqID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
if len(req.SecondPriorityTagIDs) > 0 {
|
|
tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, req.SecondPriorityTagIDs)
|
|
if err == nil {
|
|
secondPriorityTagUUIDs = make([]string, 0, len(tags))
|
|
for _, tag := range tags {
|
|
secondPriorityTagUUIDs = append(secondPriorityTagUUIDs, tag.ID)
|
|
secondPrioritySeqIDSet[tag.SeqID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build priority tag sets for sorting (using UUID)
|
|
hasFirstPriority := len(firstPriorityTagUUIDs) > 0
|
|
hasSecondPriority := len(secondPriorityTagUUIDs) > 0
|
|
hasPriorityFilter := hasFirstPriority || hasSecondPriority
|
|
|
|
firstPrioritySet := make(map[string]struct{}, len(firstPriorityTagUUIDs))
|
|
for _, tagID := range firstPriorityTagUUIDs {
|
|
firstPrioritySet[tagID] = struct{}{}
|
|
}
|
|
secondPrioritySet := make(map[string]struct{}, len(secondPriorityTagUUIDs))
|
|
for _, tagID := range secondPriorityTagUUIDs {
|
|
secondPrioritySet[tagID] = struct{}{}
|
|
}
|
|
|
|
// Perform separate searches for each priority level to ensure FirstPriority results
|
|
// are not crowded out by higher-scoring SecondPriority results in TopK truncation
|
|
var searchResults []*types.SearchResult
|
|
|
|
if hasPriorityFilter {
|
|
// Use goroutines to search both priority levels concurrently
|
|
var (
|
|
firstResults []*types.SearchResult
|
|
secondResults []*types.SearchResult
|
|
firstErr error
|
|
secondErr error
|
|
wg sync.WaitGroup
|
|
)
|
|
|
|
if hasFirstPriority {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
firstParams := types.SearchParams{
|
|
QueryText: secutils.SanitizeForLog(req.QueryText),
|
|
VectorThreshold: req.VectorThreshold,
|
|
MatchCount: req.MatchCount,
|
|
DisableKeywordsMatch: true,
|
|
TagIDs: firstPriorityTagUUIDs,
|
|
OnlyRecommended: req.OnlyRecommended,
|
|
}
|
|
firstResults, firstErr = s.kbService.HybridSearch(ctx, kbID, firstParams)
|
|
}()
|
|
}
|
|
|
|
if hasSecondPriority {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
secondParams := types.SearchParams{
|
|
QueryText: secutils.SanitizeForLog(req.QueryText),
|
|
VectorThreshold: req.VectorThreshold,
|
|
MatchCount: req.MatchCount,
|
|
DisableKeywordsMatch: true,
|
|
TagIDs: secondPriorityTagUUIDs,
|
|
OnlyRecommended: req.OnlyRecommended,
|
|
}
|
|
secondResults, secondErr = s.kbService.HybridSearch(ctx, kbID, secondParams)
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// Check errors
|
|
if firstErr != nil {
|
|
return nil, firstErr
|
|
}
|
|
if secondErr != nil {
|
|
return nil, secondErr
|
|
}
|
|
|
|
// Merge results: FirstPriority first, then SecondPriority (deduplicated)
|
|
seenChunkIDs := make(map[string]struct{})
|
|
for _, result := range firstResults {
|
|
if _, exists := seenChunkIDs[result.ID]; !exists {
|
|
seenChunkIDs[result.ID] = struct{}{}
|
|
searchResults = append(searchResults, result)
|
|
}
|
|
}
|
|
for _, result := range secondResults {
|
|
if _, exists := seenChunkIDs[result.ID]; !exists {
|
|
seenChunkIDs[result.ID] = struct{}{}
|
|
searchResults = append(searchResults, result)
|
|
}
|
|
}
|
|
} else {
|
|
// No priority filter, search all
|
|
searchParams := types.SearchParams{
|
|
QueryText: secutils.SanitizeForLog(req.QueryText),
|
|
VectorThreshold: req.VectorThreshold,
|
|
MatchCount: req.MatchCount,
|
|
DisableKeywordsMatch: true,
|
|
}
|
|
var err error
|
|
searchResults, err = s.kbService.HybridSearch(ctx, kbID, searchParams)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if len(searchResults) == 0 {
|
|
return []*types.FAQEntry{}, nil
|
|
}
|
|
|
|
// Extract chunk IDs and build score/match type/matched content maps
|
|
chunkIDs := make([]string, 0, len(searchResults))
|
|
chunkScores := make(map[string]float64)
|
|
chunkMatchTypes := make(map[string]types.MatchType)
|
|
chunkMatchedContents := make(map[string]string)
|
|
for _, result := range searchResults {
|
|
// SearchResult.ID is the chunk ID
|
|
chunkID := result.ID
|
|
chunkIDs = append(chunkIDs, chunkID)
|
|
chunkScores[chunkID] = result.Score
|
|
chunkMatchTypes[chunkID] = result.MatchType
|
|
chunkMatchedContents[chunkID] = result.MatchedContent
|
|
}
|
|
|
|
// Batch fetch chunks
|
|
chunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, chunkIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build tag UUID to seq_id map for conversion
|
|
tagSeqIDMap := make(map[string]int64)
|
|
tagIDs := make([]string, 0)
|
|
tagIDSet := make(map[string]struct{})
|
|
for _, chunk := range chunks {
|
|
if chunk.TagID != "" {
|
|
if _, exists := tagIDSet[chunk.TagID]; !exists {
|
|
tagIDSet[chunk.TagID] = struct{}{}
|
|
tagIDs = append(tagIDs, chunk.TagID)
|
|
}
|
|
}
|
|
}
|
|
if len(tagIDs) > 0 {
|
|
tags, err := s.tagRepo.GetByIDs(ctx, tenantID, tagIDs)
|
|
if err == nil {
|
|
for _, tag := range tags {
|
|
tagSeqIDMap[tag.ID] = tag.SeqID
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter FAQ chunks and convert to FAQEntry
|
|
kb.EnsureDefaults()
|
|
entries := make([]*types.FAQEntry, 0, len(chunks))
|
|
for _, chunk := range chunks {
|
|
// Only process FAQ type chunks
|
|
if chunk.ChunkType != types.ChunkTypeFAQ {
|
|
continue
|
|
}
|
|
if !chunk.IsEnabled {
|
|
continue
|
|
}
|
|
|
|
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "Failed to convert chunk to FAQ entry: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Preserve score and match type from search results
|
|
// Note: Negative question filtering is now handled in HybridSearch
|
|
if score, ok := chunkScores[chunk.ID]; ok {
|
|
entry.Score = score
|
|
}
|
|
if matchType, ok := chunkMatchTypes[chunk.ID]; ok {
|
|
entry.MatchType = matchType
|
|
}
|
|
|
|
// Set MatchedQuestion from search result's matched content
|
|
if matchedContent, ok := chunkMatchedContents[chunk.ID]; ok && matchedContent != "" {
|
|
entry.MatchedQuestion = matchedContent
|
|
}
|
|
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
// Sort entries with two-level priority tag support
|
|
if hasPriorityFilter {
|
|
// getPriorityLevel returns: 0 = first priority, 1 = second priority, 2 = no priority
|
|
// Use chunk.TagID (UUID) for comparison
|
|
getPriorityLevel := func(chunk *types.Chunk) int {
|
|
if _, ok := firstPrioritySet[chunk.TagID]; ok {
|
|
return 0
|
|
}
|
|
if _, ok := secondPrioritySet[chunk.TagID]; ok {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// Build chunk map for priority lookup
|
|
chunkMap := make(map[int64]*types.Chunk)
|
|
for _, chunk := range chunks {
|
|
chunkMap[chunk.SeqID] = chunk
|
|
}
|
|
|
|
slices.SortFunc(entries, func(a, b *types.FAQEntry) int {
|
|
aChunk := chunkMap[a.ID]
|
|
bChunk := chunkMap[b.ID]
|
|
var aPriority, bPriority int
|
|
if aChunk != nil {
|
|
aPriority = getPriorityLevel(aChunk)
|
|
} else {
|
|
aPriority = 2
|
|
}
|
|
if bChunk != nil {
|
|
bPriority = getPriorityLevel(bChunk)
|
|
} else {
|
|
bPriority = 2
|
|
}
|
|
|
|
// Compare by priority level first
|
|
if aPriority != bPriority {
|
|
return aPriority - bPriority // Lower level = higher priority
|
|
}
|
|
|
|
// Same priority level, sort by score descending
|
|
if b.Score > a.Score {
|
|
return 1
|
|
} else if b.Score < a.Score {
|
|
return -1
|
|
}
|
|
return 0
|
|
})
|
|
} else {
|
|
// No priority tags, sort by score only
|
|
slices.SortFunc(entries, func(a, b *types.FAQEntry) int {
|
|
if b.Score > a.Score {
|
|
return 1
|
|
} else if b.Score < a.Score {
|
|
return -1
|
|
}
|
|
return 0
|
|
})
|
|
}
|
|
|
|
// Limit results to requested match count
|
|
if len(entries) > req.MatchCount {
|
|
entries = entries[:req.MatchCount]
|
|
}
|
|
|
|
// 批量查询TagName并补充到结果中
|
|
if len(entries) > 0 {
|
|
// 收集所有需要查询的TagID (seq_id)
|
|
tagSeqIDs := make([]int64, 0)
|
|
tagSeqIDSet := make(map[int64]struct{})
|
|
for _, entry := range entries {
|
|
if entry.TagID != 0 {
|
|
if _, exists := tagSeqIDSet[entry.TagID]; !exists {
|
|
tagSeqIDs = append(tagSeqIDs, entry.TagID)
|
|
tagSeqIDSet[entry.TagID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 批量查询标签
|
|
if len(tagSeqIDs) > 0 {
|
|
tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, tagSeqIDs)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "Failed to batch query tags: %v", err)
|
|
} else {
|
|
// 构建TagSeqID到TagName的映射
|
|
tagNameMap := make(map[int64]string)
|
|
for _, tag := range tags {
|
|
tagNameMap[tag.SeqID] = tag.Name
|
|
}
|
|
|
|
// 补充TagName
|
|
for _, entry := range entries {
|
|
if entry.TagID != 0 {
|
|
if tagName, exists := tagNameMap[entry.TagID]; exists {
|
|
entry.TagName = tagName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
// DeleteFAQEntries deletes FAQ entries in batch by seq_id.
|
|
func (s *knowledgeService) DeleteFAQEntries(ctx context.Context,
|
|
kbID string, entrySeqIDs []int64,
|
|
) error {
|
|
if len(entrySeqIDs) == 0 {
|
|
return werrors.NewBadRequestError("请选择需要删除的 FAQ 条目")
|
|
}
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
var faqKnowledge *types.Knowledge
|
|
chunksToRemove := make([]*types.Chunk, 0, len(entrySeqIDs))
|
|
for _, seqID := range entrySeqIDs {
|
|
if seqID <= 0 {
|
|
continue
|
|
}
|
|
chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, seqID)
|
|
if err != nil {
|
|
return werrors.NewNotFoundError("FAQ条目不存在")
|
|
}
|
|
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
|
|
return werrors.NewBadRequestError("包含无效的 FAQ 条目")
|
|
}
|
|
if err := s.chunkService.DeleteChunk(ctx, chunk.ID); err != nil {
|
|
return err
|
|
}
|
|
if faqKnowledge == nil {
|
|
faqKnowledge, err = s.repo.GetKnowledgeByID(ctx, tenantID, chunk.KnowledgeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
chunksToRemove = append(chunksToRemove, chunk)
|
|
}
|
|
if len(chunksToRemove) > 0 && faqKnowledge != nil {
|
|
if err := s.deleteFAQChunkVectors(ctx, kb, faqKnowledge, chunksToRemove); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
details := map[string]any{"count": len(chunksToRemove), "source_type": "faq"}
|
|
titles := make([]string, 0, len(chunksToRemove))
|
|
for _, chunk := range chunksToRemove {
|
|
titles = append(titles, faqChunkQuestion(chunk))
|
|
}
|
|
kbActivityAppendSampleTitles(details, titles...)
|
|
recordKBActivity(ctx, s.audit, tenantID, kb.ID, types.AuditActionKnowledgeBatchDeleted,
|
|
"faq_entry", "", types.AuditOutcomeSuccess, details)
|
|
return nil
|
|
}
|
|
|
|
// ExportFAQEntries exports all FAQ entries for a knowledge base as CSV data.
|
|
// The CSV format matches the import example format with 8 columns:
|
|
// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔),
|
|
// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE),
|
|
// 是否禁止被推荐(选填-默认False 可被推荐)
|
|
func (s *knowledgeService) ExportFAQEntries(ctx context.Context, kbID string) ([]byte, error) {
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
faqKnowledge, err := s.findFAQKnowledge(ctx, tenantID, kb.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if faqKnowledge == nil {
|
|
// Return empty CSV with headers only
|
|
return s.buildFAQCSV(nil, nil), nil
|
|
}
|
|
|
|
// Get all FAQ chunks
|
|
chunks, err := s.chunkRepo.ListAllFAQChunksForExport(ctx, tenantID, faqKnowledge.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list FAQ chunks: %w", err)
|
|
}
|
|
|
|
// Build tag map for tag_id -> tag_name conversion
|
|
tagMap, err := s.buildTagMap(ctx, tenantID, kbID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build tag map: %w", err)
|
|
}
|
|
|
|
return s.buildFAQCSV(chunks, tagMap), nil
|
|
}
|
|
|
|
// ExportFAQEntriesJSON 以 JSON 数组形式导出 FAQ 知识库下的全部条目,
|
|
// 字段与 FAQEntryPayload 兼容,便于"导出 → 编辑 → 重新 append 导入"循环。
|
|
func (s *knowledgeService) ExportFAQEntriesJSON(ctx context.Context, kbID string) ([]byte, error) {
|
|
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
faqKnowledge, err := s.findFAQKnowledge(ctx, tenantID, kb.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if faqKnowledge == nil {
|
|
return json.Marshal([]types.FAQExportEntry{})
|
|
}
|
|
|
|
chunks, err := s.chunkRepo.ListAllFAQChunksForExport(ctx, tenantID, faqKnowledge.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list FAQ chunks: %w", err)
|
|
}
|
|
|
|
tagMap, err := s.buildTagMap(ctx, tenantID, kbID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build tag map: %w", err)
|
|
}
|
|
|
|
return s.buildFAQJSON(chunks, tagMap)
|
|
}
|
|
|
|
func (s *knowledgeService) buildFAQJSON(chunks []*types.Chunk, tagMap map[string]string) ([]byte, error) {
|
|
entries := make([]types.FAQExportEntry, 0, len(chunks))
|
|
for _, chunk := range chunks {
|
|
meta, err := chunk.FAQMetadata()
|
|
if err != nil || meta == nil {
|
|
continue
|
|
}
|
|
|
|
tagName := ""
|
|
if chunk.TagID != "" && tagMap != nil {
|
|
if name, ok := tagMap[chunk.TagID]; ok {
|
|
tagName = name
|
|
}
|
|
}
|
|
|
|
entries = append(entries, types.FAQExportEntry{
|
|
ID: chunk.SeqID,
|
|
TagName: tagName,
|
|
StandardQuestion: meta.StandardQuestion,
|
|
SimilarQuestions: meta.SimilarQuestions,
|
|
NegativeQuestions: meta.NegativeQuestions,
|
|
Answers: meta.Answers,
|
|
AnswerStrategy: meta.AnswerStrategy,
|
|
IsEnabled: chunk.IsEnabled,
|
|
IsRecommended: chunk.Flags.HasFlag(types.ChunkFlagRecommended),
|
|
})
|
|
}
|
|
return json.Marshal(entries)
|
|
}
|
|
|
|
// buildTagMap builds a map from tag_id to tag_name for the given knowledge base.
|
|
func (s *knowledgeService) buildTagMap(ctx context.Context, tenantID uint64, kbID string) (map[string]string, error) {
|
|
const pageSize = 1000
|
|
tagMap := make(map[string]string)
|
|
|
|
for pageNum := 1; ; pageNum++ {
|
|
page := &types.Pagination{Page: pageNum, PageSize: pageSize}
|
|
tags, _, err := s.tagRepo.ListByKB(ctx, tenantID, kbID, page, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, tag := range tags {
|
|
if tag != nil {
|
|
tagMap[tag.ID] = tag.Name
|
|
}
|
|
}
|
|
if len(tags) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
return tagMap, nil
|
|
}
|
|
|
|
// buildFAQCSV builds CSV content from FAQ chunks.
|
|
func (s *knowledgeService) buildFAQCSV(chunks []*types.Chunk, tagMap map[string]string) []byte {
|
|
var buf strings.Builder
|
|
|
|
// Write CSV header (matching import example format)
|
|
headers := []string{
|
|
"分类(必填)",
|
|
"问题(必填)",
|
|
"相似问题(选填-多个用##分隔)",
|
|
"反例问题(选填-多个用##分隔)",
|
|
"机器人回答(必填-多个用##分隔)",
|
|
"是否全部回复(选填-默认FALSE)",
|
|
"是否停用(选填-默认FALSE)",
|
|
"是否禁止被推荐(选填-默认False 可被推荐)",
|
|
}
|
|
buf.WriteString(strings.Join(headers, ","))
|
|
buf.WriteString("\n")
|
|
|
|
// Write data rows
|
|
for _, chunk := range chunks {
|
|
meta, err := chunk.FAQMetadata()
|
|
if err != nil || meta == nil {
|
|
continue
|
|
}
|
|
|
|
// Get tag name
|
|
tagName := ""
|
|
if chunk.TagID != "" && tagMap != nil {
|
|
if name, ok := tagMap[chunk.TagID]; ok {
|
|
tagName = name
|
|
}
|
|
}
|
|
|
|
// Build row
|
|
row := []string{
|
|
escapeCSVField(tagName),
|
|
escapeCSVField(meta.StandardQuestion),
|
|
escapeCSVField(strings.Join(meta.SimilarQuestions, "##")),
|
|
escapeCSVField(strings.Join(meta.NegativeQuestions, "##")),
|
|
escapeCSVField(strings.Join(meta.Answers, "##")),
|
|
boolToCSV(meta.AnswerStrategy == types.AnswerStrategyAll),
|
|
boolToCSV(!chunk.IsEnabled), // 是否停用:取反
|
|
boolToCSV(!chunk.Flags.HasFlag(types.ChunkFlagRecommended)), // 是否禁止被推荐:取反
|
|
}
|
|
buf.WriteString(strings.Join(row, ","))
|
|
buf.WriteString("\n")
|
|
}
|
|
|
|
return []byte(buf.String())
|
|
}
|
|
|
|
// escapeCSVField escapes a field for CSV format.
|
|
func escapeCSVField(field string) string {
|
|
// If field contains comma, newline, or quote, wrap in quotes and escape internal quotes
|
|
if strings.ContainsAny(field, ",\"\n\r") {
|
|
return "\"" + strings.ReplaceAll(field, "\"", "\"\"") + "\""
|
|
}
|
|
return field
|
|
}
|
|
|
|
// boolToCSV converts a boolean to CSV TRUE/FALSE string.
|
|
func boolToCSV(b bool) string {
|
|
if b {
|
|
return "TRUE"
|
|
}
|
|
return "FALSE"
|
|
}
|
|
|
|
func (s *knowledgeService) validateFAQKnowledgeBase(ctx context.Context, kbID string) (*types.KnowledgeBase, error) {
|
|
if kbID == "" {
|
|
return nil, werrors.NewBadRequestError("知识库 ID 不能为空")
|
|
}
|
|
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, kbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kb.EnsureDefaults()
|
|
if kb.Type != types.KnowledgeBaseTypeFAQ {
|
|
return nil, werrors.NewBadRequestError("仅 FAQ 知识库支持该操作")
|
|
}
|
|
return kb, nil
|
|
}
|
|
|
|
func (s *knowledgeService) findFAQKnowledge(
|
|
ctx context.Context,
|
|
tenantID uint64,
|
|
kbID string,
|
|
) (*types.Knowledge, error) {
|
|
knowledges, err := s.repo.ListKnowledgeByKnowledgeBaseID(ctx, tenantID, kbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, knowledge := range knowledges {
|
|
if knowledge.Type == types.KnowledgeTypeFAQ {
|
|
return knowledge, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *knowledgeService) ensureFAQKnowledge(
|
|
ctx context.Context,
|
|
tenantID uint64,
|
|
kb *types.KnowledgeBase,
|
|
) (*types.Knowledge, error) {
|
|
existing, err := s.findFAQKnowledge(ctx, tenantID, kb.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existing != nil {
|
|
return existing, nil
|
|
}
|
|
knowledge := &types.Knowledge{
|
|
TenantID: tenantID,
|
|
KnowledgeBaseID: kb.ID,
|
|
Type: types.KnowledgeTypeFAQ,
|
|
Channel: types.ChannelWeb,
|
|
Title: buildFAQKnowledgeTitle(kb.Name),
|
|
Description: "FAQ 条目容器",
|
|
Source: types.KnowledgeTypeFAQ,
|
|
ParseStatus: "completed",
|
|
EnableStatus: "enabled",
|
|
EmbeddingModelID: kb.EmbeddingModelID,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := s.repo.CreateKnowledge(ctx, knowledge); err != nil {
|
|
return nil, err
|
|
}
|
|
return knowledge, nil
|
|
}
|
|
|
|
// buildFAQKnowledgeTitle derives the display title for the FAQ container
|
|
// knowledge. The title is simply the knowledge base name (falling back to "FAQ"
|
|
// when empty) — no suffix is appended, so a KB named "FAQ" stays "FAQ" instead
|
|
// of becoming the redundant "FAQ - FAQ".
|
|
func buildFAQKnowledgeTitle(kbName string) string {
|
|
name := strings.TrimSpace(kbName)
|
|
if name == "" {
|
|
return "FAQ"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func faqChunkQuestion(chunk *types.Chunk) string {
|
|
if chunk == nil {
|
|
return ""
|
|
}
|
|
if meta, err := chunk.FAQMetadata(); err == nil && meta != nil {
|
|
if question := strings.TrimSpace(meta.StandardQuestion); question != "" {
|
|
return question
|
|
}
|
|
}
|
|
return strings.TrimSpace(chunk.Content)
|
|
}
|
|
|
|
func (s *knowledgeService) chunkToFAQEntry(chunk *types.Chunk, kb *types.KnowledgeBase, tagSeqIDMap map[string]int64) (*types.FAQEntry, error) {
|
|
meta, err := chunk.FAQMetadata()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meta == nil {
|
|
meta = &types.FAQChunkMetadata{StandardQuestion: chunk.Content}
|
|
}
|
|
// 默认使用 all 策略
|
|
answerStrategy := meta.AnswerStrategy
|
|
if answerStrategy == "" {
|
|
answerStrategy = types.AnswerStrategyAll
|
|
}
|
|
|
|
// Get tag seq_id from map
|
|
var tagSeqID int64
|
|
if chunk.TagID != "" && tagSeqIDMap != nil {
|
|
tagSeqID = tagSeqIDMap[chunk.TagID]
|
|
}
|
|
|
|
entry := &types.FAQEntry{
|
|
ID: chunk.SeqID,
|
|
ChunkID: chunk.ID,
|
|
KnowledgeID: chunk.KnowledgeID,
|
|
KnowledgeBaseID: chunk.KnowledgeBaseID,
|
|
TagID: tagSeqID,
|
|
IsEnabled: chunk.IsEnabled,
|
|
IsRecommended: chunk.Flags.HasFlag(types.ChunkFlagRecommended),
|
|
StandardQuestion: meta.StandardQuestion,
|
|
SimilarQuestions: meta.SimilarQuestions,
|
|
NegativeQuestions: meta.NegativeQuestions,
|
|
Answers: meta.Answers,
|
|
AnswerStrategy: answerStrategy,
|
|
IndexMode: kb.FAQConfig.IndexMode,
|
|
UpdatedAt: chunk.UpdatedAt,
|
|
CreatedAt: chunk.CreatedAt,
|
|
ChunkType: chunk.ChunkType,
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func buildFAQChunkContent(meta *types.FAQChunkMetadata, mode types.FAQIndexMode) string {
|
|
var builder strings.Builder
|
|
builder.WriteString(fmt.Sprintf("Q: %s\n", meta.StandardQuestion))
|
|
if len(meta.SimilarQuestions) > 0 {
|
|
builder.WriteString("Similar Questions:\n")
|
|
for _, q := range meta.SimilarQuestions {
|
|
builder.WriteString(fmt.Sprintf("- %s\n", q))
|
|
}
|
|
}
|
|
// 负例不应该包含在 Content 中,因为它们不应该被索引
|
|
// 答案根据索引模式决定是否包含
|
|
if mode == types.FAQIndexModeQuestionAnswer && len(meta.Answers) > 0 {
|
|
builder.WriteString("Answers:\n")
|
|
for _, ans := range meta.Answers {
|
|
builder.WriteString(fmt.Sprintf("- %s\n", ans))
|
|
}
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
// checkFAQQuestionDuplicate 检查标准问和相似问是否与知识库中其他条目重复
|
|
// excludeChunkID 用于排除当前正在编辑的条目(更新时使用)
|
|
// 按照批量导入时的检查方式:先构建已存在问题集合,再统一检查
|
|
func (s *knowledgeService) checkFAQQuestionDuplicate(
|
|
ctx context.Context,
|
|
tenantID uint64,
|
|
kbID string,
|
|
excludeChunkID string,
|
|
meta *types.FAQChunkMetadata,
|
|
) error {
|
|
// 1. 首先检查当前条目自身的相似问是否与标准问重复
|
|
for _, q := range meta.SimilarQuestions {
|
|
if q == meta.StandardQuestion {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("相似问「%s」不能与标准问相同", q))
|
|
}
|
|
}
|
|
|
|
// 2. 检查当前条目自身的相似问之间是否有重复
|
|
seen := make(map[string]struct{})
|
|
for _, q := range meta.SimilarQuestions {
|
|
if _, exists := seen[q]; exists {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("相似问「%s」重复", q))
|
|
}
|
|
seen[q] = struct{}{}
|
|
}
|
|
|
|
// 3. 检查反例问题是否与标准问或相似问重复(反例不能和正例相同)
|
|
positiveQuestions := make(map[string]struct{})
|
|
positiveQuestions[meta.StandardQuestion] = struct{}{}
|
|
for _, q := range meta.SimilarQuestions {
|
|
positiveQuestions[q] = struct{}{}
|
|
}
|
|
negativeQuestionsSeen := make(map[string]struct{})
|
|
for _, q := range meta.NegativeQuestions {
|
|
if q == "" {
|
|
continue
|
|
}
|
|
// 检查反例是否与标准问重复
|
|
if q == meta.StandardQuestion {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("反例问题「%s」不能与标准问相同", q))
|
|
}
|
|
// 检查反例是否与相似问重复
|
|
if _, exists := positiveQuestions[q]; exists {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("反例问题「%s」不能与相似问相同", q))
|
|
}
|
|
// 检查反例之间是否重复
|
|
if _, exists := negativeQuestionsSeen[q]; exists {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("反例问题「%s」重复", q))
|
|
}
|
|
negativeQuestionsSeen[q] = struct{}{}
|
|
}
|
|
|
|
// 4. 将标准问和所有相似问合并,用一条 DB 查询检查是否与其他条目冲突(替代全量扫描)
|
|
allQuestions := make([]string, 0, 1+len(meta.SimilarQuestions))
|
|
allQuestions = append(allQuestions, meta.StandardQuestion)
|
|
allQuestions = append(allQuestions, meta.SimilarQuestions...)
|
|
|
|
dupChunk, err := s.chunkRepo.FindFAQChunkWithDuplicateQuestion(ctx, tenantID, kbID, excludeChunkID, allQuestions)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check FAQ question duplicate: %w", err)
|
|
}
|
|
if dupChunk == nil {
|
|
return nil
|
|
}
|
|
|
|
existingMeta, err := dupChunk.FAQMetadata()
|
|
if err != nil || existingMeta == nil {
|
|
return werrors.NewBadRequestError("标准问或相似问与已有条目重复")
|
|
}
|
|
|
|
// 5–7. 与原先全量扫描一致的报错语义:先检查标准问,再逐条检查相似问
|
|
existingSimilarSet := make(map[string]struct{}, len(existingMeta.SimilarQuestions))
|
|
for _, q := range existingMeta.SimilarQuestions {
|
|
if q != "" {
|
|
existingSimilarSet[q] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if meta.StandardQuestion != "" {
|
|
if meta.StandardQuestion == existingMeta.StandardQuestion {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("标准问「%s」已存在", meta.StandardQuestion))
|
|
}
|
|
if _, ok := existingSimilarSet[meta.StandardQuestion]; ok {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("标准问「%s」已存在", meta.StandardQuestion))
|
|
}
|
|
}
|
|
|
|
for _, q := range meta.SimilarQuestions {
|
|
if q == "" {
|
|
continue
|
|
}
|
|
if q == existingMeta.StandardQuestion {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("相似问「%s」已存在", q))
|
|
}
|
|
if _, ok := existingSimilarSet[q]; ok {
|
|
return werrors.NewBadRequestError(fmt.Sprintf("相似问「%s」已存在", q))
|
|
}
|
|
}
|
|
|
|
return werrors.NewBadRequestError("标准问或相似问与已有条目重复")
|
|
}
|
|
|
|
// faqTagResolver resolves a payload's TagID/TagName to the tag's internal UUID.
|
|
type faqTagResolver func(*types.FAQEntryPayload) (string, error)
|
|
|
|
// buildFAQTagResolver 预加载 entries 引用的 tag UUID 映射,避免逐条 resolveTagID。
|
|
func (s *knowledgeService) buildFAQTagResolver(
|
|
ctx context.Context, kbID string, entries []types.FAQEntryPayload,
|
|
) faqTagResolver {
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
var seqIDs []int64
|
|
var names []string
|
|
seqSeen := make(map[int64]struct{})
|
|
nameSeen := make(map[string]struct{})
|
|
for i := range entries {
|
|
e := &entries[i]
|
|
switch {
|
|
case e.TagID != 0:
|
|
if _, ok := seqSeen[e.TagID]; !ok {
|
|
seqSeen[e.TagID] = struct{}{}
|
|
seqIDs = append(seqIDs, e.TagID)
|
|
}
|
|
case e.TagName != "":
|
|
if _, ok := nameSeen[e.TagName]; !ok {
|
|
nameSeen[e.TagName] = struct{}{}
|
|
names = append(names, e.TagName)
|
|
}
|
|
}
|
|
}
|
|
|
|
seqIDToUUID := make(map[int64]string)
|
|
if len(seqIDs) > 0 {
|
|
if tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, seqIDs); err == nil {
|
|
for _, t := range tags {
|
|
seqIDToUUID[t.SeqID] = t.ID
|
|
}
|
|
} else {
|
|
logger.Warnf(ctx, "buildFAQTagResolver: batch GetBySeqIDs failed (%d ids), fallback per-entry: %v",
|
|
len(seqIDs), err)
|
|
}
|
|
}
|
|
|
|
nameToUUID := make(map[string]string)
|
|
for _, name := range names {
|
|
if tag, err := s.tagRepo.GetByName(ctx, tenantID, kbID, name); err == nil && tag != nil {
|
|
nameToUUID[name] = tag.ID
|
|
}
|
|
}
|
|
|
|
return func(e *types.FAQEntryPayload) (string, error) {
|
|
if e.TagID != 0 {
|
|
if uuid, ok := seqIDToUUID[e.TagID]; ok {
|
|
return uuid, nil
|
|
}
|
|
} else if e.TagName != "" {
|
|
if uuid, ok := nameToUUID[e.TagName]; ok {
|
|
return uuid, nil
|
|
}
|
|
}
|
|
return s.resolveTagID(ctx, kbID, e)
|
|
}
|
|
}
|
|
|
|
// hashQuestion 计算问题内容的哈希值,用于生成稳定的 sourceID。
|
|
func hashQuestion(question string) string {
|
|
h := md5.Sum([]byte(question))
|
|
return hex.EncodeToString(h[:4])
|
|
}
|
|
|
|
// resolveTagID resolves tag ID (UUID) from payload, prioritizing tag_id (seq_id) over tag_name
|
|
// If no tag is specified, creates or finds the "未分类" tag
|
|
// Returns the internal UUID of the tag
|
|
func (s *knowledgeService) resolveTagID(ctx context.Context, kbID string, payload *types.FAQEntryPayload) (string, error) {
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
// 如果提供了 tag_id (seq_id),优先使用 tag_id
|
|
if payload.TagID != 0 {
|
|
tag, err := s.tagRepo.GetBySeqID(ctx, tenantID, payload.TagID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to find tag by seq_id %d: %w", payload.TagID, err)
|
|
}
|
|
return tag.ID, nil
|
|
}
|
|
|
|
// 如果提供了 tag_name,查找或创建标签
|
|
if payload.TagName != "" {
|
|
tag, err := s.tagService.FindOrCreateTagByName(ctx, kbID, payload.TagName)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve tag by name '%s': %w", payload.TagName, err)
|
|
}
|
|
return tag.ID, nil
|
|
}
|
|
|
|
// 都没有提供,使用"未分类"标签
|
|
tag, err := s.tagService.FindOrCreateTagByName(ctx, kbID, types.UntaggedTagName)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get or create default untagged tag: %w", err)
|
|
}
|
|
return tag.ID, nil
|
|
}
|
|
|
|
func sanitizeFAQEntryPayload(payload *types.FAQEntryPayload) (*types.FAQChunkMetadata, error) {
|
|
// 处理 AnswerStrategy,默认为 all
|
|
answerStrategy := types.AnswerStrategyAll
|
|
if payload.AnswerStrategy != nil && *payload.AnswerStrategy != "" {
|
|
switch *payload.AnswerStrategy {
|
|
case types.AnswerStrategyAll, types.AnswerStrategyRandom:
|
|
answerStrategy = *payload.AnswerStrategy
|
|
default:
|
|
return nil, werrors.NewBadRequestError("answer_strategy 必须是 'all' 或 'random'")
|
|
}
|
|
}
|
|
meta := &types.FAQChunkMetadata{
|
|
StandardQuestion: strings.TrimSpace(payload.StandardQuestion),
|
|
SimilarQuestions: payload.SimilarQuestions,
|
|
NegativeQuestions: payload.NegativeQuestions,
|
|
Answers: payload.Answers,
|
|
AnswerStrategy: answerStrategy,
|
|
Version: 1,
|
|
Source: "faq",
|
|
}
|
|
meta.Normalize()
|
|
if meta.StandardQuestion == "" {
|
|
return nil, werrors.NewBadRequestError("标准问不能为空")
|
|
}
|
|
if len(meta.Answers) == 0 {
|
|
return nil, werrors.NewBadRequestError("至少提供一个答案")
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
func buildFAQIndexContent(meta *types.FAQChunkMetadata, mode types.FAQIndexMode) string {
|
|
var builder strings.Builder
|
|
builder.WriteString(meta.StandardQuestion)
|
|
for _, q := range meta.SimilarQuestions {
|
|
builder.WriteString("\n")
|
|
builder.WriteString(q)
|
|
}
|
|
if mode == types.FAQIndexModeQuestionAnswer {
|
|
for _, ans := range meta.Answers {
|
|
builder.WriteString("\n")
|
|
builder.WriteString(ans)
|
|
}
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
// buildFAQIndexInfoList 构建FAQ索引信息列表,支持分别索引模式
|
|
func (s *knowledgeService) buildFAQIndexInfoList(
|
|
ctx context.Context,
|
|
kb *types.KnowledgeBase,
|
|
chunk *types.Chunk,
|
|
) ([]*types.IndexInfo, error) {
|
|
indexMode := types.FAQIndexModeQuestionAnswer
|
|
questionIndexMode := types.FAQQuestionIndexModeCombined
|
|
if kb.FAQConfig != nil {
|
|
if kb.FAQConfig.IndexMode != "" {
|
|
indexMode = kb.FAQConfig.IndexMode
|
|
}
|
|
if kb.FAQConfig.QuestionIndexMode != "" {
|
|
questionIndexMode = kb.FAQConfig.QuestionIndexMode
|
|
}
|
|
}
|
|
|
|
meta, err := chunk.FAQMetadata()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meta == nil {
|
|
meta = &types.FAQChunkMetadata{StandardQuestion: chunk.Content}
|
|
}
|
|
|
|
// 如果是一起索引模式,使用原有逻辑
|
|
if questionIndexMode == types.FAQQuestionIndexModeCombined {
|
|
content := buildFAQIndexContent(meta, indexMode)
|
|
return []*types.IndexInfo{
|
|
{
|
|
Content: content,
|
|
SourceID: chunk.ID,
|
|
SourceType: types.ChunkSourceType,
|
|
ChunkID: chunk.ID,
|
|
KnowledgeID: chunk.KnowledgeID,
|
|
KnowledgeBaseID: chunk.KnowledgeBaseID,
|
|
KnowledgeType: types.KnowledgeTypeFAQ,
|
|
TagID: chunk.TagID,
|
|
IsEnabled: chunk.IsEnabled,
|
|
IsRecommended: chunk.Flags.HasFlag(types.ChunkFlagRecommended),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// 分别索引模式:为每个问题创建独立的索引项
|
|
indexInfoList := make([]*types.IndexInfo, 0)
|
|
|
|
// 标准问索引项
|
|
standardContent := meta.StandardQuestion
|
|
if indexMode == types.FAQIndexModeQuestionAnswer && len(meta.Answers) > 0 {
|
|
var builder strings.Builder
|
|
builder.WriteString(meta.StandardQuestion)
|
|
for _, ans := range meta.Answers {
|
|
builder.WriteString("\n")
|
|
builder.WriteString(ans)
|
|
}
|
|
standardContent = builder.String()
|
|
}
|
|
indexInfoList = append(indexInfoList, &types.IndexInfo{
|
|
Content: standardContent,
|
|
SourceID: chunk.ID,
|
|
SourceType: types.ChunkSourceType,
|
|
ChunkID: chunk.ID,
|
|
KnowledgeID: chunk.KnowledgeID,
|
|
KnowledgeBaseID: chunk.KnowledgeBaseID,
|
|
KnowledgeType: types.KnowledgeTypeFAQ,
|
|
TagID: chunk.TagID,
|
|
IsEnabled: chunk.IsEnabled,
|
|
IsRecommended: chunk.Flags.HasFlag(types.ChunkFlagRecommended),
|
|
})
|
|
|
|
// 每个相似问创建一个索引项
|
|
for i, similarQ := range meta.SimilarQuestions {
|
|
similarContent := similarQ
|
|
if indexMode == types.FAQIndexModeQuestionAnswer && len(meta.Answers) > 0 {
|
|
var builder strings.Builder
|
|
builder.WriteString(similarQ)
|
|
for _, ans := range meta.Answers {
|
|
builder.WriteString("\n")
|
|
builder.WriteString(ans)
|
|
}
|
|
similarContent = builder.String()
|
|
}
|
|
sourceID := fmt.Sprintf("%s-%d", chunk.ID, i)
|
|
indexInfoList = append(indexInfoList, &types.IndexInfo{
|
|
Content: similarContent,
|
|
SourceID: sourceID,
|
|
SourceType: types.ChunkSourceType,
|
|
ChunkID: chunk.ID,
|
|
KnowledgeID: chunk.KnowledgeID,
|
|
KnowledgeBaseID: chunk.KnowledgeBaseID,
|
|
KnowledgeType: types.KnowledgeTypeFAQ,
|
|
TagID: chunk.TagID,
|
|
IsEnabled: chunk.IsEnabled,
|
|
IsRecommended: chunk.Flags.HasFlag(types.ChunkFlagRecommended),
|
|
})
|
|
}
|
|
|
|
return indexInfoList, nil
|
|
}
|