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
854 lines
28 KiB
Go
854 lines
28 KiB
Go
// Package service provides business logic implementations for WeKnora application
|
|
// This package contains service layer implementations that coordinate between
|
|
// repositories and handlers, applying business rules and transaction management
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/application/repository"
|
|
"github.com/Tencent/WeKnora/internal/application/service/retriever"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/searchutil"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var ErrChunkRevisionConflict = repository.ErrChunkRevisionConflict
|
|
|
|
// chunkService implements the ChunkService interface
|
|
// It provides operations for managing document chunks in the knowledge base
|
|
// Chunks are segments of documents that have been processed and prepared for indexing
|
|
type chunkService struct {
|
|
chunkRepository interfaces.ChunkRepository // Repository for chunk data persistence
|
|
knowledgeRepo interfaces.KnowledgeRepository
|
|
kbRepository interfaces.KnowledgeBaseRepository
|
|
modelService interfaces.ModelService
|
|
retrieveEngine interfaces.RetrieveEngineRegistry
|
|
ownership retriever.TenantStoreOwnership
|
|
task interfaces.TaskEnqueuer
|
|
spanTracker SpanTracker
|
|
}
|
|
|
|
// NewChunkService creates a new chunk service
|
|
// It initializes a service with the provided chunk repository
|
|
// Parameters:
|
|
// - chunkRepository: Repository for chunk operations
|
|
//
|
|
// Returns:
|
|
// - interfaces.ChunkService: Initialized chunk service implementation
|
|
func NewChunkService(
|
|
chunkRepository interfaces.ChunkRepository,
|
|
knowledgeRepo interfaces.KnowledgeRepository,
|
|
kbRepository interfaces.KnowledgeBaseRepository,
|
|
modelService interfaces.ModelService,
|
|
retrieveEngine interfaces.RetrieveEngineRegistry,
|
|
ownership retriever.TenantStoreOwnership,
|
|
task interfaces.TaskEnqueuer,
|
|
spanTracker SpanTracker,
|
|
) interfaces.ChunkService {
|
|
return &chunkService{
|
|
chunkRepository: chunkRepository,
|
|
knowledgeRepo: knowledgeRepo,
|
|
kbRepository: kbRepository,
|
|
modelService: modelService,
|
|
retrieveEngine: retrieveEngine,
|
|
ownership: ownership,
|
|
task: task,
|
|
spanTracker: spanTracker,
|
|
}
|
|
}
|
|
|
|
const maxEditableChunkLength = 200000
|
|
|
|
// GetRepository gets the chunk repository
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
//
|
|
// Returns:
|
|
// - interfaces.ChunkRepository: Chunk repository
|
|
func (s *chunkService) GetRepository() interfaces.ChunkRepository {
|
|
return s.chunkRepository
|
|
}
|
|
|
|
// CreateChunks creates multiple chunks
|
|
// This method persists a batch of document chunks to the repository
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
// - chunks: Slice of document chunks to create
|
|
//
|
|
// Returns:
|
|
// - error: Any error encountered during chunk creation
|
|
func (s *chunkService) CreateChunks(ctx context.Context, chunks []*types.Chunk) error {
|
|
err := s.chunkRepository.CreateChunks(ctx, chunks)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"chunk_count": len(chunks),
|
|
})
|
|
return err
|
|
}
|
|
|
|
logger.Infof(ctx, "Add %d chunks successfully", len(chunks))
|
|
return nil
|
|
}
|
|
|
|
// GetChunkByID retrieves a chunk by its ID
|
|
// This method fetches a specific chunk using its ID and validates tenant access
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
// - knowledgeID: ID of the knowledge document containing the chunk
|
|
// - id: ID of the chunk to retrieve
|
|
//
|
|
// Returns:
|
|
// - *types.Chunk: Retrieved chunk if found
|
|
// - error: Any error encountered during retrieval
|
|
func (s *chunkService) GetChunkByID(ctx context.Context, id string) (*types.Chunk, error) {
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
logger.Infof(ctx, "Getting chunk by ID, ID: %s, tenant ID: %d", id, tenantID)
|
|
chunk, err := s.chunkRepository.GetChunkByID(ctx, tenantID, id)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"tenant_id": tenantID,
|
|
})
|
|
return nil, err
|
|
}
|
|
|
|
logger.Info(ctx, "Chunk retrieved successfully")
|
|
return chunk, nil
|
|
}
|
|
|
|
// GetChunkByIDOnly retrieves a chunk by ID without tenant filter (for permission resolution).
|
|
func (s *chunkService) GetChunkByIDOnly(ctx context.Context, id string) (*types.Chunk, error) {
|
|
chunk, err := s.chunkRepository.GetChunkByIDOnly(ctx, id)
|
|
if err != nil {
|
|
// errors.Is (not string equality) so the sentinel survives wrapping.
|
|
// ErrChunkNotFound aliases the repo sentinel, so this matches directly.
|
|
if errors.Is(err, ErrChunkNotFound) {
|
|
return nil, ErrChunkNotFound
|
|
}
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{"chunk_id": id})
|
|
return nil, err
|
|
}
|
|
return chunk, nil
|
|
}
|
|
|
|
// ListChunksByKnowledgeID lists all chunks for a knowledge ID
|
|
// This method retrieves all chunks belonging to a specific knowledge document
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
// - knowledgeID: ID of the knowledge document
|
|
//
|
|
// Returns:
|
|
// - []*types.Chunk: List of chunks belonging to the knowledge document
|
|
// - error: Any error encountered during retrieval
|
|
func (s *chunkService) ListChunksByKnowledgeID(ctx context.Context, knowledgeID string) ([]*types.Chunk, error) {
|
|
logger.Info(ctx, "Start listing chunks by knowledge ID")
|
|
logger.Infof(ctx, "Knowledge ID: %s", knowledgeID)
|
|
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
logger.Infof(ctx, "Tenant ID: %d", tenantID)
|
|
|
|
chunks, err := s.chunkRepository.ListChunksByKnowledgeID(ctx, tenantID, knowledgeID)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"knowledge_id": knowledgeID,
|
|
"tenant_id": tenantID,
|
|
})
|
|
return nil, err
|
|
}
|
|
|
|
logger.Infof(ctx, "Retrieved %d chunks successfully", len(chunks))
|
|
return chunks, nil
|
|
}
|
|
|
|
// ListPagedChunksByKnowledgeID lists chunks for a knowledge ID with pagination
|
|
// This method retrieves chunks with pagination support for better performance with large datasets
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
// - knowledgeID: ID of the knowledge document
|
|
// - page: Pagination parameters including page number and page size
|
|
//
|
|
// Returns:
|
|
// - *types.PageResult: Paginated result containing chunks and pagination metadata
|
|
// - error: Any error encountered during retrieval
|
|
func (s *chunkService) ListPagedChunksByKnowledgeID(ctx context.Context,
|
|
knowledgeID string, page *types.Pagination, chunkType []types.ChunkType,
|
|
) (*types.PageResult, error) {
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
chunks, total, err := s.chunkRepository.ListPagedChunksByKnowledgeID(
|
|
ctx,
|
|
tenantID,
|
|
knowledgeID,
|
|
page,
|
|
chunkType,
|
|
nil,
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"knowledge_id": knowledgeID,
|
|
"tenant_id": tenantID,
|
|
})
|
|
return nil, err
|
|
}
|
|
|
|
logger.Infof(ctx, "Retrieved %d chunks out of %d total chunks", len(chunks), total)
|
|
return types.NewPageResult(total, page, chunks), nil
|
|
}
|
|
|
|
// updateChunk updates a chunk
|
|
// This method updates an existing chunk in the repository
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
// - chunk: Chunk with updated fields
|
|
//
|
|
// Returns:
|
|
// - error: Any error encountered during update
|
|
//
|
|
// This method handles the actual update logic for a chunk, including updating the vector database representation
|
|
func (s *chunkService) UpdateChunk(ctx context.Context, chunk *types.Chunk) error {
|
|
logger.Infof(ctx, "Updating chunk, ID: %s, knowledge ID: %s", chunk.ID, chunk.KnowledgeID)
|
|
|
|
// Update the chunk in the repository
|
|
err := s.chunkRepository.UpdateChunk(ctx, chunk)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"chunk_id": chunk.ID,
|
|
"knowledge_id": chunk.KnowledgeID,
|
|
})
|
|
return err
|
|
}
|
|
|
|
logger.Info(ctx, "Chunk updated successfully")
|
|
return nil
|
|
}
|
|
|
|
// UpdateChunks updates chunks in batch
|
|
func (s *chunkService) UpdateChunks(ctx context.Context, chunks []*types.Chunk) error {
|
|
if len(chunks) == 0 {
|
|
return nil
|
|
}
|
|
logger.Infof(ctx, "Updating %d chunks in batch", len(chunks))
|
|
|
|
// Update the chunks in the repository
|
|
err := s.chunkRepository.UpdateChunks(ctx, chunks)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"chunk_count": len(chunks),
|
|
})
|
|
return err
|
|
}
|
|
|
|
logger.Infof(ctx, "Successfully updated %d chunks", len(chunks))
|
|
return nil
|
|
}
|
|
|
|
// DeleteChunk deletes a chunk by ID
|
|
// This method removes a specific chunk from the repository
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
// - id: ID of the chunk to delete
|
|
//
|
|
// Returns:
|
|
// - error: Any error encountered during deletion
|
|
func (s *chunkService) DeleteChunk(ctx context.Context, id string) error {
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
err := s.chunkRepository.DeleteChunk(ctx, tenantID, id)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"tenant_id": tenantID,
|
|
})
|
|
return err
|
|
}
|
|
logger.Info(ctx, "Chunk deleted successfully")
|
|
return nil
|
|
}
|
|
|
|
// DeleteChunks deletes chunks by IDs in batch
|
|
// This method removes multiple chunks from the repository in a single operation
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
// - ids: Slice of chunk IDs to delete
|
|
//
|
|
// Returns:
|
|
// - error: Any error encountered during batch deletion
|
|
func (s *chunkService) DeleteChunks(ctx context.Context, ids []string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
logger.Info(ctx, "Start deleting chunks in batch")
|
|
logger.Infof(ctx, "Deleting %d chunks", len(ids))
|
|
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
logger.Infof(ctx, "Tenant ID: %d", tenantID)
|
|
|
|
err := s.chunkRepository.DeleteChunks(ctx, tenantID, ids)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"chunk_ids": ids,
|
|
"tenant_id": tenantID,
|
|
})
|
|
return err
|
|
}
|
|
|
|
logger.Infof(ctx, "Successfully deleted %d chunks", len(ids))
|
|
return nil
|
|
}
|
|
|
|
// DeleteChunksByKnowledgeID deletes all chunks for a knowledge ID
|
|
// This method removes all chunks belonging to a specific knowledge document
|
|
// Parameters:
|
|
// - ctx: Context with authentication and request information
|
|
// - knowledgeID: ID of the knowledge document
|
|
//
|
|
// Returns:
|
|
// - error: Any error encountered during bulk deletion
|
|
func (s *chunkService) DeleteChunksByKnowledgeID(ctx context.Context, knowledgeID string) error {
|
|
logger.Info(ctx, "Start deleting all chunks by knowledge ID")
|
|
logger.Infof(ctx, "Knowledge ID: %s", knowledgeID)
|
|
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
logger.Infof(ctx, "Tenant ID: %d", tenantID)
|
|
|
|
err := s.chunkRepository.DeleteChunksByKnowledgeID(ctx, tenantID, knowledgeID)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"knowledge_id": knowledgeID,
|
|
"tenant_id": tenantID,
|
|
})
|
|
return err
|
|
}
|
|
|
|
logger.Info(ctx, "All chunks under knowledge deleted successfully")
|
|
return nil
|
|
}
|
|
|
|
func (s *chunkService) DeleteByKnowledgeList(ctx context.Context, ids []string) error {
|
|
logger.Info(ctx, "Start deleting all chunks by knowledge IDs")
|
|
logger.Infof(ctx, "Knowledge IDs: %v", ids)
|
|
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
logger.Infof(ctx, "Tenant ID: %d", tenantID)
|
|
|
|
err := s.chunkRepository.DeleteByKnowledgeList(ctx, tenantID, ids)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"knowledge_id": ids,
|
|
"tenant_id": tenantID,
|
|
})
|
|
return err
|
|
}
|
|
|
|
logger.Info(ctx, "All chunks under knowledge deleted successfully")
|
|
return nil
|
|
}
|
|
|
|
func (s *chunkService) ListChunkByParentID(
|
|
ctx context.Context,
|
|
tenantID uint64,
|
|
parentID string,
|
|
) ([]*types.Chunk, error) {
|
|
logger.Info(ctx, "Start listing chunk by parent ID")
|
|
logger.Infof(ctx, "Parent ID: %s", parentID)
|
|
|
|
chunks, err := s.chunkRepository.ListChunkByParentID(ctx, tenantID, parentID)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"parent_id": parentID,
|
|
"tenant_id": tenantID,
|
|
})
|
|
return nil, err
|
|
}
|
|
|
|
logger.Info(ctx, "Chunk listed successfully")
|
|
return chunks, nil
|
|
}
|
|
|
|
// UpdateDocumentChunk applies an optimistic, versioned edit. Generated
|
|
// retrieval questions remain indexed across content edits; the UI marks them as
|
|
// stale when their revision no longer matches the current body. The current row
|
|
// remains saved when reindexing fails and exposes index_status=failed so the
|
|
// UI never presents a false success state.
|
|
func (s *chunkService) UpdateDocumentChunk(
|
|
ctx context.Context, chunkID string, content *string, isEnabled *bool, expectedRevision *int,
|
|
) (*types.Chunk, error) {
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
chunk, err := s.chunkRepository.GetChunkByID(ctx, tenantID, chunkID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if chunk.ChunkType != types.ChunkTypeText {
|
|
return nil, fmt.Errorf("only text chunks can be edited")
|
|
}
|
|
if expectedRevision != nil && *expectedRevision != chunk.ContentRevision {
|
|
return nil, ErrChunkRevisionConflict
|
|
}
|
|
|
|
newContent := chunk.Content
|
|
if content != nil {
|
|
newContent = strings.TrimSpace(*content)
|
|
if newContent == "" {
|
|
return nil, fmt.Errorf("chunk content cannot be empty")
|
|
}
|
|
if len(newContent) > maxEditableChunkLength {
|
|
return nil, fmt.Errorf("chunk content exceeds %d bytes", maxEditableChunkLength)
|
|
}
|
|
}
|
|
newEnabled := chunk.IsEnabled
|
|
if isEnabled != nil {
|
|
newEnabled = *isEnabled
|
|
}
|
|
if newContent == chunk.Content && newEnabled == chunk.IsEnabled {
|
|
if chunk.IndexStatus == "failed" {
|
|
if chunk.ParentChunkID != "" && chunk.ContentRevision > 0 {
|
|
if err := s.rebuildParentContent(ctx, chunk); err != nil {
|
|
logger.Warnf(ctx, "Failed to rebuild parent chunk while retrying edit: %v", err)
|
|
return chunk, nil
|
|
}
|
|
}
|
|
chunk.IndexStatus = "processing"
|
|
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
|
if err := s.syncChunkIndex(ctx, chunk); err != nil {
|
|
chunk.IndexStatus = "failed"
|
|
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
|
return chunk, nil
|
|
}
|
|
chunk.IndexStatus = "ready"
|
|
if err := s.chunkRepository.UpdateChunk(ctx, chunk); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return chunk, nil
|
|
}
|
|
if content != nil {
|
|
sourceContent := chunk.SourceContent
|
|
if sourceContent == "" {
|
|
sourceContent = chunk.Content
|
|
}
|
|
if err := validateEditedChunkImages(sourceContent, newContent); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
actorID, _ := types.UserIDFromContext(ctx)
|
|
now := time.Now()
|
|
oldRevision := chunk.ContentRevision
|
|
revision := &types.ChunkRevision{
|
|
ID: uuid.NewString(), TenantID: chunk.TenantID,
|
|
KnowledgeBaseID: chunk.KnowledgeBaseID, KnowledgeID: chunk.KnowledgeID,
|
|
ChunkID: chunk.ID, Revision: oldRevision, Content: chunk.Content,
|
|
IsEnabled: chunk.IsEnabled, EditorID: chunk.LastEditorID,
|
|
EditSource: "user", EditedAt: chunk.UpdatedAt, CreatedAt: now,
|
|
}
|
|
if chunk.SourceContent == "" {
|
|
chunk.SourceContent = chunk.Content
|
|
}
|
|
bodyChanged := newContent != chunk.Content
|
|
chunk.Content = newContent
|
|
chunk.IsEnabled = newEnabled
|
|
chunk.ContentRevision++
|
|
chunk.LastEditorID = actorID
|
|
chunk.IndexStatus = "processing"
|
|
chunk.UpdatedAt = now
|
|
if err := s.chunkRepository.SaveChunkRevision(ctx, chunk, revision, oldRevision); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if bodyChanged && chunk.ParentChunkID != "" {
|
|
if err := s.rebuildParentContent(ctx, chunk); err != nil {
|
|
logger.Warnf(ctx, "Failed to rebuild parent chunk after edit: %v", err)
|
|
chunk.IndexStatus = "failed"
|
|
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
|
return chunk, nil
|
|
}
|
|
}
|
|
if bodyChanged || newEnabled != revision.IsEnabled {
|
|
if err := s.syncEditedChunkImages(ctx, chunk); err != nil {
|
|
logger.Warnf(ctx, "Failed to synchronize image children after chunk edit: %v", err)
|
|
chunk.IndexStatus = "failed"
|
|
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
|
return chunk, nil
|
|
}
|
|
}
|
|
if bodyChanged || newEnabled != revision.IsEnabled {
|
|
knowledge, getErr := s.knowledgeRepo.GetKnowledgeByID(ctx, tenantID, chunk.KnowledgeID)
|
|
if getErr == nil {
|
|
if err := enqueueSummaryRefresh(
|
|
ctx, s.knowledgeRepo, s.task, s.kbRepository, s.spanTracker, knowledge,
|
|
); err != nil {
|
|
logger.Warnf(ctx, "Chunk saved but summary refresh enqueue failed for %s: %v", knowledge.ID, err)
|
|
}
|
|
}
|
|
}
|
|
if err := s.syncChunkIndex(ctx, chunk); err != nil {
|
|
chunk.IndexStatus = "failed"
|
|
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
|
logger.Errorf(ctx, "Chunk %s saved but reindex failed: %v", chunk.ID, err)
|
|
return chunk, nil
|
|
}
|
|
chunk.IndexStatus = "ready"
|
|
if err := s.chunkRepository.UpdateChunk(ctx, chunk); err != nil {
|
|
return nil, err
|
|
}
|
|
return chunk, nil
|
|
}
|
|
|
|
func validateEditedChunkImages(sourceContent, editedContent string) error {
|
|
allowed := searchutil.ImageURLsInContent(sourceContent)
|
|
for url := range searchutil.ImageURLsInContent(editedContent) {
|
|
if !allowed[url] {
|
|
return fmt.Errorf("adding images to an existing chunk is not supported: %s", url)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func imageChildMatchesContent(child *types.Chunk, contentURLs map[string]bool) bool {
|
|
for url := range searchutil.ImageURLsFromInfo(child.ImageInfo) {
|
|
if contentURLs[url] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// syncEditedChunkImages removes image OCR/caption children from retrieval
|
|
// when their Markdown image was deleted. Rows are disabled rather than hard
|
|
// deleted so reverting to a historical chunk revision can re-enable them.
|
|
func (s *chunkService) syncEditedChunkImages(ctx context.Context, chunk *types.Chunk) error {
|
|
children, err := s.chunkRepository.ListChunkByParentID(ctx, chunk.TenantID, chunk.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contentURLs := searchutil.ImageURLsInContent(chunk.Content)
|
|
for _, child := range children {
|
|
if child.ChunkType != types.ChunkTypeImageOCR && child.ChunkType != types.ChunkTypeImageCaption {
|
|
continue
|
|
}
|
|
desiredEnabled := chunk.IsEnabled && imageChildMatchesContent(child, contentURLs)
|
|
if child.IsEnabled == desiredEnabled && child.IndexStatus == "ready" {
|
|
continue
|
|
}
|
|
child.IsEnabled = desiredEnabled
|
|
child.IndexStatus = "processing"
|
|
child.UpdatedAt = time.Now()
|
|
if err := s.chunkRepository.UpdateChunk(ctx, child); err != nil {
|
|
return err
|
|
}
|
|
if err := s.syncChunkIndex(ctx, child); err != nil {
|
|
child.IndexStatus = "failed"
|
|
_ = s.chunkRepository.UpdateChunk(ctx, child)
|
|
return err
|
|
}
|
|
child.IndexStatus = "ready"
|
|
if err := s.chunkRepository.UpdateChunk(ctx, child); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *chunkService) ListChunkRevisions(ctx context.Context, chunkID string) ([]*types.ChunkRevision, error) {
|
|
return s.chunkRepository.ListChunkRevisions(ctx, types.MustTenantIDFromContext(ctx), chunkID)
|
|
}
|
|
|
|
func (s *chunkService) RevertDocumentChunk(
|
|
ctx context.Context, chunkID string, revision int, expectedRevision *int,
|
|
) (*types.Chunk, error) {
|
|
item, err := s.chunkRepository.GetChunkRevision(ctx, types.MustTenantIDFromContext(ctx), chunkID, revision)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content := item.Content
|
|
enabled := item.IsEnabled
|
|
return s.UpdateDocumentChunk(ctx, chunkID, &content, &enabled, expectedRevision)
|
|
}
|
|
|
|
// rebuildParentContent overlays manually edited child ranges on the immutable
|
|
// parent source. Applying replacements in reverse offset order preserves the
|
|
// parser coordinate system even when edited text changes length.
|
|
func (s *chunkService) rebuildParentContent(ctx context.Context, edited *types.Chunk) error {
|
|
parent, err := s.chunkRepository.GetChunkByID(ctx, edited.TenantID, edited.ParentChunkID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
children, err := s.chunkRepository.ListChunkByParentID(ctx, edited.TenantID, parent.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
base := parent.SourceContent
|
|
if base == "" {
|
|
base = parent.Content
|
|
parent.SourceContent = base
|
|
}
|
|
baseRunes := []rune(base)
|
|
type replacement struct {
|
|
start, end int
|
|
content string
|
|
updatedAt time.Time
|
|
}
|
|
replacements := make([]replacement, 0)
|
|
for _, child := range children {
|
|
if child.ContentRevision == 0 {
|
|
continue
|
|
}
|
|
start, end := child.StartAt-parent.StartAt, child.EndAt-parent.StartAt
|
|
if start >= 0 && end >= start && end <= len(baseRunes) {
|
|
replacements = append(replacements, replacement{start, end, child.Content, child.UpdatedAt})
|
|
}
|
|
}
|
|
// Overlapping arbitrary replacements cannot both occupy the same source
|
|
// interval. Keep the latest edit in place, then append every conflicting
|
|
// current body that is not already represented. Retrieval must prefer a
|
|
// small amount of duplication over silently losing an accepted edit.
|
|
sort.Slice(replacements, func(i, j int) bool { return replacements[i].updatedAt.After(replacements[j].updatedAt) })
|
|
selected := make([]replacement, 0, len(replacements))
|
|
conflicts := make([]replacement, 0)
|
|
for _, candidate := range replacements {
|
|
overlaps := false
|
|
for _, existing := range selected {
|
|
if candidate.start < existing.end && candidate.end > existing.start {
|
|
overlaps = true
|
|
break
|
|
}
|
|
}
|
|
if !overlaps {
|
|
selected = append(selected, candidate)
|
|
} else {
|
|
conflicts = append(conflicts, candidate)
|
|
}
|
|
}
|
|
replacements = selected
|
|
sort.Slice(replacements, func(i, j int) bool { return replacements[i].start > replacements[j].start })
|
|
for _, repl := range replacements {
|
|
baseRunes = append(append(append([]rune{}, baseRunes[:repl.start]...), []rune(repl.content)...), baseRunes[repl.end:]...)
|
|
}
|
|
parent.Content = string(baseRunes)
|
|
for _, conflict := range conflicts {
|
|
parent.Content = searchutil.JoinChunkContent(parent.Content, conflict.content, "\n\n")
|
|
}
|
|
parent.UpdatedAt = time.Now()
|
|
return s.chunkRepository.UpdateChunk(ctx, parent)
|
|
}
|
|
|
|
func (s *chunkService) syncChunkIndex(ctx context.Context, chunk *types.Chunk) error {
|
|
kb, err := s.kbRepository.GetKnowledgeBaseByID(ctx, chunk.KnowledgeBaseID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !kb.NeedsEmbeddingModel() {
|
|
return nil
|
|
}
|
|
embedder, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
engine, err := retriever.CreateRetrieveEngineForKB(ctx, s.retrieveEngine, s.ownership, chunk.TenantID, kb.VectorStoreID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := engine.DeleteByChunkIDList(ctx, []string{chunk.ID}, embedder.GetDimensions(), kb.Type); err != nil {
|
|
return err
|
|
}
|
|
if !chunk.IsEnabled {
|
|
return nil
|
|
}
|
|
knowledge, err := s.knowledgeRepo.GetKnowledgeByID(ctx, chunk.TenantID, chunk.KnowledgeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items := []*types.IndexInfo{{
|
|
Content: buildKnowledgeIndexContent(knowledge, chunk.EmbeddingContent()), SourceID: chunk.ID,
|
|
SourceType: types.ChunkSourceType, ChunkID: chunk.ID,
|
|
KnowledgeID: chunk.KnowledgeID, KnowledgeBaseID: chunk.KnowledgeBaseID,
|
|
KnowledgeType: kb.Type, IsEnabled: true,
|
|
}}
|
|
meta, err := chunk.DocumentMetadata()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if meta != nil {
|
|
for _, question := range meta.GeneratedQuestions {
|
|
if strings.TrimSpace(question.Question) == "" {
|
|
continue
|
|
}
|
|
items = append(items, &types.IndexInfo{
|
|
Content: buildKnowledgeIndexContent(knowledge, question.Question), SourceID: types.GeneratedQuestionSourceID(chunk.ID, question.ID),
|
|
SourceType: types.ChunkSourceType, ChunkID: chunk.ID,
|
|
KnowledgeID: chunk.KnowledgeID, KnowledgeBaseID: chunk.KnowledgeBaseID,
|
|
KnowledgeType: kb.Type, IsEnabled: true,
|
|
})
|
|
}
|
|
}
|
|
return engine.BatchIndex(ctx, embedder, items)
|
|
}
|
|
|
|
func (s *chunkService) UpsertGeneratedQuestion(
|
|
ctx context.Context, chunkID string, questionID string, question string,
|
|
) (*types.GeneratedQuestion, error) {
|
|
question = strings.TrimSpace(question)
|
|
if question == "" {
|
|
return nil, fmt.Errorf("question cannot be empty")
|
|
}
|
|
chunk, err := s.chunkRepository.GetChunkByID(ctx, types.MustTenantIDFromContext(ctx), chunkID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta, err := chunk.DocumentMetadata()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meta == nil {
|
|
meta = &types.DocumentChunkMetadata{}
|
|
}
|
|
currentRevision := chunk.ContentRevision
|
|
if questionID == "" {
|
|
questionID = uuid.NewString()
|
|
meta.GeneratedQuestions = append(meta.GeneratedQuestions, types.GeneratedQuestion{
|
|
ID: questionID, Question: question, ContentRevision: ¤tRevision,
|
|
})
|
|
} else {
|
|
found := false
|
|
for i := range meta.GeneratedQuestions {
|
|
if meta.GeneratedQuestions[i].ID == questionID {
|
|
meta.GeneratedQuestions[i].Question = question
|
|
meta.GeneratedQuestions[i].ContentRevision = ¤tRevision
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("question not found")
|
|
}
|
|
}
|
|
if err := chunk.SetDocumentMetadata(meta); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.chunkRepository.UpdateChunk(ctx, chunk); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.syncChunkIndex(ctx, chunk); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range meta.GeneratedQuestions {
|
|
if meta.GeneratedQuestions[i].ID == questionID {
|
|
return &meta.GeneratedQuestions[i], nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("question not found")
|
|
}
|
|
|
|
// DeleteGeneratedQuestion deletes a single generated question from a chunk by question ID
|
|
// This updates the chunk metadata and removes the corresponding vector index
|
|
func (s *chunkService) DeleteGeneratedQuestion(ctx context.Context, chunkID string, questionID string) error {
|
|
logger.Infof(ctx, "Deleting generated question, chunk ID: %s, question ID: %s", chunkID, questionID)
|
|
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
|
|
// 1. Get the chunk
|
|
chunk, err := s.chunkRepository.GetChunkByID(ctx, tenantID, chunkID)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"chunk_id": chunkID,
|
|
"tenant_id": tenantID,
|
|
})
|
|
return fmt.Errorf("failed to get chunk: %w", err)
|
|
}
|
|
|
|
// 2. Parse the metadata
|
|
meta, err := chunk.DocumentMetadata()
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"chunk_id": chunkID,
|
|
})
|
|
return fmt.Errorf("failed to parse chunk metadata: %w", err)
|
|
}
|
|
|
|
if meta == nil || len(meta.GeneratedQuestions) == 0 {
|
|
return fmt.Errorf("no generated questions found for chunk %s", chunkID)
|
|
}
|
|
|
|
// 3. Find the question by ID
|
|
questionIndex := -1
|
|
for i, q := range meta.GeneratedQuestions {
|
|
if q.ID == questionID {
|
|
questionIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if questionIndex == -1 {
|
|
return fmt.Errorf("question with ID %s not found in chunk %s", questionID, chunkID)
|
|
}
|
|
|
|
// 4. Get knowledge base to get embedding model
|
|
kb, err := s.kbRepository.GetKnowledgeBaseByID(ctx, chunk.KnowledgeBaseID)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"knowledge_base_id": chunk.KnowledgeBaseID,
|
|
})
|
|
return fmt.Errorf("failed to get knowledge base: %w", err)
|
|
}
|
|
|
|
// 5. Delete the vector index for this question
|
|
// The source_id format is: {chunk_id}-{question_id}
|
|
sourceID := types.GeneratedQuestionSourceID(chunkID, questionID)
|
|
|
|
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
|
|
ctx, s.retrieveEngine, s.ownership, tenantID, kb.VectorStoreID)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"chunk_id": chunkID,
|
|
})
|
|
return fmt.Errorf("failed to create retrieve engine: %w", err)
|
|
}
|
|
|
|
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"embedding_model_id": kb.EmbeddingModelID,
|
|
})
|
|
return fmt.Errorf("failed to get embedding model: %w", err)
|
|
}
|
|
|
|
// Delete the vector index by source ID
|
|
if err := retrieveEngine.DeleteBySourceIDList(ctx, []string{sourceID}, embeddingModel.GetDimensions(), kb.Type); err != nil {
|
|
logger.Warnf(ctx, "Failed to delete vector index for question (may not exist): %v", err)
|
|
// Continue even if vector deletion fails - the question might not have been indexed
|
|
}
|
|
|
|
// 6. Remove the question from metadata
|
|
newQuestions := make([]types.GeneratedQuestion, 0, len(meta.GeneratedQuestions)-1)
|
|
for i, q := range meta.GeneratedQuestions {
|
|
if i != questionIndex {
|
|
newQuestions = append(newQuestions, q)
|
|
}
|
|
}
|
|
|
|
// 7. Update chunk metadata
|
|
meta.GeneratedQuestions = newQuestions
|
|
if err := chunk.SetDocumentMetadata(meta); err != nil {
|
|
return fmt.Errorf("failed to set chunk metadata: %w", err)
|
|
}
|
|
|
|
if err := s.chunkRepository.UpdateChunk(ctx, chunk); err != nil {
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"chunk_id": chunkID,
|
|
})
|
|
return fmt.Errorf("failed to update chunk: %w", err)
|
|
}
|
|
|
|
logger.Infof(ctx, "Successfully deleted generated question %s from chunk %s", questionID, chunkID)
|
|
return nil
|
|
}
|