Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
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
|
||
}
|