1312 lines
39 KiB
Go
1312 lines
39 KiB
Go
package repository
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/Tencent/WeKnora/internal/common"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|||
|
|
"gorm.io/gorm"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var ErrChunkRevisionConflict = errors.New("chunk revision conflict")
|
|||
|
|
|
|||
|
|
// ErrChunkNotFound is returned when a chunk lookup finds no row. A typed
|
|||
|
|
// sentinel (matching the ErrXNotFound convention used by the other repos)
|
|||
|
|
// so callers can errors.Is it safely through wrapping — replacing the
|
|||
|
|
// previous bare errors.New("chunk not found") that forced fragile
|
|||
|
|
// string-equality matching in the service layer.
|
|||
|
|
var ErrChunkNotFound = errors.New("chunk not found")
|
|||
|
|
|
|||
|
|
// chunkRepository implements the ChunkRepository interface
|
|||
|
|
type chunkRepository struct {
|
|||
|
|
db *gorm.DB
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewChunkRepository creates a new chunk repository
|
|||
|
|
func NewChunkRepository(db *gorm.DB) interfaces.ChunkRepository {
|
|||
|
|
return &chunkRepository{db: db}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateChunks creates multiple chunks in batches.
|
|||
|
|
// Uses Omit("SeqID") so GORM won't include the auto-increment column in the
|
|||
|
|
// INSERT, which avoids MySQL generating ON DUPLICATE KEY UPDATE and the
|
|||
|
|
// resulting gap-lock deadlocks under concurrent writes.
|
|||
|
|
// A deadlock retry wrapper is kept as defense-in-depth for any remaining
|
|||
|
|
// edge cases on secondary unique indexes.
|
|||
|
|
func (r *chunkRepository) CreateChunks(ctx context.Context, chunks []*types.Chunk) error {
|
|||
|
|
for _, chunk := range chunks {
|
|||
|
|
chunk.Content = common.CleanInvalidUTF8(chunk.Content)
|
|||
|
|
chunk.ContextHeader = common.CleanInvalidUTF8(chunk.ContextHeader)
|
|||
|
|
if chunk.SourceContent == "" {
|
|||
|
|
chunk.SourceContent = chunk.Content
|
|||
|
|
}
|
|||
|
|
if chunk.IndexStatus == "" {
|
|||
|
|
chunk.IndexStatus = "ready"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
db := r.db.WithContext(ctx)
|
|||
|
|
|
|||
|
|
// SQLite doesn't support autoIncrement on non-PK columns,
|
|||
|
|
// so we must pre-assign SeqIDs manually (safe: single connection).
|
|||
|
|
// PostgreSQL / MySQL use DB sequences — skip to avoid duplicate key
|
|||
|
|
// races under concurrent inserts.
|
|||
|
|
if db.Dialector.Name() == "sqlite" {
|
|||
|
|
if err := types.AssignChunkSeqIDs(db, chunks); err != nil {
|
|||
|
|
return fmt.Errorf("failed to assign chunk seq_ids: %w", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Select("*") ensures zero-value fields (IsEnabled=false, Flags=0) are
|
|||
|
|
// explicitly inserted, bypassing GORM's default value behavior.
|
|||
|
|
// SeqID=0 is skipped by GORM automatically (autoIncrement tag).
|
|||
|
|
return db.Select("*").CreateInBatches(chunks, 100).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetChunkByID retrieves a chunk by its ID and tenant ID
|
|||
|
|
func (r *chunkRepository) GetChunkByID(ctx context.Context, tenantID uint64, id string) (*types.Chunk, error) {
|
|||
|
|
var chunk types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).Where("tenant_id = ? AND id = ?", tenantID, id).First(&chunk).Error; err != nil {
|
|||
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|||
|
|
return nil, ErrChunkNotFound
|
|||
|
|
}
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return &chunk, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetChunkByIDOnly retrieves a chunk by ID without tenant filter (for permission resolution).
|
|||
|
|
func (r *chunkRepository) GetChunkByIDOnly(ctx context.Context, id string) (*types.Chunk, error) {
|
|||
|
|
var chunk types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&chunk).Error; err != nil {
|
|||
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|||
|
|
return nil, ErrChunkNotFound
|
|||
|
|
}
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return &chunk, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetChunkBySeqID retrieves a chunk by its seq_id and tenant ID
|
|||
|
|
func (r *chunkRepository) GetChunkBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.Chunk, error) {
|
|||
|
|
var chunk types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).Where("tenant_id = ? AND seq_id = ?", tenantID, seqID).First(&chunk).Error; err != nil {
|
|||
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|||
|
|
return nil, ErrChunkNotFound
|
|||
|
|
}
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return &chunk, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListChunksByID retrieves multiple chunks by their IDs
|
|||
|
|
func (r *chunkRepository) ListChunksByID(
|
|||
|
|
ctx context.Context, tenantID uint64, ids []string,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND id IN ?", tenantID, ids).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListChunksByIDOnly retrieves multiple chunks by their IDs without tenant filter (for shared KB resolution).
|
|||
|
|
func (r *chunkRepository) ListChunksByIDOnly(ctx context.Context, ids []string) ([]*types.Chunk, error) {
|
|||
|
|
if len(ids) == 0 {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListChunksBySeqID retrieves multiple chunks by their seq_ids
|
|||
|
|
func (r *chunkRepository) ListChunksBySeqID(
|
|||
|
|
ctx context.Context, tenantID uint64, seqIDs []int64,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
if len(seqIDs) == 0 {
|
|||
|
|
return []*types.Chunk{}, nil
|
|||
|
|
}
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND seq_id IN ?", tenantID, seqIDs).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListChunksByKnowledgeID lists all chunks for a knowledge ID
|
|||
|
|
func (r *chunkRepository) ListChunksByKnowledgeID(
|
|||
|
|
ctx context.Context, tenantID uint64, knowledgeID string,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND knowledge_id = ? and chunk_type = ?", tenantID, knowledgeID, "text").
|
|||
|
|
Order("chunk_index ASC").
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListChunksByKnowledgeIDAndTypes lists a knowledge's chunks restricted to the
|
|||
|
|
// given chunk types. ListChunksByKnowledgeID is text-only by design, so callers
|
|||
|
|
// that also need summary / parent_text / image chunks come through here rather
|
|||
|
|
// than widening that query underneath its existing callers.
|
|||
|
|
func (r *chunkRepository) ListChunksByKnowledgeIDAndTypes(
|
|||
|
|
ctx context.Context, tenantID uint64, knowledgeID string, chunkTypes []types.ChunkType,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
if len(chunkTypes) == 0 {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND knowledge_id = ? AND chunk_type IN ?", tenantID, knowledgeID, chunkTypes).
|
|||
|
|
Order("chunk_index ASC").
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListPagedChunksByKnowledgeID lists chunks for a knowledge ID with pagination
|
|||
|
|
func (r *chunkRepository) ListPagedChunksByKnowledgeID(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
knowledgeID string,
|
|||
|
|
page *types.Pagination,
|
|||
|
|
chunkType []types.ChunkType,
|
|||
|
|
tagIDs []string,
|
|||
|
|
keyword string,
|
|||
|
|
searchField string,
|
|||
|
|
sortOrder string,
|
|||
|
|
knowledgeType string,
|
|||
|
|
isEnabled *bool,
|
|||
|
|
) ([]*types.Chunk, int64, error) {
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
var total int64
|
|||
|
|
keyword = strings.TrimSpace(keyword)
|
|||
|
|
|
|||
|
|
baseFilter := func(db *gorm.DB) *gorm.DB {
|
|||
|
|
db = db.Where("tenant_id = ? AND knowledge_id = ? AND chunk_type IN (?) AND status in (?)",
|
|||
|
|
tenantID, knowledgeID, chunkType, []int{int(types.ChunkStatusIndexed), int(types.ChunkStatusDefault)})
|
|||
|
|
if len(tagIDs) > 0 {
|
|||
|
|
db = db.Where("tag_id IN ?", tagIDs)
|
|||
|
|
}
|
|||
|
|
if isEnabled != nil {
|
|||
|
|
db = db.Where("is_enabled = ?", *isEnabled)
|
|||
|
|
}
|
|||
|
|
if keyword != "" {
|
|||
|
|
like := "%" + keyword + "%"
|
|||
|
|
|
|||
|
|
// Document type: search content only
|
|||
|
|
if knowledgeType != types.KnowledgeTypeFAQ {
|
|||
|
|
db = db.Where("content LIKE ?", like)
|
|||
|
|
return db
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// FAQ type: search based on searchField
|
|||
|
|
// 根据数据库类型使用不同的 JSON 查询语法
|
|||
|
|
isPostgres := db.Dialector.Name() == "postgres"
|
|||
|
|
|
|||
|
|
switch searchField {
|
|||
|
|
case "standard_question":
|
|||
|
|
// Search only in standard_question field of metadata
|
|||
|
|
if isPostgres {
|
|||
|
|
db = db.Where("metadata->>'standard_question' ILIKE ?", like)
|
|||
|
|
} else {
|
|||
|
|
// MySQL: metadata->>'$.standard_question' (MySQL 5.7.13+)
|
|||
|
|
// 也可以用 JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.standard_question'))
|
|||
|
|
db = db.Where("metadata->>'$.standard_question' LIKE ?", like)
|
|||
|
|
}
|
|||
|
|
case "similar_questions":
|
|||
|
|
// Search in similar_questions array of metadata
|
|||
|
|
if isPostgres {
|
|||
|
|
db = db.Where("(metadata->'similar_questions')::text ILIKE ?", like)
|
|||
|
|
} else {
|
|||
|
|
db = db.Where("JSON_EXTRACT(metadata, '$.similar_questions') LIKE ?", like)
|
|||
|
|
}
|
|||
|
|
case "answers":
|
|||
|
|
// Search in answers array of metadata
|
|||
|
|
if isPostgres {
|
|||
|
|
db = db.Where("(metadata->'answers')::text ILIKE ?", like)
|
|||
|
|
} else {
|
|||
|
|
db = db.Where("JSON_EXTRACT(metadata, '$.answers') LIKE ?", like)
|
|||
|
|
}
|
|||
|
|
default:
|
|||
|
|
// Search in all fields (content and metadata)
|
|||
|
|
if isPostgres {
|
|||
|
|
db = db.Where("(content ILIKE ? OR metadata::text ILIKE ?)", like, like)
|
|||
|
|
} else {
|
|||
|
|
db = db.Where("(content LIKE ? OR CAST(metadata AS CHAR) LIKE ?)", like, like)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return db
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
query := baseFilter(r.db.WithContext(ctx).Model(&types.Chunk{}))
|
|||
|
|
|
|||
|
|
// First query the total count
|
|||
|
|
if err := query.Count(&total).Error; err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Then query the paginated data
|
|||
|
|
dataQuery := baseFilter(r.db.WithContext(ctx))
|
|||
|
|
|
|||
|
|
// Determine sort order based on knowledge type
|
|||
|
|
var orderClause string
|
|||
|
|
if knowledgeType == types.KnowledgeTypeFAQ {
|
|||
|
|
// FAQ: sort by updated_at
|
|||
|
|
orderClause = "updated_at DESC"
|
|||
|
|
if sortOrder == "asc" {
|
|||
|
|
orderClause = "updated_at ASC"
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Document: sort by chunk_index
|
|||
|
|
orderClause = "chunk_index ASC"
|
|||
|
|
if sortOrder == "desc" {
|
|||
|
|
orderClause = "chunk_index DESC"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := dataQuery.
|
|||
|
|
Order(orderClause).
|
|||
|
|
Offset(page.Offset()).
|
|||
|
|
Limit(page.Limit()).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return chunks, total, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *chunkRepository) ListChunkByParentID(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
parentID string,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND parent_chunk_id = ?", tenantID, parentID).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *chunkRepository) ListChunksByParentIDs(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
parentIDs []string,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
if len(parentIDs) == 0 {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND parent_chunk_id IN ?", tenantID, parentIDs).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateChunk updates a chunk using GORM Save, which updates ALL fields
|
|||
|
|
// except SeqID (auto-increment, must not be overwritten).
|
|||
|
|
// Make sure the chunk object is complete (e.g., fetched from DB) before calling this method.
|
|||
|
|
func (r *chunkRepository) UpdateChunk(ctx context.Context, chunk *types.Chunk) error {
|
|||
|
|
return r.db.WithContext(ctx).Omit("SeqID").Save(chunk).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *chunkRepository) CreateChunkRevision(ctx context.Context, revision *types.ChunkRevision) error {
|
|||
|
|
return r.db.WithContext(ctx).Create(revision).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *chunkRepository) SaveChunkRevision(
|
|||
|
|
ctx context.Context, chunk *types.Chunk, revision *types.ChunkRevision, expectedRevision int,
|
|||
|
|
) error {
|
|||
|
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|||
|
|
result := tx.Model(&types.Chunk{}).
|
|||
|
|
Where("id = ? AND tenant_id = ? AND content_revision = ?", chunk.ID, chunk.TenantID, expectedRevision).
|
|||
|
|
Updates(map[string]interface{}{
|
|||
|
|
"content": common.CleanInvalidUTF8(chunk.Content),
|
|||
|
|
"source_content": common.CleanInvalidUTF8(chunk.SourceContent),
|
|||
|
|
"content_revision": chunk.ContentRevision,
|
|||
|
|
"is_enabled": chunk.IsEnabled,
|
|||
|
|
"metadata": chunk.Metadata,
|
|||
|
|
"index_status": chunk.IndexStatus,
|
|||
|
|
"last_editor_id": chunk.LastEditorID,
|
|||
|
|
"updated_at": chunk.UpdatedAt,
|
|||
|
|
})
|
|||
|
|
if result.Error != nil {
|
|||
|
|
return result.Error
|
|||
|
|
}
|
|||
|
|
if result.RowsAffected != 1 {
|
|||
|
|
return ErrChunkRevisionConflict
|
|||
|
|
}
|
|||
|
|
return tx.Create(revision).Error
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *chunkRepository) ListChunkRevisions(
|
|||
|
|
ctx context.Context, tenantID uint64, chunkID string,
|
|||
|
|
) ([]*types.ChunkRevision, error) {
|
|||
|
|
var revisions []*types.ChunkRevision
|
|||
|
|
err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND chunk_id = ?", tenantID, chunkID).
|
|||
|
|
Order("revision DESC").Find(&revisions).Error
|
|||
|
|
return revisions, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *chunkRepository) GetChunkRevision(
|
|||
|
|
ctx context.Context, tenantID uint64, chunkID string, revision int,
|
|||
|
|
) (*types.ChunkRevision, error) {
|
|||
|
|
var item types.ChunkRevision
|
|||
|
|
err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND chunk_id = ? AND revision = ?", tenantID, chunkID, revision).
|
|||
|
|
First(&item).Error
|
|||
|
|
return &item, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SaveChunks persists full chunk objects in a single transaction using GORM Save (UPDATE).
|
|||
|
|
func (r *chunkRepository) SaveChunks(ctx context.Context, chunks []*types.Chunk) error {
|
|||
|
|
if len(chunks) == 0 {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|||
|
|
for _, chunk := range chunks {
|
|||
|
|
if err := tx.Omit("SeqID").Save(chunk).Error; err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateChunks updates chunks in batch using raw SQL for efficiency.
|
|||
|
|
// Uses raw SQL to bypass GORM's default value handling for boolean fields.
|
|||
|
|
//
|
|||
|
|
// IMPORTANT: This method only updates the following fields:
|
|||
|
|
// - content
|
|||
|
|
// - is_enabled
|
|||
|
|
// - tag_id
|
|||
|
|
// - flags
|
|||
|
|
// - status
|
|||
|
|
// - updated_at
|
|||
|
|
//
|
|||
|
|
// Fields NOT updated by this method (will retain their original values):
|
|||
|
|
// - metadata
|
|||
|
|
// - content_hash
|
|||
|
|
// - embedding-related fields
|
|||
|
|
// - other fields not listed above
|
|||
|
|
//
|
|||
|
|
// If you need to update metadata or content_hash, use UpdateChunk (single) instead.
|
|||
|
|
func (r *chunkRepository) UpdateChunks(ctx context.Context, chunks []*types.Chunk) error {
|
|||
|
|
if len(chunks) == 0 {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build batch update SQL with CASE expressions
|
|||
|
|
var ids []string
|
|||
|
|
contentCases := make([]string, 0, len(chunks))
|
|||
|
|
isEnabledCases := make([]string, 0, len(chunks))
|
|||
|
|
tagIDCases := make([]string, 0, len(chunks))
|
|||
|
|
flagsCases := make([]string, 0, len(chunks))
|
|||
|
|
statusCases := make([]string, 0, len(chunks))
|
|||
|
|
|
|||
|
|
var contentArgs []interface{}
|
|||
|
|
var isEnabledArgs []interface{}
|
|||
|
|
var tagIDArgs []interface{}
|
|||
|
|
var flagsArgs []interface{}
|
|||
|
|
var statusArgs []interface{}
|
|||
|
|
|
|||
|
|
for _, chunk := range chunks {
|
|||
|
|
ids = append(ids, chunk.ID)
|
|||
|
|
content := common.CleanInvalidUTF8(chunk.Content)
|
|||
|
|
|
|||
|
|
contentCases = append(contentCases, "WHEN id = ? THEN ?")
|
|||
|
|
contentArgs = append(contentArgs, chunk.ID, content)
|
|||
|
|
|
|||
|
|
// Convert bool to string for PostgreSQL compatibility
|
|||
|
|
isEnabledStr := "false"
|
|||
|
|
if chunk.IsEnabled {
|
|||
|
|
isEnabledStr = "true"
|
|||
|
|
}
|
|||
|
|
isEnabledCases = append(isEnabledCases, "WHEN id = ? THEN ?")
|
|||
|
|
isEnabledArgs = append(isEnabledArgs, chunk.ID, isEnabledStr)
|
|||
|
|
|
|||
|
|
tagIDCases = append(tagIDCases, "WHEN id = ? THEN ?")
|
|||
|
|
tagIDArgs = append(tagIDArgs, chunk.ID, chunk.TagID)
|
|||
|
|
|
|||
|
|
flagsCases = append(flagsCases, "WHEN id = ? THEN ?")
|
|||
|
|
flagsArgs = append(flagsArgs, chunk.ID, fmt.Sprintf("%d", chunk.Flags))
|
|||
|
|
|
|||
|
|
statusCases = append(statusCases, "WHEN id = ? THEN ?")
|
|||
|
|
statusArgs = append(statusArgs, chunk.ID, fmt.Sprintf("%d", chunk.Status))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build IN clause placeholders
|
|||
|
|
inPlaceholders := make([]string, len(ids))
|
|||
|
|
for i := range ids {
|
|||
|
|
inPlaceholders[i] = "?"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Combine args in correct order: content, is_enabled, tag_id, flags, status, then IN clause
|
|||
|
|
var args []interface{}
|
|||
|
|
args = append(args, contentArgs...)
|
|||
|
|
args = append(args, isEnabledArgs...)
|
|||
|
|
args = append(args, tagIDArgs...)
|
|||
|
|
args = append(args, flagsArgs...)
|
|||
|
|
args = append(args, statusArgs...)
|
|||
|
|
for _, id := range ids {
|
|||
|
|
args = append(args, id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
isPostgres := r.db.Dialector.Name() == "postgres"
|
|||
|
|
|
|||
|
|
var sql string
|
|||
|
|
if isPostgres {
|
|||
|
|
sql = fmt.Sprintf(`
|
|||
|
|
UPDATE chunks SET
|
|||
|
|
content = CASE %s END,
|
|||
|
|
is_enabled = (CASE %s END)::boolean,
|
|||
|
|
tag_id = CASE %s END,
|
|||
|
|
flags = (CASE %s END)::integer,
|
|||
|
|
status = (CASE %s END)::integer,
|
|||
|
|
updated_at = NOW()
|
|||
|
|
WHERE id IN (%s)
|
|||
|
|
`,
|
|||
|
|
strings.Join(contentCases, " "),
|
|||
|
|
strings.Join(isEnabledCases, " "),
|
|||
|
|
strings.Join(tagIDCases, " "),
|
|||
|
|
strings.Join(flagsCases, " "),
|
|||
|
|
strings.Join(statusCases, " "),
|
|||
|
|
strings.Join(inPlaceholders, ","),
|
|||
|
|
)
|
|||
|
|
} else {
|
|||
|
|
sql = fmt.Sprintf(`
|
|||
|
|
UPDATE chunks SET
|
|||
|
|
content = CASE %s END,
|
|||
|
|
is_enabled = CASE %s END,
|
|||
|
|
tag_id = CASE %s END,
|
|||
|
|
flags = CASE %s END,
|
|||
|
|
status = CASE %s END,
|
|||
|
|
updated_at = datetime('now')
|
|||
|
|
WHERE id IN (%s)
|
|||
|
|
`,
|
|||
|
|
strings.Join(contentCases, " "),
|
|||
|
|
strings.Join(isEnabledCases, " "),
|
|||
|
|
strings.Join(tagIDCases, " "),
|
|||
|
|
strings.Join(flagsCases, " "),
|
|||
|
|
strings.Join(statusCases, " "),
|
|||
|
|
strings.Join(inPlaceholders, ","),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return r.db.WithContext(ctx).Exec(sql, args...).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteChunk deletes a chunk by its ID
|
|||
|
|
func (r *chunkRepository) DeleteChunk(ctx context.Context, tenantID uint64, id string) error {
|
|||
|
|
return r.db.WithContext(ctx).Where("tenant_id = ? AND id = ?", tenantID, id).Delete(&types.Chunk{}).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteChunks deletes chunks by IDs in batch.
|
|||
|
|
// To avoid MySQL Error 1390 (too many placeholders), IDs are split into batches.
|
|||
|
|
func (r *chunkRepository) DeleteChunks(ctx context.Context, tenantID uint64, ids []string) error {
|
|||
|
|
if len(ids) == 0 {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
const batchSize = 5000
|
|||
|
|
for i := 0; i < len(ids); i += batchSize {
|
|||
|
|
end := i + batchSize
|
|||
|
|
if end > len(ids) {
|
|||
|
|
end = len(ids)
|
|||
|
|
}
|
|||
|
|
if err := r.db.WithContext(ctx).Where("tenant_id = ? AND id IN ?", tenantID, ids[i:end]).Delete(&types.Chunk{}).Error; err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteChunksByKnowledgeID deletes all chunks for a knowledge ID
|
|||
|
|
func (r *chunkRepository) DeleteChunksByKnowledgeID(ctx context.Context, tenantID uint64, knowledgeID string) error {
|
|||
|
|
return r.db.WithContext(ctx).Where(
|
|||
|
|
"tenant_id = ? AND knowledge_id = ?", tenantID, knowledgeID,
|
|||
|
|
).Delete(&types.Chunk{}).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListImageInfoByKnowledgeIDs returns non-empty image_info values for the given knowledge IDs.
|
|||
|
|
// No chunk_type filter — collects from text, image_ocr, and image_caption chunks.
|
|||
|
|
func (r *chunkRepository) ListImageInfoByKnowledgeIDs(
|
|||
|
|
ctx context.Context, tenantID uint64, knowledgeIDs []string,
|
|||
|
|
) ([]interfaces.ChunkImageInfo, error) {
|
|||
|
|
var results []interfaces.ChunkImageInfo
|
|||
|
|
err := r.db.WithContext(ctx).
|
|||
|
|
Model(&types.Chunk{}).
|
|||
|
|
Select("knowledge_id, image_info").
|
|||
|
|
Where("tenant_id = ? AND knowledge_id IN ? AND image_info != ''", tenantID, knowledgeIDs).
|
|||
|
|
Scan(&results).Error
|
|||
|
|
return results, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteByKnowledgeList deletes all chunks for a knowledge list
|
|||
|
|
func (r *chunkRepository) DeleteByKnowledgeList(ctx context.Context, tenantID uint64, knowledgeIDs []string) error {
|
|||
|
|
return r.db.WithContext(ctx).Where(
|
|||
|
|
"tenant_id = ? AND knowledge_id in ?", tenantID, knowledgeIDs,
|
|||
|
|
).Delete(&types.Chunk{}).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MoveChunksByKnowledgeID updates knowledge_base_id for all chunks of a knowledge item
|
|||
|
|
func (r *chunkRepository) MoveChunksByKnowledgeID(ctx context.Context, tenantID uint64, knowledgeID string, targetKBID string) error {
|
|||
|
|
return r.db.WithContext(ctx).Model(&types.Chunk{}).
|
|||
|
|
Where("tenant_id = ? AND knowledge_id = ?", tenantID, knowledgeID).
|
|||
|
|
Update("knowledge_base_id", targetKBID).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteChunksByTagID deletes all chunks with the specified tag ID
|
|||
|
|
// Returns the IDs of deleted chunks for index cleanup
|
|||
|
|
func (r *chunkRepository) DeleteChunksByTagID(ctx context.Context, tenantID uint64, kbID string, tagID string, excludeIDs []string) ([]string, error) {
|
|||
|
|
// Build exclude set for O(1) lookup
|
|||
|
|
excludeSet := make(map[string]struct{}, len(excludeIDs))
|
|||
|
|
for _, id := range excludeIDs {
|
|||
|
|
excludeSet[id] = struct{}{}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get all chunk IDs for this tag
|
|||
|
|
var allIDs []string
|
|||
|
|
if err := r.db.WithContext(ctx).Model(&types.Chunk{}).
|
|||
|
|
Where("tenant_id = ? AND knowledge_base_id = ? AND tag_id = ?", tenantID, kbID, tagID).
|
|||
|
|
Pluck("id", &allIDs).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Filter out excluded IDs
|
|||
|
|
toDelete := make([]string, 0, len(allIDs))
|
|||
|
|
for _, id := range allIDs {
|
|||
|
|
if _, excluded := excludeSet[id]; !excluded {
|
|||
|
|
toDelete = append(toDelete, id)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(toDelete) == 0 {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Delete in batches
|
|||
|
|
const batchSize = 1000
|
|||
|
|
for i := 0; i < len(toDelete); i += batchSize {
|
|||
|
|
end := i + batchSize
|
|||
|
|
if end > len(toDelete) {
|
|||
|
|
end = len(toDelete)
|
|||
|
|
}
|
|||
|
|
batch := toDelete[i:end]
|
|||
|
|
|
|||
|
|
if err := r.db.WithContext(ctx).Where("id IN ?", batch).Delete(&types.Chunk{}).Error; err != nil {
|
|||
|
|
// Return already planned deletions up to this point for index cleanup
|
|||
|
|
return toDelete[:i], err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return toDelete, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CountChunksByKnowledgeBaseID counts the number of chunks in a knowledge base
|
|||
|
|
func (r *chunkRepository) CountChunksByKnowledgeBaseID(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
kbID string,
|
|||
|
|
) (int64, error) {
|
|||
|
|
var count int64
|
|||
|
|
err := r.db.WithContext(ctx).Model(&types.Chunk{}).
|
|||
|
|
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID).
|
|||
|
|
Count(&count).Error
|
|||
|
|
return count, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteUnindexedChunks by knowledge id and chunk index range
|
|||
|
|
func (r *chunkRepository) DeleteUnindexedChunks(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
knowledgeID string,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND knowledge_id = ? AND status = ?", tenantID, knowledgeID, types.ChunkStatusStored).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if len(chunks) > 0 {
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Where("tenant_id = ? AND knowledge_id = ? AND status = ?", tenantID, knowledgeID, types.ChunkStatusStored).
|
|||
|
|
Delete(&types.Chunk{}).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListAllFAQChunksByKnowledgeID lists all FAQ chunks for a knowledge ID (only essential fields for efficiency)
|
|||
|
|
// Uses batch query to handle large datasets
|
|||
|
|
func (r *chunkRepository) ListAllFAQChunksByKnowledgeID(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
knowledgeID string,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
const batchSize = 1000 // 每批查询1000条
|
|||
|
|
var allChunks []*types.Chunk
|
|||
|
|
offset := 0
|
|||
|
|
|
|||
|
|
for {
|
|||
|
|
var batchChunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Select("id, content_hash").
|
|||
|
|
Where("tenant_id = ? AND knowledge_id = ? AND chunk_type = ?", tenantID, knowledgeID, types.ChunkTypeFAQ).
|
|||
|
|
Offset(offset).
|
|||
|
|
Limit(batchSize).
|
|||
|
|
Find(&batchChunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果没有查询到数据,说明已经查询完毕
|
|||
|
|
if len(batchChunks) == 0 {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
allChunks = append(allChunks, batchChunks...)
|
|||
|
|
|
|||
|
|
// 如果返回的数据少于批次大小,说明已经是最后一批
|
|||
|
|
if len(batchChunks) < batchSize {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
offset += batchSize
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return allChunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListAllFAQChunksWithMetadataByKnowledgeBaseID lists all FAQ chunks for a knowledge base ID
|
|||
|
|
// Returns ID and Metadata fields for duplicate question checking
|
|||
|
|
// Uses batch query to handle large datasets
|
|||
|
|
func (r *chunkRepository) ListAllFAQChunksWithMetadataByKnowledgeBaseID(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
kbID string,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
const batchSize = 1000 // 每批查询1000条
|
|||
|
|
var allChunks []*types.Chunk
|
|||
|
|
offset := 0
|
|||
|
|
|
|||
|
|
for {
|
|||
|
|
var batchChunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Select("id, metadata").
|
|||
|
|
Where("tenant_id = ? AND knowledge_base_id = ? AND chunk_type = ? AND status = ?",
|
|||
|
|
tenantID, kbID, types.ChunkTypeFAQ, types.ChunkStatusIndexed).
|
|||
|
|
Offset(offset).
|
|||
|
|
Limit(batchSize).
|
|||
|
|
Find(&batchChunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果没有查询到数据,说明已经查询完毕
|
|||
|
|
if len(batchChunks) == 0 {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
allChunks = append(allChunks, batchChunks...)
|
|||
|
|
|
|||
|
|
// 如果返回的数据少于批次大小,说明已经是最后一批
|
|||
|
|
if len(batchChunks) < batchSize {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
offset += batchSize
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return allChunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// FindFAQChunkWithDuplicateQuestion finds a single FAQ chunk whose standard_question or
|
|||
|
|
// similar_questions overlap with the given question list.
|
|||
|
|
// Uses dialect-specific JSON queries (MySQL / PostgreSQL / SQLite).
|
|||
|
|
func (r *chunkRepository) FindFAQChunkWithDuplicateQuestion(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
kbID string,
|
|||
|
|
excludeChunkID string,
|
|||
|
|
questions []string,
|
|||
|
|
) (*types.Chunk, error) {
|
|||
|
|
if len(questions) == 0 {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Every non-deleted status counts, including ChunkStatusStored: a chunk that
|
|||
|
|
// is written but not yet indexed is a sibling create still in flight, and
|
|||
|
|
// skipping it lets a retried request insert a second row for the same
|
|||
|
|
// question. Soft-deleted rows are excluded by GORM.
|
|||
|
|
db := r.db.WithContext(ctx).
|
|||
|
|
Select("id, metadata").
|
|||
|
|
Where("tenant_id = ? AND knowledge_base_id = ? AND chunk_type = ? AND status IN (?) AND id != ?",
|
|||
|
|
tenantID, kbID, types.ChunkTypeFAQ,
|
|||
|
|
[]int{
|
|||
|
|
int(types.ChunkStatusDefault),
|
|||
|
|
int(types.ChunkStatusStored),
|
|||
|
|
int(types.ChunkStatusIndexed),
|
|||
|
|
},
|
|||
|
|
excludeChunkID)
|
|||
|
|
|
|||
|
|
switch r.db.Name() {
|
|||
|
|
case "mysql":
|
|||
|
|
// MySQL 5.7+: JSON_EXTRACT for standard_question, JSON_CONTAINS for similar_questions
|
|||
|
|
parts := []string{
|
|||
|
|
"JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.standard_question')) IN ?",
|
|||
|
|
}
|
|||
|
|
args := []interface{}{questions}
|
|||
|
|
for _, q := range questions {
|
|||
|
|
parts = append(parts,
|
|||
|
|
"JSON_CONTAINS(metadata, ?, '$.similar_questions')")
|
|||
|
|
jsonVal, _ := json.Marshal(q)
|
|||
|
|
args = append(args, string(jsonVal))
|
|||
|
|
}
|
|||
|
|
db = db.Where(strings.Join(parts, " OR "), args...)
|
|||
|
|
case "postgres":
|
|||
|
|
db = db.Where(
|
|||
|
|
"(metadata->>'standard_question' IN ? OR EXISTS ("+
|
|||
|
|
"SELECT 1 FROM jsonb_array_elements_text("+
|
|||
|
|
"COALESCE(metadata->'similar_questions', '[]'::jsonb)) elem "+
|
|||
|
|
"WHERE elem.value IN ?))",
|
|||
|
|
questions, questions)
|
|||
|
|
default: // sqlite
|
|||
|
|
db = db.Where(
|
|||
|
|
"(json_extract(metadata, '$.standard_question') IN ? OR EXISTS ("+
|
|||
|
|
"SELECT 1 FROM json_each("+
|
|||
|
|
"CASE WHEN json_extract(metadata, '$.similar_questions') IS NOT NULL "+
|
|||
|
|
"THEN json_extract(metadata, '$.similar_questions') ELSE '[]' END) "+
|
|||
|
|
"WHERE value IN ?))",
|
|||
|
|
questions, questions)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var chunk types.Chunk
|
|||
|
|
if err := db.Limit(1).Find(&chunk).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if chunk.ID == "" {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
return &chunk, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListAllFAQChunksForExport lists all FAQ chunks for export with full metadata, tag_id, is_enabled, and flags.
|
|||
|
|
// Uses batch query to handle large datasets.
|
|||
|
|
func (r *chunkRepository) ListAllFAQChunksForExport(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
knowledgeID string,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
const batchSize = 1000 // 每批查询1000条
|
|||
|
|
var allChunks []*types.Chunk
|
|||
|
|
offset := 0
|
|||
|
|
|
|||
|
|
for {
|
|||
|
|
var batchChunks []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Select("id, metadata, tag_id, is_enabled, flags").
|
|||
|
|
Where("tenant_id = ? AND knowledge_id = ? AND chunk_type = ? AND status = ?",
|
|||
|
|
tenantID, knowledgeID, types.ChunkTypeFAQ, types.ChunkStatusIndexed).
|
|||
|
|
Order("created_at ASC").
|
|||
|
|
Offset(offset).
|
|||
|
|
Limit(batchSize).
|
|||
|
|
Find(&batchChunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果没有查询到数据,说明已经查询完毕
|
|||
|
|
if len(batchChunks) == 0 {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
allChunks = append(allChunks, batchChunks...)
|
|||
|
|
|
|||
|
|
// 如果返回的数据少于批次大小,说明已经是最后一批
|
|||
|
|
if len(batchChunks) < batchSize {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
offset += batchSize
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return allChunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateChunkFlagsBatch updates flags for multiple chunks in batch using SQL CASE expressions.
|
|||
|
|
// This is more efficient than updating chunks one by one.
|
|||
|
|
// setFlags: map of chunk ID to flags to set (OR operation)
|
|||
|
|
// clearFlags: map of chunk ID to flags to clear (AND NOT operation)
|
|||
|
|
func (r *chunkRepository) UpdateChunkFlagsBatch(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
kbID string,
|
|||
|
|
setFlags map[string]types.ChunkFlags,
|
|||
|
|
clearFlags map[string]types.ChunkFlags,
|
|||
|
|
) error {
|
|||
|
|
if len(setFlags) == 0 && len(clearFlags) == 0 {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Collect all IDs
|
|||
|
|
allIDs := make([]string, 0, len(setFlags)+len(clearFlags))
|
|||
|
|
for id := range setFlags {
|
|||
|
|
allIDs = append(allIDs, id)
|
|||
|
|
}
|
|||
|
|
for id := range clearFlags {
|
|||
|
|
if _, exists := setFlags[id]; !exists {
|
|||
|
|
allIDs = append(allIDs, id)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(allIDs) == 0 {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build CASE expression for flags update
|
|||
|
|
// flags = (flags | setFlag) & ~clearFlag
|
|||
|
|
var setCases, clearCases []string
|
|||
|
|
var args []interface{}
|
|||
|
|
|
|||
|
|
// Build SET cases: flags | value
|
|||
|
|
for id, flag := range setFlags {
|
|||
|
|
setCases = append(setCases, "WHEN id = ? THEN ?")
|
|||
|
|
args = append(args, id, int(flag))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build CLEAR cases: flags & ~value
|
|||
|
|
for id, flag := range clearFlags {
|
|||
|
|
clearCases = append(clearCases, "WHEN id = ? THEN ?")
|
|||
|
|
args = append(args, id, int(flag))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setExpr := "0"
|
|||
|
|
clearExpr := "0"
|
|||
|
|
|
|||
|
|
if len(setCases) > 0 {
|
|||
|
|
setExpr = fmt.Sprintf("CASE %s ELSE 0 END", strings.Join(setCases, " "))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(clearCases) > 0 {
|
|||
|
|
clearExpr = fmt.Sprintf("CASE %s ELSE 0 END", strings.Join(clearCases, " "))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build IN clause placeholders manually for raw SQL
|
|||
|
|
inPlaceholders := make([]string, len(allIDs))
|
|||
|
|
for i := range allIDs {
|
|||
|
|
inPlaceholders[i] = "?"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nowFunc := "NOW()"
|
|||
|
|
if r.db.Dialector.Name() == "sqlite" {
|
|||
|
|
nowFunc = "datetime('now')"
|
|||
|
|
}
|
|||
|
|
sql := fmt.Sprintf(`
|
|||
|
|
UPDATE chunks
|
|||
|
|
SET flags = (flags | (%s)) & ~(%s),
|
|||
|
|
updated_at = %s
|
|||
|
|
WHERE tenant_id = ?
|
|||
|
|
AND knowledge_base_id = ?
|
|||
|
|
AND id IN (%s)
|
|||
|
|
`, setExpr, clearExpr, nowFunc, strings.Join(inPlaceholders, ","))
|
|||
|
|
|
|||
|
|
args = append(args, tenantID, kbID)
|
|||
|
|
for _, id := range allIDs {
|
|||
|
|
args = append(args, id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return r.db.WithContext(ctx).Exec(sql, args...).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateChunkFieldsByTagID updates fields for all chunks with the specified tag ID.
|
|||
|
|
// Returns the list of affected chunk IDs for syncing with retriever engines.
|
|||
|
|
// newTagID: if not nil, updates tag_id to this value (empty string means uncategorized)
|
|||
|
|
func (r *chunkRepository) UpdateChunkFieldsByTagID(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
kbID string,
|
|||
|
|
tagID string,
|
|||
|
|
isEnabled *bool,
|
|||
|
|
setFlags types.ChunkFlags,
|
|||
|
|
clearFlags types.ChunkFlags,
|
|||
|
|
newTagID *string,
|
|||
|
|
excludeIDs []string,
|
|||
|
|
) ([]string, error) {
|
|||
|
|
// First, get the IDs of chunks that will be affected (for is_enabled sync)
|
|||
|
|
var affectedIDs []string
|
|||
|
|
if isEnabled != nil {
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
query := r.db.WithContext(ctx).
|
|||
|
|
Select("id").
|
|||
|
|
Where("tenant_id = ? AND knowledge_base_id = ? AND chunk_type = ?",
|
|||
|
|
tenantID, kbID, types.ChunkTypeFAQ)
|
|||
|
|
if tagID != "" {
|
|||
|
|
query = query.Where("tag_id = ?", tagID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(excludeIDs) > 0 {
|
|||
|
|
query = query.Where("id NOT IN ?", excludeIDs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Only get chunks that need to change
|
|||
|
|
query = query.Where("is_enabled != ?", *isEnabled)
|
|||
|
|
if err := query.Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
for _, c := range chunks {
|
|||
|
|
affectedIDs = append(affectedIDs, c.ID)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build update query
|
|||
|
|
updates := map[string]interface{}{
|
|||
|
|
"updated_at": time.Now(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if isEnabled != nil {
|
|||
|
|
updates["is_enabled"] = *isEnabled
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle newTagID update
|
|||
|
|
if newTagID != nil {
|
|||
|
|
updates["tag_id"] = *newTagID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
query := r.db.WithContext(ctx).Model(&types.Chunk{}).
|
|||
|
|
Where("tenant_id = ? AND knowledge_base_id = ? AND chunk_type = ?",
|
|||
|
|
tenantID, kbID, types.ChunkTypeFAQ)
|
|||
|
|
|
|||
|
|
if tagID != "" {
|
|||
|
|
query = query.Where("tag_id = ?", tagID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(excludeIDs) > 0 {
|
|||
|
|
query = query.Where("id NOT IN ?", excludeIDs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle flags update
|
|||
|
|
if setFlags != 0 || clearFlags != 0 {
|
|||
|
|
flagsExpr := "flags"
|
|||
|
|
if setFlags != 0 {
|
|||
|
|
flagsExpr = fmt.Sprintf("(%s | %d)", flagsExpr, int(setFlags))
|
|||
|
|
}
|
|||
|
|
if clearFlags != 0 {
|
|||
|
|
flagsExpr = fmt.Sprintf("(%s & ~%d)", flagsExpr, int(clearFlags))
|
|||
|
|
}
|
|||
|
|
updates["flags"] = r.db.Raw(flagsExpr)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := query.Updates(updates).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return affectedIDs, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type chunkIDHash struct {
|
|||
|
|
ID string `gorm:"column:id"`
|
|||
|
|
ContentHash string `gorm:"column:content_hash"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const faqChunkDiffBatchSize = 5000
|
|||
|
|
|
|||
|
|
// listFAQChunkIDHashesByKB loads id/content_hash pairs for all FAQ chunks in a KB.
|
|||
|
|
func (r *chunkRepository) listFAQChunkIDHashesByKB(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
kbID string,
|
|||
|
|
) ([]chunkIDHash, error) {
|
|||
|
|
var all []chunkIDHash
|
|||
|
|
var lastID string
|
|||
|
|
|
|||
|
|
for {
|
|||
|
|
var batch []chunkIDHash
|
|||
|
|
query := r.db.WithContext(ctx).Model(&types.Chunk{}).
|
|||
|
|
Select("id, content_hash").
|
|||
|
|
Where("tenant_id = ? AND knowledge_base_id = ? AND chunk_type = ?",
|
|||
|
|
tenantID, kbID, types.ChunkTypeFAQ).
|
|||
|
|
Order("id ASC").
|
|||
|
|
Limit(faqChunkDiffBatchSize)
|
|||
|
|
if lastID != "" {
|
|||
|
|
query = query.Where("id > ?", lastID)
|
|||
|
|
}
|
|||
|
|
if err := query.Find(&batch).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if len(batch) == 0 {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
all = append(all, batch...)
|
|||
|
|
lastID = batch[len(batch)-1].ID
|
|||
|
|
if len(batch) < faqChunkDiffBatchSize {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return all, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func diffFAQChunkIDsByContentHash(src, dst []chunkIDHash) (
|
|||
|
|
chunksToAdd, chunksToDelete []string,
|
|||
|
|
matched []types.FAQChunkSyncPair,
|
|||
|
|
) {
|
|||
|
|
dstHashes := make(map[string]struct{}, len(dst))
|
|||
|
|
dstIDByHash := make(map[string]string, len(dst))
|
|||
|
|
dstIDsByHash := make(map[string][]string, len(dst))
|
|||
|
|
for _, pair := range dst {
|
|||
|
|
dstHashes[pair.ContentHash] = struct{}{}
|
|||
|
|
if _, ok := dstIDByHash[pair.ContentHash]; !ok {
|
|||
|
|
dstIDByHash[pair.ContentHash] = pair.ID
|
|||
|
|
}
|
|||
|
|
dstIDsByHash[pair.ContentHash] = append(dstIDsByHash[pair.ContentHash], pair.ID)
|
|||
|
|
}
|
|||
|
|
srcHashes := make(map[string]struct{}, len(src))
|
|||
|
|
for _, pair := range src {
|
|||
|
|
srcHashes[pair.ContentHash] = struct{}{}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, pair := range src {
|
|||
|
|
if _, exists := dstHashes[pair.ContentHash]; !exists {
|
|||
|
|
chunksToAdd = append(chunksToAdd, pair.ID)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if dstID, ok := dstIDByHash[pair.ContentHash]; ok {
|
|||
|
|
matched = append(matched, types.FAQChunkSyncPair{
|
|||
|
|
SrcChunkID: pair.ID,
|
|||
|
|
DstChunkID: dstID,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for _, pair := range dst {
|
|||
|
|
if _, exists := srcHashes[pair.ContentHash]; !exists {
|
|||
|
|
chunksToDelete = append(chunksToDelete, pair.ID)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for hash, ids := range dstIDsByHash {
|
|||
|
|
if hash == "" || len(ids) <= 1 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if _, inSrc := srcHashes[hash]; !inSrc {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
canonical := dstIDByHash[hash]
|
|||
|
|
for _, id := range ids {
|
|||
|
|
if id != canonical {
|
|||
|
|
chunksToDelete = append(chunksToDelete, id)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return chunksToAdd, chunksToDelete, matched
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// FAQChunkDiff compares FAQ chunks between two knowledge bases and returns the differences.
|
|||
|
|
// Returns: chunksToAdd (IDs of chunks in src whose content_hash is not in dst),
|
|||
|
|
//
|
|||
|
|
// chunksToDelete (IDs of chunks in dst whose content_hash is not in src, plus
|
|||
|
|
// duplicate dst chunks that share a content_hash with another dst chunk when
|
|||
|
|
// that hash still exists in src)
|
|||
|
|
func (r *chunkRepository) FAQChunkDiff(
|
|||
|
|
ctx context.Context,
|
|||
|
|
srcTenantID uint64, srcKBID string,
|
|||
|
|
dstTenantID uint64, dstKBID string,
|
|||
|
|
) (*types.FAQChunkDiffResult, error) {
|
|||
|
|
srcPairs, err := r.listFAQChunkIDHashesByKB(ctx, srcTenantID, srcKBID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("failed to list source FAQ chunks: %w", err)
|
|||
|
|
}
|
|||
|
|
dstPairs, err := r.listFAQChunkIDHashesByKB(ctx, dstTenantID, dstKBID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("failed to list destination FAQ chunks: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
add, del, matched := diffFAQChunkIDsByContentHash(srcPairs, dstPairs)
|
|||
|
|
return &types.FAQChunkDiffResult{
|
|||
|
|
ChunksToAdd: add,
|
|||
|
|
ChunksToDelete: del,
|
|||
|
|
MatchedPairs: matched,
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListFAQChunkStatusByIDs loads status fields for FAQ clone sync.
|
|||
|
|
func (r *chunkRepository) ListFAQChunkStatusByIDs(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
ids []string,
|
|||
|
|
) (map[string]*types.FAQChunkStatus, error) {
|
|||
|
|
if len(ids) == 0 {
|
|||
|
|
return map[string]*types.FAQChunkStatus{}, nil
|
|||
|
|
}
|
|||
|
|
const batchSize = 5000
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
for i := 0; i < len(ids); i += batchSize {
|
|||
|
|
end := i + batchSize
|
|||
|
|
if end > len(ids) {
|
|||
|
|
end = len(ids)
|
|||
|
|
}
|
|||
|
|
var batch []*types.Chunk
|
|||
|
|
if err := r.db.WithContext(ctx).
|
|||
|
|
Select("id, tag_id, is_enabled, flags, metadata").
|
|||
|
|
Where("tenant_id = ? AND id IN ?", tenantID, ids[i:end]).
|
|||
|
|
Find(&batch).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
chunks = append(chunks, batch...)
|
|||
|
|
}
|
|||
|
|
out := make(map[string]*types.FAQChunkStatus, len(chunks))
|
|||
|
|
for _, chunk := range chunks {
|
|||
|
|
strategy := types.AnswerStrategyAll
|
|||
|
|
if meta, err := chunk.FAQMetadata(); err == nil && meta != nil {
|
|||
|
|
strategy = meta.AnswerStrategy
|
|||
|
|
}
|
|||
|
|
out[chunk.ID] = &types.FAQChunkStatus{
|
|||
|
|
ID: chunk.ID,
|
|||
|
|
TagID: chunk.TagID,
|
|||
|
|
IsEnabled: chunk.IsEnabled,
|
|||
|
|
Flags: chunk.Flags,
|
|||
|
|
AnswerStrategy: strategy,
|
|||
|
|
Metadata: chunk.Metadata,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListRecommendedFAQChunks lists FAQ chunks with the recommended flag set.
|
|||
|
|
// Filter by explicitly selected kbIDs, knowledgeIDs, and/or FAQ tagIDs (OR relationship).
|
|||
|
|
// Returns up to `limit` chunks sorted by updated_at descending.
|
|||
|
|
func (r *chunkRepository) ListRecommendedFAQChunks(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
kbIDs []string,
|
|||
|
|
knowledgeIDs []string,
|
|||
|
|
tagIDs []string,
|
|||
|
|
limit int,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
if limit <= 0 {
|
|||
|
|
limit = 10
|
|||
|
|
}
|
|||
|
|
if len(kbIDs) == 0 && len(knowledgeIDs) == 0 && len(tagIDs) == 0 {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
query := r.db.WithContext(ctx).
|
|||
|
|
Select("id, knowledge_id, knowledge_base_id, chunk_type, metadata, flags, updated_at").
|
|||
|
|
Where("tenant_id = ? AND chunk_type = ? AND status IN ? AND is_enabled = ? AND flags & ? != 0",
|
|||
|
|
tenantID, types.ChunkTypeFAQ, []int{int(types.ChunkStatusIndexed), int(types.ChunkStatusDefault)}, true, int(types.ChunkFlagRecommended))
|
|||
|
|
var scopeClauses []string
|
|||
|
|
var scopeArgs []interface{}
|
|||
|
|
if len(kbIDs) > 0 {
|
|||
|
|
scopeClauses = append(scopeClauses, "knowledge_base_id IN ?")
|
|||
|
|
scopeArgs = append(scopeArgs, kbIDs)
|
|||
|
|
}
|
|||
|
|
if len(knowledgeIDs) > 0 {
|
|||
|
|
scopeClauses = append(scopeClauses, "knowledge_id IN ?")
|
|||
|
|
scopeArgs = append(scopeArgs, knowledgeIDs)
|
|||
|
|
}
|
|||
|
|
if len(tagIDs) > 0 {
|
|||
|
|
scopeClauses = append(scopeClauses, "tag_id IN ?")
|
|||
|
|
scopeArgs = append(scopeArgs, tagIDs)
|
|||
|
|
}
|
|||
|
|
query = query.Where("("+strings.Join(scopeClauses, " OR ")+")", scopeArgs...)
|
|||
|
|
|
|||
|
|
orderClause := "RANDOM()"
|
|||
|
|
if r.db.Dialector.Name() == "mysql" {
|
|||
|
|
orderClause = "RAND()"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := query.
|
|||
|
|
Order(orderClause).
|
|||
|
|
Limit(limit).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListRecentDocumentChunksWithQuestions lists recent document chunks that have generated questions.
|
|||
|
|
// Filter by kbIDs and/or knowledgeIDs (OR relationship). At least one must be non-empty.
|
|||
|
|
// Returns up to `limit` chunks sorted by updated_at descending.
|
|||
|
|
func (r *chunkRepository) ListRecentDocumentChunksWithQuestions(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
kbIDs []string,
|
|||
|
|
knowledgeIDs []string,
|
|||
|
|
limit int,
|
|||
|
|
) ([]*types.Chunk, error) {
|
|||
|
|
if limit <= 0 {
|
|||
|
|
limit = 10
|
|||
|
|
}
|
|||
|
|
if len(kbIDs) == 0 && len(knowledgeIDs) == 0 {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
var chunks []*types.Chunk
|
|||
|
|
|
|||
|
|
baseQuery := r.db.WithContext(ctx).
|
|||
|
|
Select("id, knowledge_id, knowledge_base_id, chunk_type, metadata, updated_at").
|
|||
|
|
Where("tenant_id = ? AND chunk_type = ? AND status IN ? AND is_enabled = ?",
|
|||
|
|
tenantID, types.ChunkTypeText, []int{int(types.ChunkStatusIndexed), int(types.ChunkStatusDefault)}, true)
|
|||
|
|
|
|||
|
|
if len(kbIDs) > 0 && len(knowledgeIDs) > 0 {
|
|||
|
|
baseQuery = baseQuery.Where("knowledge_base_id IN ? OR knowledge_id IN ?", kbIDs, knowledgeIDs)
|
|||
|
|
} else if len(knowledgeIDs) > 0 {
|
|||
|
|
// 指定了具体知识文档,直接按 knowledge_id 过滤(忽略 kbIDs)
|
|||
|
|
baseQuery = baseQuery.Where("knowledge_id IN ?", knowledgeIDs)
|
|||
|
|
} else if len(kbIDs) > 0 {
|
|||
|
|
baseQuery = baseQuery.Where("knowledge_base_id IN ?", kbIDs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
orderClause := "RANDOM()"
|
|||
|
|
if r.db.Dialector.Name() == "mysql" {
|
|||
|
|
orderClause = "RAND()"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Query chunks that have non-empty generated_questions in metadata
|
|||
|
|
switch r.db.Name() {
|
|||
|
|
case "postgres":
|
|||
|
|
if err := baseQuery.
|
|||
|
|
Where("metadata IS NOT NULL AND metadata::text != '{}' AND jsonb_array_length(COALESCE(metadata->'generated_questions', '[]'::jsonb)) > 0").
|
|||
|
|
Order(orderClause).
|
|||
|
|
Limit(limit).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
case "mysql":
|
|||
|
|
if err := baseQuery.
|
|||
|
|
Where("metadata IS NOT NULL AND JSON_LENGTH(JSON_EXTRACT(metadata, '$.generated_questions')) > 0").
|
|||
|
|
Order(orderClause).
|
|||
|
|
Limit(limit).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
default: // sqlite
|
|||
|
|
if err := baseQuery.
|
|||
|
|
Where("metadata IS NOT NULL AND json_array_length(json_extract(metadata, '$.generated_questions')) > 0").
|
|||
|
|
Order(orderClause).
|
|||
|
|
Limit(limit).
|
|||
|
|
Find(&chunks).Error; err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return chunks, nil
|
|||
|
|
}
|