1075 lines
38 KiB
Go
1075 lines
38 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"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/tracing/langfuse"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/google/uuid"
|
|
"github.com/hibiken/asynq"
|
|
"github.com/redis/go-redis/v9"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
func (s *knowledgeService) CloneKnowledgeBase(ctx context.Context, srcID, dstID string) error {
|
|
srcKB, dstKB, err := s.kbService.CopyKnowledgeBase(ctx, srcID, dstID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to copy knowledge base: %v", err)
|
|
return err
|
|
}
|
|
|
|
addKnowledge, err := s.repo.AminusB(ctx, srcKB.TenantID, srcKB.ID, dstKB.TenantID, dstKB.ID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get knowledge: %v", err)
|
|
return err
|
|
}
|
|
|
|
delKnowledge, err := s.repo.AminusB(ctx, dstKB.TenantID, dstKB.ID, srcKB.TenantID, srcKB.ID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get knowledge: %v", err)
|
|
return err
|
|
}
|
|
logger.Infof(ctx, "Knowledge after update to add: %d, delete: %d", len(addKnowledge), len(delKnowledge))
|
|
|
|
batch := 10
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
for ids := range slices.Chunk(delKnowledge, batch) {
|
|
g.Go(func() error {
|
|
err := s.DeleteKnowledgeList(gctx, ids)
|
|
if err != nil {
|
|
logger.Errorf(gctx, "delete partial knowledge %v: %v", ids, err)
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
err = g.Wait()
|
|
if err != nil {
|
|
logger.Errorf(ctx, "delete total knowledge %d: %v", len(delKnowledge), err)
|
|
return err
|
|
}
|
|
|
|
// Copy context out of auto-stop task
|
|
g, gctx = errgroup.WithContext(ctx)
|
|
g.SetLimit(batch)
|
|
for _, knowledge := range addKnowledge {
|
|
g.Go(func() error {
|
|
srcKn, err := s.repo.GetKnowledgeByID(gctx, srcKB.TenantID, knowledge)
|
|
if err != nil {
|
|
logger.Errorf(gctx, "get knowledge %s: %v", knowledge, err)
|
|
return err
|
|
}
|
|
err = s.cloneKnowledge(gctx, srcKn, dstKB)
|
|
if err != nil {
|
|
logger.Errorf(gctx, "clone knowledge %s: %v", knowledge, err)
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
err = g.Wait()
|
|
if err != nil {
|
|
logger.Errorf(ctx, "add total knowledge %d: %v", len(addKnowledge), err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CloneChunk clone chunks from one knowledge to another
|
|
// This method transfers a chunk from a source knowledge document to a target knowledge document
|
|
// It handles the creation of new chunks in the target knowledge and updates the vector database accordingly
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
// - src: Source knowledge document containing the chunk to move
|
|
// - dst: Target knowledge document where the chunk will be moved
|
|
//
|
|
// Returns:
|
|
// - error: Any error encountered during the move operation
|
|
//
|
|
// This method handles the chunk transfer logic, including creating new chunks in the target knowledge
|
|
// and updating the vector database representation of the moved chunks.
|
|
// It also ensures that the chunk's relationships (like pre and next chunk IDs) are maintained
|
|
// by mapping the source chunk IDs to the new target chunk IDs.
|
|
func (s *knowledgeService) CloneChunk(ctx context.Context, src, dst *types.Knowledge) error {
|
|
chunkPage := 1
|
|
chunkPageSize := 100
|
|
srcTodst := map[string]string{}
|
|
tagIDMapping := map[string]string{} // srcTagID -> dstTagID
|
|
targetChunks := make([]*types.Chunk, 0, 10)
|
|
chunkType := []types.ChunkType{
|
|
types.ChunkTypeText, types.ChunkTypeParentText, types.ChunkTypeSummary,
|
|
types.ChunkTypeImageCaption, types.ChunkTypeImageOCR,
|
|
}
|
|
for {
|
|
sourceChunks, _, err := s.chunkRepo.ListPagedChunksByKnowledgeID(ctx,
|
|
src.TenantID,
|
|
src.ID,
|
|
&types.Pagination{
|
|
Page: chunkPage,
|
|
PageSize: chunkPageSize,
|
|
},
|
|
chunkType,
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
)
|
|
chunkPage++
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(sourceChunks) == 0 {
|
|
break
|
|
}
|
|
now := time.Now()
|
|
for _, sourceChunk := range sourceChunks {
|
|
// Map TagID to target knowledge base
|
|
targetTagID := ""
|
|
if sourceChunk.TagID != "" {
|
|
if mappedTagID, ok := tagIDMapping[sourceChunk.TagID]; ok {
|
|
targetTagID = mappedTagID
|
|
} else {
|
|
// Try to find or create the tag in target knowledge base
|
|
targetTagID = s.getOrCreateTagInTarget(ctx, src.TenantID, dst.TenantID, dst.KnowledgeBaseID, sourceChunk.TagID, tagIDMapping)
|
|
}
|
|
}
|
|
|
|
targetChunk := &types.Chunk{
|
|
ID: uuid.New().String(),
|
|
TenantID: dst.TenantID,
|
|
KnowledgeID: dst.ID,
|
|
KnowledgeBaseID: dst.KnowledgeBaseID,
|
|
TagID: targetTagID,
|
|
Content: sourceChunk.Content,
|
|
ChunkIndex: sourceChunk.ChunkIndex,
|
|
IsEnabled: sourceChunk.IsEnabled,
|
|
Flags: sourceChunk.Flags,
|
|
Status: sourceChunk.Status,
|
|
StartAt: sourceChunk.StartAt,
|
|
EndAt: sourceChunk.EndAt,
|
|
PreChunkID: sourceChunk.PreChunkID,
|
|
NextChunkID: sourceChunk.NextChunkID,
|
|
ChunkType: sourceChunk.ChunkType,
|
|
ParentChunkID: sourceChunk.ParentChunkID,
|
|
Metadata: sourceChunk.Metadata,
|
|
ContentHash: sourceChunk.ContentHash,
|
|
ImageInfo: sourceChunk.ImageInfo,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
targetChunks = append(targetChunks, targetChunk)
|
|
srcTodst[sourceChunk.ID] = targetChunk.ID
|
|
}
|
|
}
|
|
for _, targetChunk := range targetChunks {
|
|
if val, ok := srcTodst[targetChunk.PreChunkID]; ok {
|
|
targetChunk.PreChunkID = val
|
|
} else {
|
|
targetChunk.PreChunkID = ""
|
|
}
|
|
if val, ok := srcTodst[targetChunk.NextChunkID]; ok {
|
|
targetChunk.NextChunkID = val
|
|
} else {
|
|
targetChunk.NextChunkID = ""
|
|
}
|
|
if val, ok := srcTodst[targetChunk.ParentChunkID]; ok {
|
|
targetChunk.ParentChunkID = val
|
|
} else {
|
|
targetChunk.ParentChunkID = ""
|
|
}
|
|
}
|
|
for chunks := range slices.Chunk(targetChunks, chunkPageSize) {
|
|
err := s.chunkRepo.CreateChunks(ctx, chunks)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
// Route CopyIndices via the source KB's bound store. This function does
|
|
// not handle cross-store copies — embeddings written by different
|
|
// VectorStore backends are not bit-compatible, so callers that allow
|
|
// source/target KBs to bind to different stores must perform their own
|
|
// cross-store migration before invoking this.
|
|
var sourceStoreID *string
|
|
if srcKB, loadErr := s.kbService.GetKnowledgeBaseByID(ctx, src.KnowledgeBaseID); loadErr == nil && srcKB != nil {
|
|
sourceStoreID = srcKB.VectorStoreID
|
|
}
|
|
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
|
|
ctx, s.retrieveEngine, s.ownership, tenantID, sourceStoreID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, dst.EmbeddingModelID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := retrieveEngine.CopyIndices(ctx, src.KnowledgeBaseID, dst.KnowledgeBaseID,
|
|
map[string]string{src.ID: dst.ID},
|
|
srcTodst,
|
|
embeddingModel.GetDimensions(),
|
|
dst.Type,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
kbCloneProgressKeyPrefix = "kb_clone_progress:"
|
|
kbCloneProgressTTL = 24 * time.Hour
|
|
)
|
|
|
|
// getKBCloneProgressKey returns the Redis key for storing KB clone progress
|
|
func getKBCloneProgressKey(taskID string) string {
|
|
return kbCloneProgressKeyPrefix + taskID
|
|
}
|
|
|
|
// ProcessKBClone handles Asynq knowledge base clone tasks
|
|
func (s *knowledgeService) ProcessKBClone(ctx context.Context, t *asynq.Task) error {
|
|
var payload types.KBClonePayload
|
|
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
|
|
return fmt.Errorf("failed to unmarshal KB clone payload: %w", err)
|
|
}
|
|
|
|
// Add tenant ID to context
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID)
|
|
|
|
// Get tenant info and add to context
|
|
tenantInfo, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get tenant info: %v", err)
|
|
return fmt.Errorf("failed to get tenant info: %w", err)
|
|
}
|
|
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenantInfo)
|
|
|
|
// Check if this is the last retry
|
|
retryCount, _ := asynq.GetRetryCount(ctx)
|
|
maxRetry, _ := asynq.GetMaxRetry(ctx)
|
|
isLastRetry := retryCount >= maxRetry
|
|
|
|
logger.Infof(ctx, "Processing KB clone task: %s, source: %s, target: %s, retry: %d/%d",
|
|
payload.TaskID, payload.SourceID, payload.TargetID, retryCount, maxRetry)
|
|
|
|
// Helper function to handle errors - only mark as failed on last retry
|
|
handleError := func(progress *types.KBCloneProgress, err error, message string) {
|
|
if isLastRetry {
|
|
progress.Status = types.KBCloneStatusFailed
|
|
progress.Error = err.Error()
|
|
progress.Message = message
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKBCloneProgress(ctx, progress)
|
|
}
|
|
}
|
|
|
|
// Update progress to processing
|
|
progress := &types.KBCloneProgress{
|
|
TaskID: payload.TaskID,
|
|
SourceID: payload.SourceID,
|
|
TargetID: payload.TargetID,
|
|
Status: types.KBCloneStatusProcessing,
|
|
Progress: 0,
|
|
Message: "Starting knowledge base clone...",
|
|
UpdatedAt: time.Now().Unix(),
|
|
}
|
|
if err := s.saveKBCloneProgress(ctx, progress); err != nil {
|
|
logger.Errorf(ctx, "Failed to update KB clone progress: %v", err)
|
|
}
|
|
|
|
// Get source and target knowledge bases
|
|
srcKB, dstKB, err := s.kbService.CopyKnowledgeBase(ctx, payload.SourceID, payload.TargetID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to copy knowledge base: %v", err)
|
|
handleError(progress, err, "Failed to copy knowledge base configuration")
|
|
return err
|
|
}
|
|
|
|
// Use different sync strategies based on knowledge base type
|
|
if srcKB.Type == types.KnowledgeBaseTypeFAQ {
|
|
return s.cloneFAQKnowledgeBase(ctx, srcKB, dstKB, progress, handleError)
|
|
}
|
|
|
|
// Document type: use Knowledge-level diff based on file_hash
|
|
addKnowledge, err := s.repo.AminusB(ctx, srcKB.TenantID, srcKB.ID, dstKB.TenantID, dstKB.ID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get knowledge to add: %v", err)
|
|
handleError(progress, err, "Failed to calculate knowledge difference")
|
|
return err
|
|
}
|
|
|
|
delKnowledge, err := s.repo.AminusB(ctx, dstKB.TenantID, dstKB.ID, srcKB.TenantID, srcKB.ID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get knowledge to delete: %v", err)
|
|
handleError(progress, err, "Failed to calculate knowledge difference")
|
|
return err
|
|
}
|
|
|
|
totalOperations := len(addKnowledge) + len(delKnowledge)
|
|
progress.Total = totalOperations
|
|
progress.Message = fmt.Sprintf("Found %d knowledge to add, %d to delete", len(addKnowledge), len(delKnowledge))
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKBCloneProgress(ctx, progress)
|
|
|
|
logger.Infof(ctx, "Knowledge after update to add: %d, delete: %d", len(addKnowledge), len(delKnowledge))
|
|
|
|
processedCount := 0
|
|
batch := 10
|
|
|
|
// Delete knowledge in target that doesn't exist in source
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
for ids := range slices.Chunk(delKnowledge, batch) {
|
|
g.Go(func() error {
|
|
err := s.DeleteKnowledgeList(gctx, ids)
|
|
if err != nil {
|
|
logger.Errorf(gctx, "delete partial knowledge %v: %v", ids, err)
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
logger.Errorf(ctx, "delete total knowledge %d: %v", len(delKnowledge), err)
|
|
handleError(progress, err, "Failed to delete knowledge")
|
|
return err
|
|
}
|
|
|
|
processedCount += len(delKnowledge)
|
|
if totalOperations > 0 {
|
|
progress.Progress = processedCount * 100 / totalOperations
|
|
}
|
|
progress.Processed = processedCount
|
|
progress.Message = fmt.Sprintf("Deleted %d knowledge, cloning %d...", len(delKnowledge), len(addKnowledge))
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKBCloneProgress(ctx, progress)
|
|
|
|
// Clone knowledge from source to target
|
|
g, gctx = errgroup.WithContext(ctx)
|
|
g.SetLimit(batch)
|
|
for _, knowledge := range addKnowledge {
|
|
g.Go(func() error {
|
|
srcKn, err := s.repo.GetKnowledgeByID(gctx, srcKB.TenantID, knowledge)
|
|
if err != nil {
|
|
logger.Errorf(gctx, "get knowledge %s: %v", knowledge, err)
|
|
return err
|
|
}
|
|
err = s.cloneKnowledge(gctx, srcKn, dstKB)
|
|
if err != nil {
|
|
logger.Errorf(gctx, "clone knowledge %s: %v", knowledge, err)
|
|
return err
|
|
}
|
|
|
|
// Update progress
|
|
processedCount++
|
|
if totalOperations > 0 {
|
|
progress.Progress = processedCount * 100 / totalOperations
|
|
}
|
|
progress.Processed = processedCount
|
|
progress.Message = fmt.Sprintf("Cloned %d/%d knowledge", processedCount-len(delKnowledge), len(addKnowledge))
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKBCloneProgress(ctx, progress)
|
|
|
|
return nil
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
logger.Errorf(ctx, "add total knowledge %d: %v", len(addKnowledge), err)
|
|
handleError(progress, err, "Failed to clone knowledge")
|
|
return err
|
|
}
|
|
|
|
// Mark as completed
|
|
progress.Status = types.KBCloneStatusCompleted
|
|
progress.Progress = 100
|
|
progress.Processed = totalOperations
|
|
progress.Message = "Knowledge base clone completed successfully"
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
if err := s.saveKBCloneProgress(ctx, progress); err != nil {
|
|
logger.Errorf(ctx, "Failed to update KB clone progress to completed: %v", err)
|
|
}
|
|
|
|
logger.Infof(ctx, "KB clone task completed: %s", payload.TaskID)
|
|
return nil
|
|
}
|
|
|
|
// cloneFAQKnowledgeBase handles FAQ knowledge base cloning with chunk-level incremental sync
|
|
func (s *knowledgeService) cloneFAQKnowledgeBase(
|
|
ctx context.Context,
|
|
srcKB, dstKB *types.KnowledgeBase,
|
|
progress *types.KBCloneProgress,
|
|
handleError func(*types.KBCloneProgress, error, string),
|
|
) error {
|
|
// Get source FAQ knowledge first (FAQ KB has exactly one Knowledge entry)
|
|
srcKnowledgeList, err := s.repo.ListKnowledgeByKnowledgeBaseID(ctx, srcKB.TenantID, srcKB.ID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get source FAQ knowledge: %v", err)
|
|
handleError(progress, err, "Failed to get source FAQ knowledge")
|
|
return err
|
|
}
|
|
if len(srcKnowledgeList) == 0 {
|
|
// Source has no FAQ knowledge, nothing to clone
|
|
progress.Status = types.KBCloneStatusCompleted
|
|
progress.Progress = 100
|
|
progress.Message = "Source FAQ knowledge base is empty"
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKBCloneProgress(ctx, progress)
|
|
return nil
|
|
}
|
|
srcKnowledge := srcKnowledgeList[0]
|
|
|
|
// Get chunk-level differences based on content_hash
|
|
chunksToAdd, chunksToDelete, err := s.chunkRepo.FAQChunkDiff(ctx, srcKB.TenantID, srcKB.ID, dstKB.TenantID, dstKB.ID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to calculate FAQ chunk difference: %v", err)
|
|
handleError(progress, err, "Failed to calculate FAQ chunk difference")
|
|
return err
|
|
}
|
|
|
|
totalOperations := len(chunksToAdd) + len(chunksToDelete)
|
|
progress.Total = totalOperations
|
|
progress.Message = fmt.Sprintf("Found %d FAQ entries to add, %d to delete", len(chunksToAdd), len(chunksToDelete))
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKBCloneProgress(ctx, progress)
|
|
|
|
logger.Infof(ctx, "FAQ chunks to add: %d, delete: %d", len(chunksToAdd), len(chunksToDelete))
|
|
|
|
// If nothing to do, mark as completed
|
|
if totalOperations == 0 {
|
|
progress.Status = types.KBCloneStatusCompleted
|
|
progress.Progress = 100
|
|
progress.Message = "FAQ knowledge base is already in sync"
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKBCloneProgress(ctx, progress)
|
|
return nil
|
|
}
|
|
|
|
// Route the FAQ clone through the source KB's bound store. Same
|
|
// constraint as CloneChunk: callers must ensure source and target share
|
|
// the same VectorStore (cross-store FAQ clone is not handled here).
|
|
var sourceStoreID *string
|
|
if srcKB != nil {
|
|
sourceStoreID = srcKB.VectorStoreID
|
|
}
|
|
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
|
|
ctx, s.retrieveEngine, s.ownership, types.MustTenantIDFromContext(ctx), sourceStoreID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to init retrieve engine: %v", err)
|
|
handleError(progress, err, "Failed to initialize retrieve engine")
|
|
return err
|
|
}
|
|
|
|
// Get embedding model
|
|
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, dstKB.EmbeddingModelID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get embedding model: %v", err)
|
|
handleError(progress, err, "Failed to get embedding model")
|
|
return err
|
|
}
|
|
|
|
processedCount := 0
|
|
|
|
// Delete FAQ chunks that don't exist in source
|
|
if len(chunksToDelete) > 0 {
|
|
// Delete from vector store
|
|
if err := retrieveEngine.DeleteByChunkIDList(ctx, chunksToDelete, embeddingModel.GetDimensions(), types.KnowledgeTypeFAQ); err != nil {
|
|
logger.Errorf(ctx, "Failed to delete FAQ chunks from vector store: %v", err)
|
|
handleError(progress, err, "Failed to delete FAQ entries from vector store")
|
|
return err
|
|
}
|
|
// Delete from database
|
|
if err := s.chunkRepo.DeleteChunks(ctx, dstKB.TenantID, chunksToDelete); err != nil {
|
|
logger.Errorf(ctx, "Failed to delete FAQ chunks from database: %v", err)
|
|
handleError(progress, err, "Failed to delete FAQ entries from database")
|
|
return err
|
|
}
|
|
processedCount += len(chunksToDelete)
|
|
if totalOperations > 0 {
|
|
progress.Progress = processedCount * 100 / totalOperations
|
|
}
|
|
progress.Processed = processedCount
|
|
progress.Message = fmt.Sprintf("Deleted %d FAQ entries, adding %d...", len(chunksToDelete), len(chunksToAdd))
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKBCloneProgress(ctx, progress)
|
|
}
|
|
|
|
// Get or create the FAQ knowledge entry in destination
|
|
dstKnowledge, err := s.getOrCreateFAQKnowledge(ctx, dstKB, srcKnowledge)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get or create FAQ knowledge: %v", err)
|
|
handleError(progress, err, "Failed to prepare FAQ knowledge entry")
|
|
return err
|
|
}
|
|
|
|
// Clone FAQ chunks from source to destination
|
|
batch := 50
|
|
tagIDMapping := map[string]string{} // srcTagID -> dstTagID
|
|
for i := 0; i < len(chunksToAdd); i += batch {
|
|
end := i + batch
|
|
if end > len(chunksToAdd) {
|
|
end = len(chunksToAdd)
|
|
}
|
|
batchIDs := chunksToAdd[i:end]
|
|
|
|
// Get source chunks
|
|
srcChunks, err := s.chunkRepo.ListChunksByID(ctx, srcKB.TenantID, batchIDs)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get source FAQ chunks: %v", err)
|
|
handleError(progress, err, "Failed to get source FAQ entries")
|
|
return err
|
|
}
|
|
|
|
// Create new chunks for destination
|
|
newChunks := make([]*types.Chunk, 0, len(srcChunks))
|
|
for _, srcChunk := range srcChunks {
|
|
// Map TagID to target knowledge base
|
|
targetTagID := ""
|
|
if srcChunk.TagID != "" {
|
|
if mappedTagID, ok := tagIDMapping[srcChunk.TagID]; ok {
|
|
targetTagID = mappedTagID
|
|
} else {
|
|
// Try to find or create the tag in target knowledge base
|
|
targetTagID = s.getOrCreateTagInTarget(ctx, srcKB.TenantID, dstKB.TenantID, dstKB.ID, srcChunk.TagID, tagIDMapping)
|
|
}
|
|
}
|
|
|
|
newChunk := &types.Chunk{
|
|
ID: uuid.New().String(),
|
|
TenantID: dstKB.TenantID,
|
|
KnowledgeID: dstKnowledge.ID,
|
|
KnowledgeBaseID: dstKB.ID,
|
|
TagID: targetTagID,
|
|
Content: srcChunk.Content,
|
|
ChunkIndex: srcChunk.ChunkIndex,
|
|
IsEnabled: srcChunk.IsEnabled,
|
|
Flags: srcChunk.Flags,
|
|
ChunkType: types.ChunkTypeFAQ,
|
|
Metadata: srcChunk.Metadata,
|
|
ContentHash: srcChunk.ContentHash,
|
|
ImageInfo: srcChunk.ImageInfo,
|
|
Status: int(types.ChunkStatusStored), // Initially stored, will be indexed
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
newChunks = append(newChunks, newChunk)
|
|
}
|
|
|
|
// Save to database
|
|
if err := s.chunkRepo.CreateChunks(ctx, newChunks); err != nil {
|
|
logger.Errorf(ctx, "Failed to create FAQ chunks: %v", err)
|
|
handleError(progress, err, "Failed to create FAQ entries")
|
|
return err
|
|
}
|
|
|
|
// Index in vector store using existing method
|
|
// This will index standard question + similar questions based on FAQConfig
|
|
if err := s.indexFAQChunks(ctx, dstKB, dstKnowledge, newChunks, embeddingModel, false, false); err != nil {
|
|
logger.Errorf(ctx, "Failed to index FAQ chunks: %v", err)
|
|
handleError(progress, err, "Failed to index FAQ entries")
|
|
return err
|
|
}
|
|
|
|
// Update chunk status to indexed
|
|
for _, chunk := range newChunks {
|
|
chunk.Status = int(types.ChunkStatusIndexed)
|
|
}
|
|
if err := s.chunkService.UpdateChunks(ctx, newChunks); err != nil {
|
|
logger.Warnf(ctx, "Failed to update FAQ chunks status: %v", err)
|
|
// Don't fail the whole operation for status update failure
|
|
}
|
|
|
|
processedCount += len(batchIDs)
|
|
if totalOperations > 0 {
|
|
progress.Progress = processedCount * 100 / totalOperations
|
|
}
|
|
progress.Processed = processedCount
|
|
progress.Message = fmt.Sprintf("Added %d/%d FAQ entries", processedCount-len(chunksToDelete), len(chunksToAdd))
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKBCloneProgress(ctx, progress)
|
|
}
|
|
|
|
// Mark as completed
|
|
progress.Status = types.KBCloneStatusCompleted
|
|
progress.Progress = 100
|
|
progress.Processed = totalOperations
|
|
progress.Message = "FAQ knowledge base clone completed successfully"
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
if err := s.saveKBCloneProgress(ctx, progress); err != nil {
|
|
logger.Errorf(ctx, "Failed to update KB clone progress to completed: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getOrCreateFAQKnowledge gets or creates the FAQ knowledge entry for a knowledge base
|
|
// If srcKnowledge is provided, it will copy relevant fields from source when creating new knowledge
|
|
func (s *knowledgeService) getOrCreateFAQKnowledge(ctx context.Context, kb *types.KnowledgeBase, srcKnowledge *types.Knowledge) (*types.Knowledge, error) {
|
|
// FAQ knowledge base should have exactly one Knowledge entry
|
|
knowledgeList, err := s.repo.ListKnowledgeByKnowledgeBaseID(ctx, kb.TenantID, kb.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(knowledgeList) > 0 {
|
|
return knowledgeList[0], nil
|
|
}
|
|
|
|
// Create a new FAQ knowledge entry, copying from source if available
|
|
knowledge := &types.Knowledge{
|
|
ID: uuid.New().String(),
|
|
TenantID: kb.TenantID,
|
|
KnowledgeBaseID: kb.ID,
|
|
Type: types.KnowledgeTypeFAQ,
|
|
Channel: types.ChannelWeb,
|
|
Title: "FAQ",
|
|
ParseStatus: "completed",
|
|
EnableStatus: "enabled",
|
|
EmbeddingModelID: kb.EmbeddingModelID,
|
|
}
|
|
|
|
// Copy additional fields from source knowledge if available
|
|
if srcKnowledge != nil {
|
|
knowledge.Title = srcKnowledge.Title
|
|
knowledge.Description = srcKnowledge.Description
|
|
knowledge.Source = srcKnowledge.Source
|
|
knowledge.Channel = srcKnowledge.Channel
|
|
knowledge.Metadata = srcKnowledge.Metadata
|
|
}
|
|
|
|
if err := s.repo.CreateKnowledge(ctx, knowledge); err != nil {
|
|
return nil, err
|
|
}
|
|
return knowledge, nil
|
|
}
|
|
|
|
// saveKBCloneProgress saves the KB clone progress to Redis
|
|
func (s *knowledgeService) saveKBCloneProgress(ctx context.Context, progress *types.KBCloneProgress) error {
|
|
key := getKBCloneProgressKey(progress.TaskID)
|
|
data, err := json.Marshal(progress)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal progress: %w", err)
|
|
}
|
|
return s.redisClient.Set(ctx, key, data, kbCloneProgressTTL).Err()
|
|
}
|
|
|
|
// SaveKBCloneProgress saves the KB clone progress to Redis (public method for handler use)
|
|
func (s *knowledgeService) SaveKBCloneProgress(ctx context.Context, progress *types.KBCloneProgress) error {
|
|
return s.saveKBCloneProgress(ctx, progress)
|
|
}
|
|
|
|
// GetKBCloneProgress retrieves the progress of a knowledge base clone task
|
|
func (s *knowledgeService) GetKBCloneProgress(ctx context.Context, taskID string) (*types.KBCloneProgress, error) {
|
|
key := getKBCloneProgressKey(taskID)
|
|
data, err := s.redisClient.Get(ctx, key).Bytes()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
return nil, werrors.NewNotFoundError("KB clone task not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get progress from Redis: %w", err)
|
|
}
|
|
|
|
var progress types.KBCloneProgress
|
|
if err := json.Unmarshal(data, &progress); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal progress: %w", err)
|
|
}
|
|
return &progress, nil
|
|
}
|
|
|
|
const (
|
|
knowledgeMoveProgressKeyPrefix = "knowledge_move_progress:"
|
|
knowledgeMoveProgressTTL = 24 * time.Hour
|
|
)
|
|
|
|
func getKnowledgeMoveProgressKey(taskID string) string {
|
|
return knowledgeMoveProgressKeyPrefix + taskID
|
|
}
|
|
|
|
func (s *knowledgeService) saveKnowledgeMoveProgress(ctx context.Context, progress *types.KnowledgeMoveProgress) error {
|
|
key := getKnowledgeMoveProgressKey(progress.TaskID)
|
|
data, err := json.Marshal(progress)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal move progress: %w", err)
|
|
}
|
|
return s.redisClient.Set(ctx, key, data, knowledgeMoveProgressTTL).Err()
|
|
}
|
|
|
|
// SaveKnowledgeMoveProgress saves the knowledge move progress to Redis (public method for handler use)
|
|
func (s *knowledgeService) SaveKnowledgeMoveProgress(ctx context.Context, progress *types.KnowledgeMoveProgress) error {
|
|
return s.saveKnowledgeMoveProgress(ctx, progress)
|
|
}
|
|
|
|
// GetKnowledgeMoveProgress retrieves the progress of a knowledge move task
|
|
func (s *knowledgeService) GetKnowledgeMoveProgress(ctx context.Context, taskID string) (*types.KnowledgeMoveProgress, error) {
|
|
key := getKnowledgeMoveProgressKey(taskID)
|
|
data, err := s.redisClient.Get(ctx, key).Bytes()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
return nil, werrors.NewNotFoundError("Knowledge move task not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get move progress from Redis: %w", err)
|
|
}
|
|
|
|
var progress types.KnowledgeMoveProgress
|
|
if err := json.Unmarshal(data, &progress); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal move progress: %w", err)
|
|
}
|
|
return &progress, nil
|
|
}
|
|
|
|
// ProcessKnowledgeMove handles Asynq knowledge move tasks
|
|
func (s *knowledgeService) ProcessKnowledgeMove(ctx context.Context, t *asynq.Task) error {
|
|
var payload types.KnowledgeMovePayload
|
|
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
|
|
return fmt.Errorf("failed to unmarshal knowledge move payload: %w", err)
|
|
}
|
|
|
|
// Add tenant ID to context
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID)
|
|
|
|
// Get tenant info and add to context
|
|
tenantInfo, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "ProcessKnowledgeMove: failed to get tenant info: %v", err)
|
|
return fmt.Errorf("failed to get tenant info: %w", err)
|
|
}
|
|
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenantInfo)
|
|
|
|
// Check if this is the last retry
|
|
retryCount, _ := asynq.GetRetryCount(ctx)
|
|
maxRetry, _ := asynq.GetMaxRetry(ctx)
|
|
isLastRetry := retryCount >= maxRetry
|
|
|
|
logger.Infof(ctx, "ProcessKnowledgeMove: task=%s, source=%s, target=%s, mode=%s, count=%d, retry=%d/%d",
|
|
payload.TaskID, payload.SourceKBID, payload.TargetKBID, payload.Mode, len(payload.KnowledgeIDs), retryCount, maxRetry)
|
|
|
|
// Helper function to handle errors - only mark as failed on last retry
|
|
handleError := func(progress *types.KnowledgeMoveProgress, err error, message string) {
|
|
if isLastRetry {
|
|
progress.Status = types.KBCloneStatusFailed
|
|
progress.Error = err.Error()
|
|
progress.Message = message
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKnowledgeMoveProgress(ctx, progress)
|
|
}
|
|
}
|
|
|
|
// Update progress to processing
|
|
progress := &types.KnowledgeMoveProgress{
|
|
TaskID: payload.TaskID,
|
|
SourceKBID: payload.SourceKBID,
|
|
TargetKBID: payload.TargetKBID,
|
|
Status: types.KBCloneStatusProcessing,
|
|
Total: len(payload.KnowledgeIDs),
|
|
Progress: 0,
|
|
Message: "Starting knowledge move...",
|
|
UpdatedAt: time.Now().Unix(),
|
|
}
|
|
_ = s.saveKnowledgeMoveProgress(ctx, progress)
|
|
|
|
// Get source and target knowledge bases
|
|
sourceKB, err := s.kbService.GetKnowledgeBaseByID(ctx, payload.SourceKBID)
|
|
if err != nil {
|
|
handleError(progress, err, "Failed to get source knowledge base")
|
|
return err
|
|
}
|
|
targetKB, err := s.kbService.GetKnowledgeBaseByID(ctx, payload.TargetKBID)
|
|
if err != nil {
|
|
handleError(progress, err, "Failed to get target knowledge base")
|
|
return err
|
|
}
|
|
|
|
// Validate compatibility
|
|
if sourceKB.Type != targetKB.Type {
|
|
err := fmt.Errorf("type mismatch: source=%s, target=%s", sourceKB.Type, targetKB.Type)
|
|
handleError(progress, err, "Source and target knowledge bases must be the same type")
|
|
return err
|
|
}
|
|
if sourceKB.EmbeddingModelID != targetKB.EmbeddingModelID {
|
|
err := fmt.Errorf("embedding model mismatch: source=%s, target=%s", sourceKB.EmbeddingModelID, targetKB.EmbeddingModelID)
|
|
handleError(progress, err, "Source and target must use the same embedding model")
|
|
return err
|
|
}
|
|
|
|
// Process each knowledge item
|
|
for i, knowledgeID := range payload.KnowledgeIDs {
|
|
err := s.moveOneKnowledge(ctx, knowledgeID, sourceKB, targetKB, payload.Mode)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "ProcessKnowledgeMove: failed to move knowledge %s: %v", knowledgeID, err)
|
|
progress.Failed++
|
|
}
|
|
progress.Processed = i + 1
|
|
if progress.Total > 0 {
|
|
progress.Progress = progress.Processed * 100 / progress.Total
|
|
}
|
|
progress.Message = fmt.Sprintf("Moved %d/%d knowledge items", progress.Processed, progress.Total)
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKnowledgeMoveProgress(ctx, progress)
|
|
}
|
|
|
|
// Mark as completed
|
|
if progress.Failed > 0 && progress.Failed == progress.Total {
|
|
progress.Status = types.KBCloneStatusFailed
|
|
progress.Message = fmt.Sprintf("Knowledge move failed: all %d items failed", progress.Total)
|
|
} else {
|
|
progress.Status = types.KBCloneStatusCompleted
|
|
progress.Message = fmt.Sprintf("Knowledge move completed: %d/%d succeeded", progress.Processed-progress.Failed, progress.Total)
|
|
}
|
|
progress.Progress = 100
|
|
progress.UpdatedAt = time.Now().Unix()
|
|
_ = s.saveKnowledgeMoveProgress(ctx, progress)
|
|
|
|
logger.Infof(ctx, "ProcessKnowledgeMove: task=%s completed, processed=%d, failed=%d", payload.TaskID, progress.Processed, progress.Failed)
|
|
return nil
|
|
}
|
|
|
|
// moveOneKnowledge moves a single knowledge item from source KB to target KB.
|
|
func (s *knowledgeService) moveOneKnowledge(
|
|
ctx context.Context,
|
|
knowledgeID string,
|
|
sourceKB, targetKB *types.KnowledgeBase,
|
|
mode string,
|
|
) error {
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
// Get the knowledge item
|
|
knowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, knowledgeID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get knowledge %s: %w", knowledgeID, err)
|
|
}
|
|
|
|
// Only move completed items
|
|
if knowledge.ParseStatus != types.ParseStatusCompleted {
|
|
return fmt.Errorf("knowledge %s is not in completed status (current: %s)", knowledgeID, knowledge.ParseStatus)
|
|
}
|
|
|
|
// Mark as processing during move
|
|
knowledge.ParseStatus = types.ParseStatusProcessing
|
|
if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil {
|
|
return fmt.Errorf("failed to mark knowledge as processing: %w", err)
|
|
}
|
|
|
|
switch mode {
|
|
case "reuse_vectors":
|
|
return s.moveKnowledgeReuseVectors(ctx, knowledge, sourceKB, targetKB)
|
|
case "reparse":
|
|
return s.moveKnowledgeReparse(ctx, knowledge, sourceKB, targetKB)
|
|
default:
|
|
return fmt.Errorf("unknown move mode: %s", mode)
|
|
}
|
|
}
|
|
|
|
// moveKnowledgeReuseVectors moves knowledge by copying vector indices and updating DB references.
|
|
func (s *knowledgeService) moveKnowledgeReuseVectors(
|
|
ctx context.Context,
|
|
knowledge *types.Knowledge,
|
|
sourceKB, targetKB *types.KnowledgeBase,
|
|
) error {
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
// 1. Get old chunk IDs for vector index copy mapping
|
|
oldChunks, err := s.chunkRepo.ListChunksByKnowledgeID(ctx, tenantID, knowledge.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list chunks: %w", err)
|
|
}
|
|
|
|
// Build identity mapping (same chunk IDs, just moving between KBs)
|
|
chunkIDMapping := make(map[string]string, len(oldChunks))
|
|
for _, c := range oldChunks {
|
|
chunkIDMapping[c.ID] = c.ID
|
|
}
|
|
|
|
// 2. Copy vector indices from source KB to target KB
|
|
if len(chunkIDMapping) > 0 && knowledge.EmbeddingModelID != "" {
|
|
// reuse_vectors mode copies index entries directly between KBs, which
|
|
// only works inside the same VectorStore backend. Route through the
|
|
// source KB's binding; callers must reject cross-store moves before
|
|
// landing here.
|
|
var sourceStoreID *string
|
|
if sourceKB != nil {
|
|
sourceStoreID = sourceKB.VectorStoreID
|
|
}
|
|
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
|
|
ctx, s.retrieveEngine, s.ownership, tenantID, sourceStoreID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to init retrieve engine: %w", err)
|
|
}
|
|
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, knowledge.EmbeddingModelID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get embedding model: %w", err)
|
|
}
|
|
|
|
// Copy indices from source KB to target KB
|
|
knowledgeIDMapping := map[string]string{knowledge.ID: knowledge.ID}
|
|
if err := retrieveEngine.CopyIndices(ctx, sourceKB.ID, targetKB.ID,
|
|
knowledgeIDMapping, chunkIDMapping,
|
|
embeddingModel.GetDimensions(), sourceKB.Type,
|
|
); err != nil {
|
|
return fmt.Errorf("failed to copy indices: %w", err)
|
|
}
|
|
|
|
// Delete indices from source KB
|
|
if err := retrieveEngine.DeleteByKnowledgeIDList(ctx, []string{knowledge.ID},
|
|
embeddingModel.GetDimensions(), sourceKB.Type,
|
|
); err != nil {
|
|
logger.Warnf(ctx, "moveKnowledgeReuseVectors: failed to delete old indices for knowledge %s: %v", knowledge.ID, err)
|
|
// Non-fatal: indices will be orphaned but won't affect correctness
|
|
}
|
|
}
|
|
|
|
// 3. Update chunks' knowledge_base_id in DB
|
|
if err := s.chunkRepo.MoveChunksByKnowledgeID(ctx, tenantID, knowledge.ID, targetKB.ID); err != nil {
|
|
return fmt.Errorf("failed to move chunks: %w", err)
|
|
}
|
|
|
|
// 4. Update knowledge record
|
|
knowledge.KnowledgeBaseID = targetKB.ID
|
|
knowledge.TagID = "" // Clear tag since tags are KB-scoped
|
|
knowledge.ParseStatus = types.ParseStatusCompleted
|
|
knowledge.UpdatedAt = time.Now()
|
|
if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil {
|
|
return fmt.Errorf("failed to update knowledge: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// moveKnowledgeReparse moves knowledge to target KB and re-parses it with target KB's configuration.
|
|
func (s *knowledgeService) moveKnowledgeReparse(
|
|
ctx context.Context,
|
|
knowledge *types.Knowledge,
|
|
_, targetKB *types.KnowledgeBase,
|
|
) error {
|
|
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
|
|
|
// 1. Clean up existing chunks and vector indices
|
|
if err := s.cleanupKnowledgeResources(ctx, knowledge); err != nil {
|
|
logger.Warnf(ctx, "moveKnowledgeReparse: cleanup partial error for knowledge %s: %v", knowledge.ID, err)
|
|
// Continue - partial cleanup is acceptable
|
|
}
|
|
|
|
// 2. Update knowledge to belong to target KB
|
|
knowledge.KnowledgeBaseID = targetKB.ID
|
|
knowledge.EmbeddingModelID = targetKB.EmbeddingModelID
|
|
knowledge.TagID = "" // Clear tag since tags are KB-scoped
|
|
knowledge.ParseStatus = types.ParseStatusPending
|
|
knowledge.EnableStatus = "disabled"
|
|
knowledge.Description = ""
|
|
knowledge.ProcessedAt = nil
|
|
knowledge.UpdatedAt = time.Now()
|
|
if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil {
|
|
return fmt.Errorf("failed to update knowledge: %w", err)
|
|
}
|
|
|
|
// 3. Enqueue document processing task with target KB's configuration
|
|
if knowledge.IsManual() {
|
|
meta, err := knowledge.ManualMetadata()
|
|
if err != nil || meta == nil {
|
|
return fmt.Errorf("failed to get manual metadata for reparse: %w", err)
|
|
}
|
|
s.triggerManualProcessing(ctx, targetKB, knowledge, meta.Content, false)
|
|
return nil
|
|
}
|
|
|
|
if knowledge.FilePath != "" {
|
|
enableMultimodel := targetKB.IsMultimodalEnabled()
|
|
enableQuestionGeneration := false
|
|
questionCount := 3
|
|
if targetKB.QuestionGenerationConfig != nil && targetKB.QuestionGenerationConfig.Enabled {
|
|
enableQuestionGeneration = true
|
|
if targetKB.QuestionGenerationConfig.QuestionCount > 0 {
|
|
questionCount = targetKB.QuestionGenerationConfig.QuestionCount
|
|
}
|
|
}
|
|
|
|
lang, _ := types.LanguageFromContext(ctx)
|
|
taskPayload := types.DocumentProcessPayload{
|
|
TenantID: tenantID,
|
|
KnowledgeID: knowledge.ID,
|
|
KnowledgeBaseID: targetKB.ID,
|
|
FilePath: knowledge.FilePath,
|
|
FileName: knowledge.FileName,
|
|
FileType: getFileType(knowledge.FileName),
|
|
EnableMultimodel: enableMultimodel,
|
|
EnableQuestionGeneration: enableQuestionGeneration,
|
|
QuestionCount: questionCount,
|
|
Language: lang,
|
|
}
|
|
|
|
langfuse.InjectTracing(ctx, &taskPayload)
|
|
payloadBytes, err := json.Marshal(taskPayload)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal document process payload: %w", err)
|
|
}
|
|
|
|
task := asynq.NewTask(types.TypeDocumentProcess, payloadBytes,
|
|
documentProcessTaskOptions(s.config, asynq.MaxRetry(3))...)
|
|
info, err := s.task.Enqueue(task)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to enqueue document process task: %w", err)
|
|
}
|
|
logger.Infof(ctx, "moveKnowledgeReparse: enqueued reparse task id=%s for knowledge=%s", info.ID, knowledge.ID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getOrCreateTagInTarget finds or creates a tag in the target knowledge base based on the source tag.
|
|
// It looks up the source tag by ID, then tries to find a tag with the same name in the target KB.
|
|
// If not found, it creates a new tag with the same properties.
|
|
// The mapping is cached in tagIDMapping for subsequent lookups.
|
|
func (s *knowledgeService) getOrCreateTagInTarget(
|
|
ctx context.Context,
|
|
srcTenantID, dstTenantID uint64,
|
|
dstKnowledgeBaseID string,
|
|
srcTagID string,
|
|
tagIDMapping map[string]string,
|
|
) string {
|
|
// Get source tag
|
|
srcTag, err := s.tagRepo.GetByID(ctx, srcTenantID, srcTagID)
|
|
if err != nil || srcTag == nil {
|
|
logger.Warnf(ctx, "Failed to get source tag %s: %v", srcTagID, err)
|
|
tagIDMapping[srcTagID] = "" // Cache empty result to avoid repeated lookups
|
|
return ""
|
|
}
|
|
|
|
// Try to find existing tag with same name in target KB
|
|
dstTag, err := s.tagRepo.GetByName(ctx, dstTenantID, dstKnowledgeBaseID, srcTag.Name)
|
|
if err == nil && dstTag != nil {
|
|
tagIDMapping[srcTagID] = dstTag.ID
|
|
return dstTag.ID
|
|
}
|
|
|
|
// Create new tag in target KB
|
|
// "未分类" tag should have the lowest sort order to appear first
|
|
sortOrder := srcTag.SortOrder
|
|
if srcTag.Name == types.UntaggedTagName {
|
|
sortOrder = -1
|
|
}
|
|
newTag := &types.KnowledgeTag{
|
|
ID: uuid.New().String(),
|
|
TenantID: dstTenantID,
|
|
KnowledgeBaseID: dstKnowledgeBaseID,
|
|
Name: srcTag.Name,
|
|
Color: srcTag.Color,
|
|
SortOrder: sortOrder,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := s.tagRepo.Create(ctx, newTag); err != nil {
|
|
logger.Warnf(ctx, "Failed to create tag %s in target KB: %v", srcTag.Name, err)
|
|
tagIDMapping[srcTagID] = "" // Cache empty result
|
|
return ""
|
|
}
|
|
|
|
tagIDMapping[srcTagID] = newTag.ID
|
|
logger.Infof(ctx, "Created tag %s (ID: %s) in target KB %s", newTag.Name, newTag.ID, dstKnowledgeBaseID)
|
|
return newTag.ID
|
|
}
|