package repository import ( "context" "crypto/sha256" "encoding/hex" "fmt" "github.com/Tencent/WeKnora/internal/knowledgeversion" "github.com/Tencent/WeKnora/internal/types" "gorm.io/gorm" ) // CopyVersionGraph copies imported PostgreSQL evidence without rerunning AI // extraction. Provenance remains immutable; only proven native pointers change. func (r *knowledgeRepository) CopyVersionGraph(ctx context.Context, source, target string, documents, chunks map[string]string) error { tenant, ok := types.TenantIDFromContext(ctx) if !knowledgeversion.IsWorker(ctx) || !ok || tenant == 0 || source == target { return fmt.Errorf("graph version copy requires an isolated workspace worker") } db := r.db.WithContext(ctx) if !knowledgeversion.GraphTablesExist(db) { return nil } return db.Transaction(func(tx *gorm.DB) error { var bases []types.KnowledgeBase if err := tx.Where("tenant_id=? AND id IN ?", tenant, []string{source, target}).Find(&bases).Error; err != nil { return err } validTarget := false for _, kb := range bases { if kb.ID == target && kb.IsTemporary && kb.VersionState == "copying" { validTarget = true } } if len(bases) != 2 || !validTarget { return fmt.Errorf("graph version target must be a temporary workspace copy") } for _, model := range []any{&knowledgeversion.GraphRecord{}, &knowledgeversion.GraphSource{}} { var count int64 if err := tx.Model(model).Where("tenant_id=? AND knowledge_base_id=?", tenant, target).Count(&count).Error; err != nil { return err } if count != 0 { return fmt.Errorf("graph version target must be empty") } } for offset := 0; ; offset += 200 { var records []knowledgeversion.GraphRecord if err := tx.Where("tenant_id=? AND knowledge_base_id=?", tenant, source).Order("source_table,legacy_id").Limit(200).Offset(offset).Find(&records).Error; err != nil { return err } if len(records) == 0 { break } for i := range records { records[i].KnowledgeBaseID = target } if err := tx.Create(&records).Error; err != nil { return err } } for offset := 0; ; offset += 200 { var evidence []knowledgeversion.GraphSource if err := tx.Where("tenant_id=? AND knowledge_base_id=?", tenant, source).Order("source_chunk_id").Limit(200).Offset(offset).Find(&evidence).Error; err != nil { return err } if len(evidence) == 0 { break } ids := []string{} for _, e := range evidence { if e.NativeChunkID != nil { ids = append(ids, *e.NativeChunkID) if dst := chunks[*e.NativeChunkID]; dst != "" { ids = append(ids, dst) } } } loaded := []types.Chunk{} if len(ids) > 0 { if err := tx.Where("tenant_id=? AND knowledge_base_id IN ? AND id IN ?", tenant, []string{source, target}, ids).Find(&loaded).Error; err != nil { return err } } byID := map[string]types.Chunk{} for _, c := range loaded { byID[c.ID] = c } for i := range evidence { e := &evidence[i] e.KnowledgeBaseID = target oldID, oldDoc, oldHash := e.NativeChunkID, e.NativeKnowledgeID, e.NativeContentSHA256 e.NativeChunkID, e.NativeKnowledgeID, e.NativeContentSHA256 = nil, nil, nil if oldID == nil { continue } e.MappingState = "stale" src, srcOK := byID[*oldID] dst, dstOK := byID[chunks[*oldID]] if !srcOK || !dstOK || oldDoc == nil || oldHash == nil || src.KnowledgeBaseID != source || dst.KnowledgeBaseID != target || src.KnowledgeID != *oldDoc || documents[*oldDoc] != dst.KnowledgeID { continue } sourceHash := sha256.Sum256([]byte(src.Content)) if hex.EncodeToString(sourceHash[:]) != *oldHash { continue } targetHash := sha256.Sum256([]byte(dst.Content)) hash := hex.EncodeToString(targetHash[:]) e.NativeChunkID, e.NativeKnowledgeID, e.NativeContentSHA256 = &dst.ID, &dst.KnowledgeID, &hash e.MappingState = "exact_content_hash" } if err := tx.Create(&evidence).Error; err != nil { return err } } return nil }) }