Files
xinmi/internal/application/service/knowledge_clone_move.go
T

1653 lines
60 KiB
Go
Raw Normal View History

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"slices"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/application/service/retriever"
werrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/knowledgeversion"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/google/uuid"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/errgroup"
)
// copyOwnedObject copies srcPath into a NEW object owned by the destination
// tenant, returning the new provider:// (resource) path.
//
// Extracted/embedded chunk images MUST land in the tenant's exports/ namespace,
// because GET /knowledge-bases/:id/files only serves objects that pass
// ValidateKBScopedStoragePath (i.e. {tenant}/exports/...). CopyFile writes to
// the knowledge-scoped upload layout ({tenant}/{knowledgeID}/...) used for raw
// source files, which the KB proxy rejects — so a clone that used CopyFile
// produced images that could no longer be rendered. Instead, read the source
// bytes and re-save them via SaveBytes, exactly mirroring how the original
// images were persisted during ingestion (see image_resolver.saveReferencedImage),
// so the copy is a genuine independent object in the servable namespace.
func copyOwnedObject(
ctx context.Context,
srcSvc, dstSvc interfaces.FileService,
srcPath string,
tenantID uint64,
knowledgeID string,
) (string, error) {
_ = knowledgeID // exports objects are tenant-scoped, not knowledge-scoped
rc, err := srcSvc.GetFile(ctx, srcPath)
if err != nil {
return "", fmt.Errorf("read source image %q: %w", srcPath, err)
}
defer rc.Close()
const maxCopiedImageBytes = 64 << 20
data, err := io.ReadAll(io.LimitReader(rc, maxCopiedImageBytes+1))
if err != nil {
return "", fmt.Errorf("buffer source image %q: %w", srcPath, err)
}
if len(data) > maxCopiedImageBytes {
return "", fmt.Errorf("embedded image exceeds the 64 MiB copy limit")
}
fileName := uuid.New().String() + imageExtForCopy(srcPath, data)
newPath, err := dstSvc.SaveBytes(ctx, data, tenantID, fileName, false)
if err != nil {
return "", fmt.Errorf("save copied image for %q: %w", srcPath, err)
}
if newPath == "" || newPath == srcPath {
return "", fmt.Errorf("image storage did not create an independent object")
}
return newPath, nil
}
// imageExtForCopy resolves the file extension to use for a copied image. It
// prefers an image extension already present on the source path, then falls
// back to sniffing the content bytes, and finally defaults to ".png" (matching
// image_resolver's default) so the object is always served with a sane type.
func imageExtForCopy(srcPath string, data []byte) string {
if ext := strings.ToLower(filepath.Ext(srcPath)); isImageExt(ext) {
return ext
}
switch http.DetectContentType(data) {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "image/bmp":
return ".bmp"
}
return ".png"
}
func isImageExt(ext string) bool {
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg":
return true
default:
return false
}
}
// cloneChunkImageInfo parses a chunk's image_info JSON, copies every referenced
// object into a NEW object owned by (tenantID, knowledgeID), and returns the
// re-serialized image_info plus the list of newly-created object URLs (for
// rollback on failure). urlCache dedups identical source objects across chunks
// so the same source image is copied at most once per clone AND accumulates the
// full old->new URL mapping so callers can rewrite in-content Markdown image
// references (see rewriteContentImageURLs).
//
// An empty srcImageInfo yields ("", nil, nil). A JSON parse failure returns an
// error (the clone fails) rather than silently inheriting the shared-reference
// bug. When an image's OriginalURL points at the same object as its URL (the
// common case for extracted images), OriginalURL is rewritten to the new path
// too; an OriginalURL from a different/external source is preserved.
func cloneChunkImageInfo(
ctx context.Context,
dstSvc interfaces.FileService,
srcImageInfo string,
tenantID uint64,
knowledgeID string,
urlCache map[string]string,
) (newImageInfo string, copiedURLs []string, err error) {
if srcImageInfo == "" {
return "", nil, nil
}
var images []*types.ImageInfo
if err := json.Unmarshal([]byte(srcImageInfo), &images); err != nil {
return "", nil, fmt.Errorf("failed to parse chunk image_info JSON: %w", err)
}
for _, img := range images {
if img == nil || img.URL == "" {
continue
}
originalMatchedURL := img.OriginalURL == img.URL
newURL, cached := urlCache[img.URL]
if !cached {
newURL, err = copyOwnedObject(ctx, dstSvc, dstSvc, img.URL, tenantID, knowledgeID)
if err != nil {
return "", copiedURLs, fmt.Errorf("failed to copy chunk image %q: %w", img.URL, err)
}
urlCache[img.URL] = newURL
copiedURLs = append(copiedURLs, newURL)
}
if originalMatchedURL {
img.OriginalURL = newURL
}
img.URL = newURL
}
out, err := json.Marshal(images)
if err != nil {
return "", copiedURLs, fmt.Errorf("failed to re-serialize chunk image_info: %w", err)
}
return string(out), copiedURLs, nil
}
// rewriteContentImageURLs replaces every occurrence of an old image URL with its
// new (copied) URL in content, using the old->new mapping accumulated in
// urlCache. It is the second half of the image deep-copy: chunk Content embeds
// image URLs as Markdown ![](url) references, but for document knowledge the
// image objects live in independent image_ocr/image_caption child chunks — the
// parent text chunk carries the ![](url) reference with an empty image_info. So
// the old->new mapping is only known after every chunk's image_info has been
// processed; this rewrite must therefore run as a final pass once urlCache is
// complete, over ALL cloned chunks, not per-chunk.
//
// Replacements are applied longest-old-URL first so a URL that is a prefix of
// another is not partially rewritten. Entries whose old==new are skipped.
func rewriteContentImageURLs(content string, urlCache map[string]string) string {
if content == "" || len(urlCache) == 0 {
return content
}
oldURLs := make([]string, 0, len(urlCache))
for oldURL, newURL := range urlCache {
if oldURL == "" || oldURL == newURL {
continue
}
oldURLs = append(oldURLs, oldURL)
}
slices.SortFunc(oldURLs, func(a, b string) int { return len(b) - len(a) })
for _, oldURL := range oldURLs {
content = strings.ReplaceAll(content, oldURL, urlCache[oldURL])
}
return content
}
// cleanupCopiedObjects deletes objects that were newly created during a clone
// that subsequently failed, to avoid orphaning storage. It is best-effort:
// delete errors are logged but never returned (the original clone error wins).
func cleanupCopiedObjects(ctx context.Context, svc interfaces.FileService, paths []string) {
if len(paths) == 0 || svc == nil {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
logger.Infof(ctx, "Cleaning up %d copied objects after clone failure", len(paths))
for _, p := range paths {
if p == "" {
continue
}
if err := svc.DeleteFile(ctx, p); err != nil {
logger.Errorf(ctx, "Failed to clean up copied object %s: %v", p, err)
}
}
}
func (s *knowledgeService) CloneKnowledgeBase(ctx context.Context, srcID, dstID string) error {
if err := checkKnowledgeCopyLineage(ctx, srcID); err != nil {
return err
}
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) (err error) {
return s.cloneChunk(ctx, src, dst, map[string]string{})
}
func (s *knowledgeService) cloneChunk(ctx context.Context, src, dst *types.Knowledge, tagIDMapping map[string]string, versionMappings ...map[string]string) (err error) {
if err := checkKnowledgeCopyLineage(ctx, src.KnowledgeBaseID); err != nil {
return err
}
chunkPage := 1
chunkPageSize := 100
srcTodst := map[string]string{}
targetChunks := make([]*types.Chunk, 0, 10)
chunkType := []types.ChunkType{
types.ChunkTypeText, types.ChunkTypeParentText, types.ChunkTypeSummary,
types.ChunkTypeImageCaption, types.ChunkTypeImageOCR,
}
versionCopy := knowledgeversion.IsWorker(ctx)
if versionCopy {
chunkType = append(chunkType, types.ChunkTypeFAQ, types.ChunkTypeEntity, types.ChunkTypeRelationship,
types.ChunkTypeWebSearch, types.ChunkTypeTableSummary, types.ChunkTypeTableColumn, types.ChunkTypeWikiPage)
}
// Resolve the destination FileService so extracted images can be copied
// into objects owned by the destination knowledge. urlCache dedups identical
// source images across chunks; copiedURLs accumulates new objects so they can
// be cleaned up if the clone fails partway through.
dstKB, dstKBErr := s.kbService.GetKnowledgeBaseByID(ctx, dst.KnowledgeBaseID)
if dstKBErr != nil {
return fmt.Errorf("failed to load destination knowledge base for image copy: %w", dstKBErr)
}
dstSvc := s.resolveFileService(ctx, dstKB)
if versionCopy {
dstSvc = &versionTrackingFileService{FileService: dstSvc, service: s, knowledge: dst}
}
urlCache := map[string]string{}
var copiedURLs []string
var references *versionReferenceCopier
defer func() {
if err != nil {
if references != nil {
copiedURLs = append(copiedURLs, references.copied...)
}
cleanupCopiedObjects(ctx, dstSvc, copiedURLs)
}
}()
if versionCopy {
if src.FilePath != "" && dst.FilePath != "" {
urlCache[src.FilePath] = dst.FilePath
}
srcKB, loadErr := s.kbService.GetKnowledgeBaseByID(ctx, src.KnowledgeBaseID)
if loadErr != nil {
return loadErr
}
references = &versionReferenceCopier{ctx: ctx, source: func(path string) interfaces.FileService {
return s.resolveFileServiceForPath(ctx, srcKB, path)
}, destination: dstSvc, tenantID: dst.TenantID, knowledgeID: dst.ID, urls: urlCache}
for _, field := range []struct {
source types.JSON
target *types.JSON
}{
{src.Metadata, &dst.Metadata}, {src.CustomMetadata, &dst.CustomMetadata},
} {
value, copyErr := references.json(field.source)
if copyErr != nil {
return copyErr
}
*field.target = value
}
}
for {
var sourceChunks []*types.Chunk
var listErr error
if versionCopy {
if chunkPage > 1 {
break
}
// The paginated browse API excludes stored parent chunks. Version
// copies must include them to preserve the original context tree.
sourceChunks, listErr = s.chunkRepo.ListChunksByKnowledgeIDAndTypes(ctx, src.TenantID, src.ID, chunkType)
} else {
sourceChunks, _, listErr = s.chunkRepo.ListPagedChunksByKnowledgeID(ctx,
src.TenantID,
src.ID,
&types.Pagination{
Page: chunkPage,
PageSize: chunkPageSize,
},
chunkType,
nil,
"",
"",
"",
"",
nil,
)
}
chunkPage++
if listErr != nil {
return listErr
}
if len(sourceChunks) == 0 {
break
}
now := time.Now()
for _, sourceChunk := range sourceChunks {
if versionCopy {
switch sourceChunk.ChunkType {
case types.ChunkTypeText, types.ChunkTypeParentText, types.ChunkTypeSummary,
types.ChunkTypeImageCaption, types.ChunkTypeImageOCR, types.ChunkTypeFAQ:
default:
return fmt.Errorf("当前知识分块类型尚不支持独立版本复制: %s", sourceChunk.ChunkType)
}
if sourceChunk.IndexStatus != "" && sourceChunk.IndexStatus != "ready" {
return fmt.Errorf("知识分块尚未完成索引: %s", sourceChunk.ID)
}
}
// Map TagID to target knowledge base
targetTagID := ""
if sourceChunk.TagID != "" {
if mappedTagID, ok := tagIDMapping[sourceChunk.TagID]; ok {
targetTagID = mappedTagID
} else {
if versionCopy {
return fmt.Errorf("知识分类未完整复制: %s", sourceChunk.TagID)
}
// Try to find or create the tag in target knowledge base
targetTagID = s.getOrCreateTagInTarget(ctx, src.TenantID, dst.TenantID, dst.KnowledgeBaseID, sourceChunk.TagID, tagIDMapping)
}
}
// Deep-copy extracted images into objects owned by the destination
// knowledge so deleting the source never breaks this clone. Content
// URL rewriting happens in a final pass below, once urlCache holds
// the complete old->new mapping (image objects live in independent
// child chunks, so a parent text chunk's ![](url) reference cannot be
// rewritten until its child image chunk has been processed).
var newImageInfo string
var copied []string
var copyErr error
if versionCopy {
var imageJSON types.JSON
imageJSON, copyErr = references.json([]byte(sourceChunk.ImageInfo))
newImageInfo = string(imageJSON)
} else {
newImageInfo, copied, copyErr = cloneChunkImageInfo(
ctx, dstSvc, sourceChunk.ImageInfo, dst.TenantID, dst.ID, urlCache)
}
copiedURLs = append(copiedURLs, copied...)
if copyErr != nil {
err = fmt.Errorf("clone chunk image copy failed: %w", copyErr)
return err
}
targetChunk := &types.Chunk{
ID: uuid.New().String(),
TenantID: dst.TenantID,
KnowledgeID: dst.ID,
KnowledgeBaseID: dst.KnowledgeBaseID,
TagID: targetTagID,
Content: sourceChunk.Content,
SourceContent: sourceChunk.SourceContent,
ContentRevision: sourceChunk.ContentRevision,
IndexStatus: sourceChunk.IndexStatus,
LastEditorID: sourceChunk.LastEditorID,
ContextHeader: sourceChunk.ContextHeader,
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: newImageInfo,
CreatedAt: now,
UpdatedAt: now,
}
if versionCopy {
for _, value := range []*string{&targetChunk.Content, &targetChunk.SourceContent, &targetChunk.ContextHeader} {
copied, copyErr := references.text(*value)
if copyErr != nil {
return copyErr
}
*value = copied
}
targetChunk.Metadata, copyErr = references.json(sourceChunk.Metadata)
if copyErr != nil {
return copyErr
}
}
targetChunks = append(targetChunks, targetChunk)
srcTodst[sourceChunk.ID] = targetChunk.ID
}
}
for _, targetChunk := range targetChunks {
// Rewrite in-content Markdown image URLs now that urlCache holds the
// complete old->new mapping across all chunks. This fixes parent text
// chunks whose ![](url) reference points at a source object copied while
// processing an independent image_ocr/image_caption child chunk.
targetChunk.Content = rewriteContentImageURLs(targetChunk.Content, urlCache)
targetChunk.SourceContent = rewriteContentImageURLs(targetChunk.SourceContent, urlCache)
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 = ""
}
}
if references != nil && len(references.copied) > 0 {
if len(targetChunks) == 0 {
return fmt.Errorf("带有附件的知识没有可复制分块")
}
// Native deletion collects image_info. Keep metadata-only attachments
// in that inventory as well so later deletion can reclaim every copy.
var inventory []*types.ImageInfo
if targetChunks[0].ImageInfo != "" {
if err := json.Unmarshal([]byte(targetChunks[0].ImageInfo), &inventory); err != nil {
return err
}
}
seen := map[string]bool{}
for _, img := range inventory {
if img != nil {
seen[img.URL] = true
}
}
for _, path := range references.copied {
if !seen[path] {
inventory = append(inventory, &types.ImageInfo{URL: path, OriginalURL: path})
seen[path] = true
}
if _, resourceReference := types.ParseResourcePath(path); resourceReference && s.resourceCatalog != nil {
if err := s.resourceCatalog.Bind(ctx, path, types.ResourceOwnerKnowledge, dst.ID, types.ResourceRelationExtractedImage); err != nil {
return err
}
}
}
encoded, marshalErr := json.Marshal(inventory)
if marshalErr != nil {
return marshalErr
}
targetChunks[0].ImageInfo = string(encoded)
}
for chunks := range slices.Chunk(targetChunks, chunkPageSize) {
var state []types.Chunk
if versionCopy {
for _, chunk := range chunks {
state = append(state, *chunk)
}
}
err := s.chunkRepo.CreateChunks(ctx, chunks)
if err != nil {
return err
}
if versionCopy {
var corrected []*types.Chunk
for i, chunk := range chunks {
if chunk.IsEnabled != state[i].IsEnabled || chunk.Flags != state[i].Flags {
chunk.IsEnabled, chunk.Flags = state[i].IsEnabled, state[i].Flags
corrected = append(corrected, chunk)
}
}
if len(corrected) > 0 {
if err := s.chunkRepo.UpdateChunks(ctx, corrected); 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
}
if knowledgeversion.IsWorker(ctx) {
tags := make(map[string]string, len(targetChunks))
enabled := make(map[string]bool, len(targetChunks))
for _, chunk := range targetChunks {
tags[chunk.ID] = chunk.TagID
enabled[chunk.ID] = chunk.IsEnabled && src.EnableStatus != "disabled"
}
if len(tags) > 0 {
if err := retrieveEngine.BatchUpdateChunkTagID(ctx, tags); err != nil {
return err
}
if err := retrieveEngine.BatchUpdateChunkEnabledStatus(ctx, enabled); err != nil {
return err
}
}
}
for _, mapping := range versionMappings {
for source, target := range srcTodst {
mapping[source] = target
}
}
return nil
}
func checkKnowledgeCopyLineage(ctx context.Context, sourceID string) error {
if knowledgeversion.IsWorker(ctx) || types.IsSystemAdminFromContext(ctx) || types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleAdmin) {
return nil
}
snapshot := accesspolicy.FromContext(ctx)
if snapshot != nil && (snapshot.Publications["knowledge:"+sourceID] || snapshot.NoExport["knowledge:"+sourceID]) {
return werrors.NewForbiddenError("全局知识库及其个人版本不能复制或移动到其他知识库,请使用个人版本")
}
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)
}
ctx = payload.Initiator.Apply(ctx)
ctx = withKBActivityTask(ctx, payload.TaskID, kbActivityTrigger(ctx))
// 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)
recordKBActivity(ctx, s.audit, payload.TenantID, payload.TargetID, types.AuditActionKBCloneFailed,
"knowledge_base", payload.TargetID, types.AuditOutcomeFailed,
map[string]any{"source_kb_id": payload.SourceID, "task_id": payload.TaskID})
}
}
// 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
}
if retryCount == 0 {
recordKBActivity(ctx, s.audit, payload.TenantID, payload.TargetID, types.AuditActionKBCloneStarted,
"knowledge_base", payload.TargetID, types.AuditOutcomeAccepted,
map[string]any{"source_kb_id": payload.SourceID, "task_id": payload.TaskID})
}
// Use different sync strategies based on knowledge base type
if srcKB.Type == types.KnowledgeBaseTypeFAQ {
if err := s.cloneFAQKnowledgeBase(ctx, srcKB, dstKB, progress, handleError); err != nil {
return err
}
recordKBActivity(ctx, s.audit, payload.TenantID, payload.TargetID, types.AuditActionKBCloneCompleted,
"knowledge_base", payload.TargetID, types.AuditOutcomeSuccess,
map[string]any{"source_kb_id": payload.SourceID, "task_id": payload.TaskID, "total": progress.Total})
return nil
}
// 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)
recordKBActivity(ctx, s.audit, payload.TenantID, payload.TargetID, types.AuditActionKBCloneCompleted,
"knowledge_base", payload.TargetID, types.AuditOutcomeSuccess,
map[string]any{"source_kb_id": payload.SourceID, "task_id": payload.TaskID, "total": totalOperations})
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),
) (retErr error) {
// Deep-copy extracted FAQ images into objects owned by the destination KB.
// urlCache dedups identical source images across chunks; copiedURLs tracks
// new objects for best-effort cleanup if the clone fails partway through.
dstSvc := s.resolveFileService(ctx, dstKB)
imageURLCache := map[string]string{}
var copiedImageURLs []string
defer func() {
if retErr != nil {
cleanupCopiedObjects(ctx, dstSvc, copiedImageURLs)
}
}()
// 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.
diff, 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
}
chunksToAdd := diff.ChunksToAdd
chunksToDelete := diff.ChunksToDelete
tagIDMapping := map[string]string{}
resolveFAQTag := func(srcTagID string) string {
if srcTagID == "" {
return ""
}
if id, ok := tagIDMapping[srcTagID]; ok {
return id
}
return s.getOrCreateTagInTarget(ctx, srcKB.TenantID, dstKB.TenantID, dstKB.ID, srcTagID, tagIDMapping)
}
syncPlan, err := s.buildFAQStatusSyncPlan(ctx, srcKB.TenantID, dstKB.TenantID, diff.MatchedPairs, resolveFAQTag)
if err != nil {
logger.Errorf(ctx, "Failed to build FAQ status sync plan: %v", err)
handleError(progress, err, "Failed to build FAQ status sync plan")
return err
}
chunksToUpdate := syncPlan.Pairs
totalOperations := len(chunksToAdd) + len(chunksToDelete) + len(chunksToUpdate)
progress.Total = totalOperations
progress.Message = fmt.Sprintf(
"Found %d FAQ entries to add, %d to delete, %d to update status",
len(chunksToAdd), len(chunksToDelete), len(chunksToUpdate),
)
progress.UpdatedAt = time.Now().Unix()
_ = s.saveKBCloneProgress(ctx, progress)
logger.Infof(ctx, "FAQ chunks to add: %d, delete: %d, update status: %d",
len(chunksToAdd), len(chunksToDelete), len(chunksToUpdate))
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
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 != "" {
targetTagID = resolveFAQTag(srcChunk.TagID)
}
// Deep-copy extracted images into objects owned by the destination
// FAQ knowledge so deleting the source never breaks this clone.
newImageInfo, copied, copyErr := cloneChunkImageInfo(
ctx, dstSvc, srcChunk.ImageInfo, dstKB.TenantID, dstKnowledge.ID, imageURLCache)
if copyErr != nil {
logger.Errorf(ctx, "Failed to copy FAQ chunk images: %v", copyErr)
handleError(progress, copyErr, "Failed to copy FAQ entry images")
retErr = copyErr
return retErr
}
copiedImageURLs = append(copiedImageURLs, copied...)
newChunk := &types.Chunk{
ID: uuid.New().String(),
TenantID: dstKB.TenantID,
KnowledgeID: dstKnowledge.ID,
KnowledgeBaseID: dstKB.ID,
TagID: targetTagID,
Content: rewriteContentImageURLs(srcChunk.Content, imageURLCache),
ChunkIndex: srcChunk.ChunkIndex,
IsEnabled: srcChunk.IsEnabled,
Flags: srcChunk.Flags,
ChunkType: types.ChunkTypeFAQ,
Metadata: srcChunk.Metadata,
ContentHash: srcChunk.ContentHash,
ImageInfo: newImageInfo,
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)
}
for i := 0; i < len(chunksToUpdate); i += batch {
end := i + batch
if end > len(chunksToUpdate) {
end = len(chunksToUpdate)
}
if err := s.syncFAQChunkStatusBatch(
ctx, dstKB, chunksToUpdate[i:end], syncPlan.SrcByID, syncPlan.DstByID, resolveFAQTag); err != nil {
logger.Errorf(ctx, "Failed to sync FAQ status fields: %v", err)
handleError(progress, err, "Failed to sync FAQ status fields")
return err
}
processedCount += end - i
if totalOperations > 0 {
progress.Progress = processedCount * 100 / totalOperations
}
progress.Processed = processedCount
progress.Message = fmt.Sprintf("Updated %d/%d FAQ status fields", processedCount-len(chunksToDelete)-len(chunksToAdd), len(chunksToUpdate))
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)
}
ctx = payload.Initiator.Apply(ctx)
ctx = withKBActivityTask(ctx, payload.TaskID, kbActivityTrigger(ctx))
// 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)
for _, kbID := range []string{payload.SourceKBID, payload.TargetKBID} {
recordKBActivity(ctx, s.audit, payload.TenantID, kbID, types.AuditActionKnowledgeMoveFailed,
"knowledge_move", payload.TaskID, types.AuditOutcomeFailed,
map[string]any{"source_kb_id": payload.SourceKBID, "target_kb_id": payload.TargetKBID,
"task_id": payload.TaskID, "count": len(payload.KnowledgeIDs), "mode": payload.Mode})
}
}
}
if retryCount == 0 {
for _, kbID := range []string{payload.SourceKBID, payload.TargetKBID} {
recordKBActivity(ctx, s.audit, payload.TenantID, kbID, types.AuditActionKnowledgeMoveStarted,
"knowledge_move", payload.TaskID, types.AuditOutcomeAccepted,
map[string]any{"source_kb_id": payload.SourceKBID, "target_kb_id": payload.TargetKBID,
"task_id": payload.TaskID, "count": len(payload.KnowledgeIDs), "mode": payload.Mode})
}
}
// 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)
outcome := types.AuditOutcomeSuccess
action := types.AuditActionKnowledgeMoveCompleted
if progress.Failed == progress.Total && progress.Total > 0 {
outcome = types.AuditOutcomeFailed
action = types.AuditActionKnowledgeMoveFailed
} else if progress.Failed > 0 {
outcome = types.AuditOutcomePartial
}
for _, kbID := range []string{payload.SourceKBID, payload.TargetKBID} {
recordKBActivity(ctx, s.audit, payload.TenantID, kbID, action,
"knowledge_move", payload.TaskID, outcome,
map[string]any{"source_kb_id": payload.SourceKBID, "target_kb_id": payload.TargetKBID,
"task_id": payload.TaskID, "count": progress.Total, "failed": progress.Failed, "mode": payload.Mode})
}
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 {
if err := checkKnowledgeCopyLineage(ctx, sourceKB.ID); err != nil {
return err
}
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)
}
// Reject a cross-store reuse_vectors move BEFORE mutating status, so a
// rejected move leaves the knowledge untouched (Completed) rather than
// stranded in Processing. reuse_vectors copies indices through the source
// store only; a cross-store copy would corrupt vector data. The handler
// rejects this synchronously — this is defense-in-depth for directly
// enqueued tasks. Cross-store moves must use reparse mode.
if mode == "reuse_vectors" && !sourceKB.SharesStoreWith(targetKB) {
return fmt.Errorf(
"reuse_vectors move across different vector stores is not supported "+
"(source KB %s, target KB %s); use reparse mode", sourceKB.ID, targetKB.ID)
}
// 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)
}
// From the source KB's point of view the document is leaving for good, so it
// needs the same wiki reconciliation a delete performs: wiki_pages carry
// source_refs back to this knowledge and are what the folder tree and the
// wiki graph are built from, and nothing below touches them. This must run
// while KnowledgeBaseID still points at the source and before any chunk is
// removed, since the cleanup matches pages by chunk_refs.
if sourceKB.IsWikiEnabled() {
s.cleanupWikiOnKnowledgeDelete(ctx, knowledge)
}
switch mode {
case "reuse_vectors":
if err := s.moveKnowledgeReuseVectors(ctx, knowledge, sourceKB, targetKB); err != nil {
return err
}
// reparse re-ingests through KnowledgePostProcess once the new chunks
// land; reuse_vectors keeps the existing chunks and never re-enters that
// pipeline, so the target KB has to be told about the document here.
if targetKB.IsWikiEnabled() {
EnqueueWikiIngest(ctx, s.task, s.taskPendingRepo, tenantID, targetKB.ID, knowledge.ID)
}
return nil
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)
// reuse_vectors copies index entries directly between KBs, which only works
// inside the same VectorStore backend (CopyIndices is routed through the
// source store). A cross-store reuse_vectors move would write target-KB rows
// into the source store and then delete the source indices, corrupting data.
// The MoveKnowledge handler rejects this up front; this is defense-in-depth
// for any path that enqueues a move task directly. Cross-store moves must use
// reparse mode (moveKnowledgeReparse), which re-indexes into the target store.
if !sourceKB.SharesStoreWith(targetKB) {
return fmt.Errorf(
"reuse_vectors move across different vector stores is not supported "+
"(source KB %s, target KB %s); use reparse mode", sourceKB.ID, targetKB.ID)
}
// 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 != "" {
// Same VectorStore backend is guaranteed by the SharesStoreWith guard at
// the top of this function, so routing CopyIndices through the source
// KB's binding also resolves the target's store.
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 (tags are KB-scoped; clear relations before moving)
if err := s.repo.DeleteKnowledgeTagRelations(ctx, knowledge.ID); err != nil {
return fmt.Errorf("failed to clear knowledge tag relations: %w", err)
}
knowledge.KnowledgeBaseID = targetKB.ID
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 (tags are KB-scoped; clear relations)
if err := s.repo.DeleteKnowledgeTagRelations(ctx, knowledge.ID); err != nil {
return fmt.Errorf("failed to clear knowledge tag relations: %w", err)
}
knowledge.KnowledgeBaseID = targetKB.ID
knowledge.EmbeddingModelID = targetKB.EmbeddingModelID
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.LanguageFromContextOrDefault(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
}