package service import ( "context" "encoding/json" "errors" "fmt" "io" "sort" "strings" "time" "github.com/Tencent/WeKnora/internal/application/service/retriever" "github.com/Tencent/WeKnora/internal/common" werrors "github.com/Tencent/WeKnora/internal/errors" "github.com/Tencent/WeKnora/internal/infrastructure/chunker" "github.com/Tencent/WeKnora/internal/infrastructure/docparser" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/models/chat" "github.com/Tencent/WeKnora/internal/models/embedding" "github.com/Tencent/WeKnora/internal/searchutil" "github.com/Tencent/WeKnora/internal/tracing/langfuse" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" secutils "github.com/Tencent/WeKnora/internal/utils" "github.com/google/uuid" "github.com/hibiken/asynq" ) func (s *knowledgeService) cloneKnowledge( ctx context.Context, src *types.Knowledge, targetKB *types.KnowledgeBase, ) (err error) { if src.ParseStatus != "completed" { logger.GetLogger(ctx).WithField("knowledge_id", src.ID).Errorf("MoveKnowledge parse status is not completed") return nil } tenantInfo := ctx.Value(types.TenantInfoContextKey).(*types.Tenant) dst := &types.Knowledge{ ID: uuid.New().String(), TenantID: targetKB.TenantID, KnowledgeBaseID: targetKB.ID, Type: src.Type, Channel: src.Channel, Title: src.Title, Description: src.Description, Source: src.Source, ParseStatus: "processing", EnableStatus: "disabled", EmbeddingModelID: targetKB.EmbeddingModelID, FileName: src.FileName, FolderPath: src.FolderPath, FileType: src.FileType, FileSize: src.FileSize, FileHash: src.FileHash, FilePath: src.FilePath, StorageSize: src.StorageSize, Metadata: src.Metadata, } // Deep-copy the source document file into an object owned by the destination // knowledge. Without this the clone only shares the source's storage path, so // deleting the source knowledge would destroy the clone's file too. The new // object is tracked for cleanup if the clone fails downstream. var copiedFilePaths []string if src.FilePath != "" { srcKB, kbErr := s.kbService.GetKnowledgeBaseByID(ctx, src.KnowledgeBaseID) if kbErr != nil { return fmt.Errorf("clone knowledge: failed to load source knowledge base: %w", kbErr) } srcSvc := s.resolveFileServiceForPath(ctx, srcKB, src.FilePath) dstSvc := s.resolveFileService(ctx, targetKB) newPath, copyErr := copyOwnedObject(ctx, srcSvc, dstSvc, src.FilePath, targetKB.TenantID, dst.ID) if copyErr != nil { return fmt.Errorf("clone knowledge file copy failed: %w", copyErr) } dst.FilePath = newPath copiedFilePaths = append(copiedFilePaths, newPath) } defer func() { if err != nil { if len(copiedFilePaths) > 0 { cleanupCopiedObjects(ctx, s.resolveFileService(ctx, targetKB), copiedFilePaths) } dst.ParseStatus = "failed" dst.ErrorMessage = err.Error() _ = s.repo.UpdateKnowledge(ctx, dst) logger.GetLogger(ctx).WithField("error", err).Errorf("MoveKnowledge failed to move knowledge") } else { dst.ParseStatus = "completed" dst.EnableStatus = "enabled" _ = s.repo.UpdateKnowledge(ctx, dst) logger.GetLogger(ctx).WithField("knowledge_id", dst.ID).Infof("MoveKnowledge move knowledge successfully") } }() if err = s.repo.CreateKnowledge(ctx, dst); err != nil { logger.GetLogger(ctx).WithField("error", err).Errorf("MoveKnowledge create knowledge failed") return } tenantInfo.StorageUsed += dst.StorageSize if err = s.tenantRepo.AdjustStorageUsed(ctx, tenantInfo.ID, dst.StorageSize); err != nil { logger.GetLogger(ctx).WithField("error", err).Errorf("MoveKnowledge update tenant storage used failed") return } if err = s.CloneChunk(ctx, src, dst); err != nil { logger.GetLogger(ctx).WithField("knowledge_id", dst.ID). WithField("error", err).Errorf("MoveKnowledge move chunks failed") return } return } // processDocumentFromPassage handles asynchronous processing of text passages func (s *knowledgeService) processDocumentFromPassage(ctx context.Context, kb *types.KnowledgeBase, knowledge *types.Knowledge, passage []string, ) { // Update status to processing knowledge.ParseStatus = "processing" knowledge.UpdatedAt = time.Now() if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { return } // Convert passages to chunks chunks := make([]types.ParsedChunk, 0, len(passage)) start, end := 0, 0 for i, p := range passage { if p == "" { continue } end += len([]rune(p)) chunks = append(chunks, types.ParsedChunk{ Content: p, Seq: i, Start: start, End: end, }) start = end } // Process and store chunks var opts ProcessChunksOptions if kb.QuestionGenerationConfig != nil && kb.QuestionGenerationConfig.Enabled { opts.EnableQuestionGeneration = true opts.QuestionCount = kb.QuestionGenerationConfig.QuestionCount if opts.QuestionCount <= 0 { opts.QuestionCount = 3 } } s.processChunks(ctx, kb, knowledge, chunks, opts) } // ProcessChunksOptions contains options for processing chunks type ProcessChunksOptions struct { EnableQuestionGeneration bool QuestionCount int EnableMultimodel bool StoredImages []docparser.StoredImage // ParentChunks holds parent chunk data when parent-child chunking is enabled. // When set, the chunks passed to processChunks are child chunks, and each // child's ParentIndex references an entry in this slice. ParentChunks []types.ParsedParentChunk Metadata map[string]string } // markKnowledgeProcessingFailed is the terminal transition used when a // processing stage cannot even start (for example, embedding model // initialization fails). Keeping this transition in one helper prevents a // worker from returning while parse_status remains "processing" forever. func markKnowledgeProcessingFailed(knowledge *types.Knowledge, cause error, now time.Time) { if knowledge == nil { return } knowledge.ParseStatus = types.ParseStatusFailed if cause != nil { knowledge.ErrorMessage = cause.Error() } else { knowledge.ErrorMessage = "processing failed" } knowledge.UpdatedAt = now } // finalizeIndexedKnowledgeState makes a document retrievable as soon as chunks // and indexes are persisted (enable_status=enabled), but it deliberately does // NOT mark the row completed when enrichment is still expected. Whenever the // document still has work to fan out — pending multimodal image tasks, or text // chunks that feed summary/question/graph generation — parse_status stays // "processing" so KnowledgePostProcess remains the single authority that drives // processing → finalizing → completed. Marking the row completed here would make // post-process hit its "non-processing status" guard and skip the summary // fan-out, stranding summary_status on "pending" forever. func finalizeIndexedKnowledgeState( knowledge *types.Knowledge, totalStorageSize int64, textChunkCount int, hasPendingMultimodal bool, now time.Time, ) { if hasPendingMultimodal || textChunkCount > 0 { knowledge.ParseStatus = types.ParseStatusProcessing knowledge.SummaryStatus = types.SummaryStatusNone } else { // No text chunks and no pending multimodal work: there is nothing for // post-process to enrich, so complete immediately. This is the only // route to 'completed' that bypasses FinalizeSubtask, so it has to // clear error_message itself — otherwise a successfully indexed row // keeps reporting a failure from an earlier attempt. knowledge.ParseStatus = types.ParseStatusCompleted knowledge.SummaryStatus = types.SummaryStatusNone knowledge.ErrorMessage = "" } knowledge.EnableStatus = "enabled" knowledge.StorageSize = totalStorageSize knowledge.ProcessedAt = &now knowledge.UpdatedAt = now } // markKnowledgeProcessing makes the top-level knowledge state describe the // attempt that is starting now. Clearing error_message is part of that: the // column is only meaningful for the attempt that produced it, so a row // re-entering processing must not keep surfacing the previous attempt's // failure while it is visibly running again. func markKnowledgeProcessing(knowledge *types.Knowledge, now time.Time) { knowledge.ParseStatus = types.ParseStatusProcessing knowledge.ErrorMessage = "" knowledge.UpdatedAt = now } // buildSplitterConfig creates a SplitterConfig with fallbacks from a KnowledgeBase. // Defaults mirror chunker.DefaultChunkSize / DefaultChunkOverlap so behavior is // identical whether callers come through this path or invoke the chunker // directly with a zero-value config. func buildSplitterConfig(kb *types.KnowledgeBase) chunker.SplitterConfig { return buildSplitterConfigFromChunking(kb.ChunkingConfig) } func buildSplitterConfigFromChunking(cc types.ChunkingConfig) chunker.SplitterConfig { return chunker.NormalizeSplitterConfig(chunker.SplitterConfig{ ChunkSize: cc.ChunkSize, ChunkOverlap: cc.ChunkOverlap, Separators: cc.Separators, Strategy: cc.Strategy, TokenLimit: cc.TokenLimit, Languages: cc.Languages, }) } // buildParentChildConfigs derives parent and child SplitterConfig from ChunkingConfig. // The base config (already validated with defaults) is used for separators and // the splitting strategy. Strategy must be propagated: an empty Strategy resolves // to the legacy tier (see resolveChainWithProfile), which never runs the heading // splitter, so parent-child chunks would silently lose heading alignment and // ContextHeader breadcrumbs regardless of the configured strategy. func buildParentChildConfigs(cc types.ChunkingConfig, base chunker.SplitterConfig) (parent, child chunker.SplitterConfig) { return chunker.DeriveParentChildConfigs(base, cc.ParentChunkSize, cc.ChildChunkSize) } // processChunks processes chunks and creates embeddings for knowledge content func (s *knowledgeService) processChunks(ctx context.Context, kb *types.KnowledgeBase, knowledge *types.Knowledge, chunks []types.ParsedChunk, opts ...ProcessChunksOptions, ) { // Get options var options ProcessChunksOptions if len(opts) > 0 { options = opts[0] } // Parser output and manually supplied passages can contain malformed byte // sequences. Clean them before logging, chunk persistence, or embedding; // the embedding provider and tracing/database drivers expect valid UTF-8. for i := range chunks { chunks[i].Content = common.CleanInvalidUTF8(chunks[i].Content) chunks[i].ContextHeader = common.CleanInvalidUTF8(chunks[i].ContextHeader) for j := range chunks[i].Images { chunks[i].Images[j].URL = common.CleanInvalidUTF8(chunks[i].Images[j].URL) chunks[i].Images[j].Caption = common.CleanInvalidUTF8(chunks[i].Images[j].Caption) chunks[i].Images[j].OCRText = common.CleanInvalidUTF8(chunks[i].Images[j].OCRText) chunks[i].Images[j].OriginalURL = common.CleanInvalidUTF8(chunks[i].Images[j].OriginalURL) } } for i := range options.ParentChunks { options.ParentChunks[i].Content = common.CleanInvalidUTF8(options.ParentChunks[i].Content) } // Check if knowledge is being deleted/cancelled before processing. // Both statuses short-circuit identically here — there's nothing to clean // up yet so the branch is purely "stop early". if aborted, status := s.isKnowledgeAborted(ctx, knowledge.TenantID, knowledge.ID); aborted { logger.Infof(ctx, "Knowledge aborted (%s), skipping chunk processing: %s", status, knowledge.ID) return } // Get embedding model for vectorization — only needed when vector/keyword indexing is enabled var embeddingModel embedding.Embedder if kb.NeedsEmbeddingModel() { // Open the embedding stage before initializing the provider. Model // construction is part of the indexing pipeline and can fail (for // example, when a provider was removed or its credentials expired). // Keeping the stage open here ensures the persisted trace records the // failure instead of leaving an indefinitely-running embedding span. s.beginStage(ctx, knowledge.ID, types.StageEmbedding, types.JSONMap{ "model_id": kb.EmbeddingModelID, }) var err error embeddingModel, err = s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID) if err != nil { logger.GetLogger(ctx).WithField("error", err).Errorf("processChunks get embedding model failed") markKnowledgeProcessingFailed(knowledge, err, time.Now()) if updateErr := s.repo.UpdateKnowledge(ctx, knowledge); updateErr != nil { logger.Warnf(ctx, "failed to persist embedding model failure for knowledge %s: %v", knowledge.ID, updateErr) } s.failStage(ctx, knowledge.ID, types.StageEmbedding, werrors.ErrCodeEmbeddingProviderFail, "get embedding model failed", err) return } } else { logger.Infof(ctx, "Vector/keyword indexing disabled for KB %s, skipping embedding model", kb.ID) } // 幂等性处理:清理旧的chunks和索引数据,避免重复数据 logger.Infof(ctx, "Cleaning up existing chunks and index data for knowledge: %s", knowledge.ID) // 删除旧的chunks if err := s.chunkService.DeleteChunksByKnowledgeID(ctx, knowledge.ID); err != nil { logger.Warnf(ctx, "Failed to delete existing chunks (may not exist): %v", err) // 不返回错误,继续处理(可能没有旧数据) } // 删除旧的索引数据 — only when vector/keyword indexing is enabled tenantInfo := ctx.Value(types.TenantInfoContextKey).(*types.Tenant) retrieveEngine, err := retriever.CreateRetrieveEngineForKB( ctx, s.retrieveEngine, s.ownership, tenantInfo.ID, kb.VectorStoreID) if err == nil && embeddingModel != nil { if err := retrieveEngine.DeleteByKnowledgeIDList(ctx, []string{knowledge.ID}, embeddingModel.GetDimensions(), knowledge.Type); err != nil { logger.Warnf(ctx, "Failed to delete existing index data (may not exist): %v", err) // 不返回错误,继续处理(可能没有旧数据) } else { logger.Infof(ctx, "Successfully deleted existing index data for knowledge: %s", knowledge.ID) } } // 删除知识图谱数据(如果存在) namespace := types.NameSpace{KnowledgeBase: knowledge.KnowledgeBaseID, Knowledge: knowledge.ID} if err := s.graphEngine.DelGraph(ctx, []types.NameSpace{namespace}); err != nil { logger.Warnf(ctx, "Failed to delete existing graph data (may not exist): %v", err) // 不返回错误,继续处理 } logger.Infof(ctx, "Cleanup completed, starting to process new chunks") // ========== DocReader 解析结果日志 ========== logger.Infof(ctx, "[DocReader] ========== 解析结果概览 ==========") logger.Infof(ctx, "[DocReader] 知识ID: %s, 知识库ID: %s", knowledge.ID, knowledge.KnowledgeBaseID) logger.Infof(ctx, "[DocReader] 总Chunk数量: %d", len(chunks)) // 统计图片信息 totalImages := 0 chunksWithImages := 0 for _, chunkData := range chunks { if len(chunkData.Images) > 0 { chunksWithImages++ totalImages += len(chunkData.Images) } } logger.Infof(ctx, "[DocReader] 包含图片的Chunk数: %d, 总图片数: %d", chunksWithImages, totalImages) // 打印每个Chunk的详细信息 for idx, chunkData := range chunks { contentPreview := chunkData.Content if len(contentPreview) > 200 { contentPreview = contentPreview[:200] + "..." } logger.Infof(ctx, "[DocReader] Chunk #%d (seq=%d): 内容长度=%d, 图片数=%d, 范围=[%d-%d]", idx, chunkData.Seq, len(chunkData.Content), len(chunkData.Images), chunkData.Start, chunkData.End) logger.Debugf(ctx, "[DocReader] Chunk #%d 内容预览: %s", idx, contentPreview) // 打印图片详细信息 for imgIdx, img := range chunkData.Images { logger.Infof(ctx, "[DocReader] 图片 #%d: URL=%s", imgIdx, img.URL) logger.Infof(ctx, "[DocReader] 图片 #%d: OriginalURL=%s", imgIdx, img.OriginalURL) if img.Caption != "" { captionPreview := img.Caption if len(captionPreview) > 100 { captionPreview = captionPreview[:100] + "..." } logger.Infof(ctx, "[DocReader] 图片 #%d: Caption=%s", imgIdx, captionPreview) } if img.OCRText != "" { ocrPreview := img.OCRText if len(ocrPreview) > 100 { ocrPreview = ocrPreview[:100] + "..." } logger.Infof(ctx, "[DocReader] 图片 #%d: OCRText=%s", imgIdx, ocrPreview) } logger.Infof(ctx, "[DocReader] 图片 #%d: 位置=[%d-%d]", imgIdx, img.Start, img.End) } } logger.Infof(ctx, "[DocReader] ========== 解析结果概览结束 ==========") // Create chunk objects from proto chunks maxSeq := 0 // 统计图片相关的子Chunk数量,用于扩展insertChunks的容量 imageChunkCount := 0 for _, chunkData := range chunks { if len(chunkData.Images) > 0 { // 为每个图片的OCR和Caption分别创建一个Chunk imageChunkCount += len(chunkData.Images) * 2 } if int(chunkData.Seq) > maxSeq { maxSeq = int(chunkData.Seq) } } // === Parent-Child Chunking: create parent chunks first === hasParentChild := len(options.ParentChunks) > 0 var parentDBChunks []*types.Chunk // indexed by ParsedParentChunk position if hasParentChild { parentDBChunks = make([]*types.Chunk, len(options.ParentChunks)) for i, pc := range options.ParentChunks { parentDBChunks[i] = &types.Chunk{ ID: uuid.New().String(), TenantID: knowledge.TenantID, KnowledgeID: knowledge.ID, KnowledgeBaseID: knowledge.KnowledgeBaseID, Content: pc.Content, ChunkIndex: pc.Seq, IsEnabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), StartAt: pc.Start, EndAt: pc.End, ChunkType: types.ChunkTypeParentText, } } // Set prev/next links for parent chunks for i := range parentDBChunks { if i > 0 { parentDBChunks[i-1].NextChunkID = parentDBChunks[i].ID parentDBChunks[i].PreChunkID = parentDBChunks[i-1].ID } } logger.Infof(ctx, "Created %d parent chunks for parent-child strategy", len(parentDBChunks)) } // 重新分配容量,考虑图片相关的Chunk + parent chunks parentCount := len(options.ParentChunks) insertChunks := make([]*types.Chunk, 0, len(chunks)+imageChunkCount+parentCount) // Add parent chunks first (they go into DB but NOT into the vector index) if hasParentChild { insertChunks = append(insertChunks, parentDBChunks...) } for idx, chunkData := range chunks { if strings.TrimSpace(chunkData.Content) == "" { continue } // 创建主文本Chunk textChunk := &types.Chunk{ ID: uuid.New().String(), TenantID: knowledge.TenantID, KnowledgeID: knowledge.ID, KnowledgeBaseID: knowledge.KnowledgeBaseID, Content: chunkData.Content, ContextHeader: chunkData.ContextHeader, ChunkIndex: int(chunkData.Seq), IsEnabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), StartAt: int(chunkData.Start), EndAt: int(chunkData.End), ChunkType: types.ChunkTypeText, } // Wire up ParentChunkID for child chunks if hasParentChild && chunkData.ParentIndex >= 0 && chunkData.ParentIndex < len(parentDBChunks) { textChunk.ParentChunkID = parentDBChunks[chunkData.ParentIndex].ID } chunks[idx].ChunkID = textChunk.ID insertChunks = append(insertChunks, textChunk) } // Sort chunks by index for proper ordering sort.Slice(insertChunks, func(i, j int) bool { return insertChunks[i].ChunkIndex < insertChunks[j].ChunkIndex }) // Collect retrievable text chunks only. ParentChunkID only controls parent expansion after retrieval. // When ParentChunkID is empty, retrieval keeps the standalone child content without loading a parent. textChunks := make([]*types.Chunk, 0, len(chunks)) for _, chunk := range insertChunks { if chunk.ChunkType == types.ChunkTypeText { textChunks = append(textChunks, chunk) } } // 设置文本Chunk之间的前后关系 (skip if parent-child, children don't need prev/next links) if !hasParentChild { for i, chunk := range textChunks { if i > 0 { textChunks[i-1].NextChunkID = chunk.ID } if i < len(textChunks)-1 { textChunks[i+1].PreChunkID = chunk.ID } } } // Check if knowledge is being deleted/cancelled before writing chunks. // Nothing has been persisted yet, so both branches just bail. if aborted, status := s.isKnowledgeAborted(ctx, knowledge.TenantID, knowledge.ID); aborted { logger.Infof(ctx, "Knowledge aborted (%s), skipping chunk write: %s", status, knowledge.ID) return } // Save chunks to database — ALWAYS, regardless of indexing strategy. // Chunks are needed for wiki generation, graph extraction, and summary generation // even when vector/keyword indexing is disabled. s.beginStage(ctx, knowledge.ID, types.StageChunking, types.JSONMap{ "chunks_planned": len(insertChunks), }) if err := s.chunkService.CreateChunks(ctx, insertChunks); err != nil { knowledge.ParseStatus = types.ParseStatusFailed knowledge.ErrorMessage = err.Error() knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) s.failStage(ctx, knowledge.ID, types.StageChunking, werrors.ErrCodeChunkingFailed, "create chunks failed", err) return } totalChunkChars := 0 for _, c := range insertChunks { totalChunkChars += len(c.Content) } s.endStage(ctx, knowledge.ID, types.StageChunking, types.JSONMap{ "chunks_written": len(insertChunks), "total_text_chars": totalChunkChars, }) // Create index information and perform vector indexing — only when vector/keyword is enabled. // Chunks are ALWAYS saved to DB (above) because wiki and graph need them even without vector indexing. var totalStorageSize int64 if kb.NeedsEmbeddingModel() && embeddingModel != nil { embedInput := types.JSONMap{ "chunks_to_embed": len(textChunks), "model_id": kb.EmbeddingModelID, } if dim := embeddingModel.GetDimensions(); dim > 0 { embedInput["dim"] = dim } s.beginStage(ctx, knowledge.ID, types.StageEmbedding, embedInput) // Create index information — only for child/flat chunks, NOT parent chunks. // Parent chunks are stored for context retrieval but do not need vector embeddings. // Prepend the document title to improve semantic alignment between // question-style queries and statement-style chunk content. indexInfoList := make([]*types.IndexInfo, 0, len(textChunks)) for _, chunk := range textChunks { // chunk.EmbeddingContent prepends ContextHeader (heading breadcrumb) // when the chunker populated it during Tier-1 splitting; falls back // to plain Content otherwise. The document title sits outermost; // custom metadata remains document-scoped model context. indexContent := buildKnowledgeIndexContent(knowledge, chunk.EmbeddingContent()) indexInfoList = append(indexInfoList, &types.IndexInfo{ Content: indexContent, SourceID: chunk.ID, SourceType: types.ChunkSourceType, ChunkID: chunk.ID, KnowledgeID: knowledge.ID, KnowledgeBaseID: knowledge.KnowledgeBaseID, IsEnabled: true, }) } // Calculate storage size required for embeddings totalStorageSize = retrieveEngine.EstimateStorageSize(ctx, embeddingModel, indexInfoList) if tenantInfo.StorageQuota > 0 { // Re-fetch tenant storage information tenantInfo, err = s.tenantRepo.GetTenantByID(ctx, tenantInfo.ID) if err != nil { knowledge.ParseStatus = types.ParseStatusFailed knowledge.ErrorMessage = err.Error() knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return } // Check if there's enough storage quota available if tenantInfo.StorageUsed+totalStorageSize > tenantInfo.StorageQuota { knowledge.ParseStatus = types.ParseStatusFailed knowledge.ErrorMessage = "存储空间不足" knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return } } // Check again before batch indexing (heavy operation). // deleting → row is going away anyway, drop the chunks we just wrote. // cancelled → user wants to keep what was already persisted, just stop. if aborted, status := s.isKnowledgeAborted(ctx, knowledge.TenantID, knowledge.ID); aborted { logger.Infof(ctx, "Knowledge aborted (%s) before indexing: %s", status, knowledge.ID) if status == types.ParseStatusDeleting { if err := s.chunkService.DeleteChunksByKnowledgeID(ctx, knowledge.ID); err != nil { logger.Warnf(ctx, "Failed to cleanup chunks after deletion detected: %v", err) } } return } err = retrieveEngine.BatchIndex(ctx, embeddingModel, indexInfoList) if err != nil { knowledge.ParseStatus = types.ParseStatusFailed knowledge.ErrorMessage = err.Error() knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) // delete failed chunks if err := s.chunkService.DeleteChunksByKnowledgeID(ctx, knowledge.ID); err != nil { logger.Errorf(ctx, "Delete chunks failed: %v", err) } // delete index if err := retrieveEngine.DeleteByKnowledgeIDList( ctx, []string{knowledge.ID}, embeddingModel.GetDimensions(), kb.Type, ); err != nil { logger.Errorf(ctx, "Delete index failed: %v", err) } // Map vector store / embedding rate-limit errors to a // stable code so the UI can offer "retry later" hints. code := werrors.ErrCodeVectorStoreWriteFailed if isLikelyRateLimitError(err) { code = werrors.ErrCodeEmbeddingRateLimit } s.failStage(ctx, knowledge.ID, types.StageEmbedding, code, "batch index failed", err) return } logger.GetLogger(ctx).Infof("processChunks batch index successfully, with %d index", len(indexInfoList)) s.endStage(ctx, knowledge.ID, types.StageEmbedding, types.JSONMap{ "vectors_written": len(indexInfoList), "storage_bytes": totalStorageSize, }) // Final check before marking as completed. // deleting → drop chunks+index we just wrote. // cancelled → keep persisted data; the row stays in cancelled status // and downstream stages skip via the entry guards. if aborted, status := s.isKnowledgeAborted(ctx, knowledge.TenantID, knowledge.ID); aborted { logger.Infof(ctx, "Knowledge aborted (%s) after indexing: %s", status, knowledge.ID) if status == types.ParseStatusDeleting { if err := s.chunkService.DeleteChunksByKnowledgeID(ctx, knowledge.ID); err != nil { logger.Warnf(ctx, "Failed to cleanup chunks after deletion detected: %v", err) } if err := retrieveEngine.DeleteByKnowledgeIDList(ctx, []string{knowledge.ID}, embeddingModel.GetDimensions(), kb.Type); err != nil { logger.Warnf(ctx, "Failed to cleanup index after deletion detected: %v", err) } } return } } else { logger.Infof(ctx, "Vector/keyword indexing disabled for KB %s, skipping BatchIndex", kb.ID) s.skipStage(ctx, knowledge.ID, types.StageEmbedding, "skipped") } // Check if this document has extracted images that will be processed asynchronously isImage := IsImageType(knowledge.FileType) isVideo := IsVideoType(knowledge.FileType) pendingMultimodal := isImage && options.EnableMultimodel && len(options.StoredImages) > 0 pendingPDFMultimodal := !isImage && !isVideo && options.EnableMultimodel && len(options.StoredImages) > 0 now := time.Now() finalizeIndexedKnowledgeState( knowledge, totalStorageSize, len(textChunks), pendingMultimodal || pendingPDFMultimodal, now, ) if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { logger.GetLogger(ctx).WithField("error", err).Errorf("processChunks update knowledge failed") } // Enqueue multimodal tasks for images (async, non-blocking) if options.EnableMultimodel && len(options.StoredImages) > 0 { s.beginStage(ctx, knowledge.ID, types.StageMultimodal, types.JSONMap{ "image_count": len(options.StoredImages), "enable_ocr": true, "enable_caption": true, }) s.enqueueImageMultimodalTasks(ctx, knowledge, kb, options.StoredImages, chunks, options.Metadata) } else { s.skipStage(ctx, knowledge.ID, types.StageMultimodal, "skipped") // If there are no multimodal tasks, enqueue the post process task immediately lang := types.LanguageFromContextOrDefault(ctx) postProcessPayload := types.KnowledgePostProcessPayload{ TenantID: knowledge.TenantID, KnowledgeID: knowledge.ID, KnowledgeBaseID: knowledge.KnowledgeBaseID, Language: lang, Attempt: attemptFromCtx(ctx), } langfuse.InjectTracing(ctx, &postProcessPayload) payloadBytes, err := json.Marshal(postProcessPayload) if err == nil { task := asynq.NewTask(types.TypeKnowledgePostProcess, payloadBytes, knowledgePostProcessTaskOptions()...) if _, err := s.task.Enqueue(task); err != nil { logger.Errorf(ctx, "Failed to enqueue knowledge post process task: %v", err) } else { logger.Infof(ctx, "Enqueued knowledge post process task for %s", knowledge.ID) } } else { logger.Errorf(ctx, "Failed to marshal knowledge post process payload: %v", err) } } // Update tenant's storage usage tenantInfo.StorageUsed += totalStorageSize if err := s.tenantRepo.AdjustStorageUsed(ctx, tenantInfo.ID, totalStorageSize); err != nil { logger.GetLogger(ctx).WithField("error", err).Errorf("processChunks update tenant storage used failed") } logger.GetLogger(ctx).Infof("processChunks successfully") } // defaultMaxInputChars is the default maximum characters used as input for summary generation. const defaultMaxInputChars = 1024 * 24 // imageDominatedTextThreshold is the rune count below which a document is // considered "image-dominated" — i.e. the body text is so sparse that we // should fall back to full image enrichment (caption + OCR) for the summary // LLM call. Above this threshold the document has enough native text that // caption-only enrichment is preferable (OCR text from incidental figures // would otherwise add noise without contributing to the main topic). const imageDominatedTextThreshold = 200 // errInsufficientSummaryContent signals that getSummary refused to call the // LLM because the document had no usable text after image markup was stripped // (typical for scanned PDFs where VLM OCR yielded nothing). Callers should // mark the knowledge's summary as failed instead of falling back to the first // chunk's raw content (which would just be a bare image reference). var ( errInsufficientSummaryContent = errors.New("insufficient text content for summary generation") errEmptySummaryOutput = errors.New("summary model returned empty output") ) const summaryFallbackMaxRunes = 500 // validateSummaryOutput rejects successful model responses that contain no // user-visible text. Treating whitespace-only output as an error lets Asynq // retry the summary task instead of persisting description="" as completed. func validateSummaryOutput(response *types.ChatResponse) (string, error) { if response == nil { return "", errEmptySummaryOutput } content := strings.TrimSpace(response.Content) if content == "" { return "", errEmptySummaryOutput } return content, nil } // firstTextChunkSummaryFallback preserves the existing deterministic fallback: // use the first already-ordered text chunk and cap it by runes so Chinese and // emoji are never cut in the middle of a UTF-8 sequence. func firstTextChunkSummaryFallback(textChunks []*types.Chunk) string { if len(textChunks) == 0 || textChunks[0] == nil { return "" } fallback := strings.TrimSpace(textChunks[0].Content) runes := []rune(fallback) if len(runes) > summaryFallbackMaxRunes { fallback = string(runes[:summaryFallbackMaxRunes]) } return fallback } // applyRetryableSummaryFailureState keeps an existing description visible // while another attempt is queued, then publishes the deterministic fallback // and marks only the summary subtask failed after the retry budget is exhausted. func applyRetryableSummaryFailureState( knowledge *types.Knowledge, textChunks []*types.Chunk, willRetry bool, ) string { knowledge.UpdatedAt = time.Now() if willRetry { knowledge.SummaryStatus = types.SummaryStatusPending return "" } fallback := firstTextChunkSummaryFallback(textChunks) knowledge.Description = fallback knowledge.SummaryStatus = types.SummaryStatusFailed return fallback } // summaryTaskWillRetry reports whether the current Asynq delivery has another // configured attempt remaining. Calls outside an Asynq worker are terminal. func summaryTaskWillRetry(ctx context.Context) bool { retried, retryOK := asynq.GetRetryCount(ctx) maxRetry, maxRetryOK := asynq.GetMaxRetry(ctx) if retryOK && maxRetryOK { return retried < maxRetry } retried, maxRetry, ok := types.TaskRetryMetadataFromContext(ctx) return ok && retried < maxRetry } // checkSufficientSummaryContent returns errInsufficientSummaryContent if the // given content does not carry enough real text (after stripping image markup) // for an LLM summary call, and logs a warning at the call site. Returns nil // when the content passes the threshold. // // Extracted so the threshold gate can be unit-tested without standing up the // full ProcessSummaryGeneration dependency graph. func checkSufficientSummaryContent(ctx context.Context, knowledgeID, content string) error { realTextLen := realTextRuneCount(content) if realTextLen < minTextContentRunes { logger.GetLogger(ctx).Warnf( "summary content check: knowledge %s has insufficient text after stripping image markup (real_text_runes=%d, min=%d); skipping LLM call", knowledgeID, realTextLen, minTextContentRunes, ) return errInsufficientSummaryContent } return nil } // sortChunksForSummary orders chunks for document reconstruction. Parser // StartAt offsets stay authoritative until any chunk has been manually edited; // after that ChunkIndex is the safer reading order. func sortChunksForSummary(chunks []*types.Chunk) []*types.Chunk { sorted := make([]*types.Chunk, len(chunks)) copy(sorted, chunks) edited := false for _, chunk := range sorted { if chunk.ContentRevision > 0 { edited = true break } } sort.Slice(sorted, func(i, j int) bool { if edited { if sorted[i].ChunkIndex != sorted[j].ChunkIndex { return sorted[i].ChunkIndex < sorted[j].ChunkIndex } return sorted[i].ID < sorted[j].ID } return sorted[i].StartAt < sorted[j].StartAt }) return sorted } // getSummary generates a summary for knowledge content using an AI model func (s *knowledgeService) getSummary(ctx context.Context, summaryModel chat.Chat, knowledge *types.Knowledge, chunks []*types.Chunk, ) (string, error) { // Get knowledge info from the first chunk if len(chunks) == 0 { return "", fmt.Errorf("no chunks provided for summary generation") } // Determine max input chars from config maxInputChars := defaultMaxInputChars if s.config.Conversation.Summary != nil && s.config.Conversation.Summary.MaxInputChars > 0 { maxInputChars = s.config.Conversation.Summary.MaxInputChars } // Sort chunks for reconstruction. Parser offsets remain authoritative until // any chunk has been manually edited; after that ChunkIndex is safer. sortedChunks := sortChunksForSummary(chunks) // Reconstruct the document before enriching it with image info. Enrichment // must happen AFTER reconstruction because StartAt is based on original // document offsets; enriched (longer) content would break the positioning. chunkContents := "" hasEditedChunk := false for _, chunk := range sortedChunks { if chunk.ContentRevision > 0 { hasEditedChunk = true break } } if hasEditedChunk { // Parser offsets describe the immutable source. Once a replacement has // changed length they can no longer be applied to the effective content; // concatenate current chunks instead of truncating at stale offsets. parts := make([]string, 0, len(sortedChunks)) for _, chunk := range sortedChunks { if chunk.IsEnabled && strings.TrimSpace(chunk.Content) != "" { parts = append(parts, chunk.Content) } } chunkContents = strings.Join(parts, "\n\n") } else { // Synthetic table headers are not represented in StartAt/EndAt. Match // real text overlap instead of slicing by source offsets. chunkContents = searchutil.MergeTextChunks(sortedChunks, "") } // Collect image_info from image_ocr/image_caption children and enrich chunkIDs := make([]string, len(sortedChunks)) for i, c := range sortedChunks { chunkIDs[i] = c.ID } imageInfoMap := searchutil.CollectImageInfoByChunkIDs(ctx, s.chunkRepo, knowledge.TenantID, chunkIDs) mergedImageInfo := searchutil.MergeImageInfoJSON(imageInfoMap) if mergedImageInfo != "" { // For image-dominated documents (e.g. a docx whose only payload is a // single embedded picture, or a screenshot-only file), captions alone // often carry too little signal — the real content lives in OCR text. // Detect that case by measuring the document's real (non-image-markup) // text BEFORE enrichment, and switch to full enrichment (caption + OCR) // when the body is essentially empty. Text-heavy documents stay on the // caption-only path to avoid OCR noise (page headers/footers/watermarks // from many figures diluting the main topic). if realTextRuneCount(chunkContents) < imageDominatedTextThreshold { // Caption + OCR (no URL/original wrappers — those are pure noise // for the summary LLM and have been observed to trigger the // "image reference with no extracted text" refusal heuristic). chunkContents = searchutil.EnrichContentCaptionAndOCR(chunkContents, mergedImageInfo) } else { chunkContents = searchutil.EnrichContentCaptionOnly(chunkContents, mergedImageInfo) } } // Apply length limit: sample long content to fit within maxInputChars chunkContents = sampleLongContent(chunkContents, maxInputChars) logger.GetLogger(ctx).Infof("getSummary: content length=%d chars (max=%d) for knowledge %s", len([]rune(chunkContents)), maxInputChars, knowledge.ID) // Bail out before the LLM call when there is not enough actual text to // summarise. We deliberately do not pass filename/file-type metadata to the // LLM: scanned PDFs frequently carry filenames like "MX5280.pdf" (the // scanner model), and feeding that to the model would invite it to // hallucinate a scanner manual instead of admitting the document had no // extractable text. if err := checkSufficientSummaryContent(ctx, knowledge.ID, chunkContents); err != nil { return "", err } // User-authored metadata is trusted document context. Internal ingestion // metadata remains excluded because it contains IDs and pipeline controls. contentWithMetadata := chunkContents if custom := knowledge.CustomMetadataText(); custom != "" { contentWithMetadata = "Document metadata:\n" + custom + "\n\nDocument content:\n" + chunkContents } contentWithMetadata = sampleLongContent(contentWithMetadata, maxInputChars) // Determine max output tokens from config maxTokens := 2048 if s.config.Conversation.Summary != nil && s.config.Conversation.Summary.MaxCompletionTokens > 0 { maxTokens = s.config.Conversation.Summary.MaxCompletionTokens } // Generate summary using AI model summaryPrompt := types.RenderPromptPlaceholders(s.config.Conversation.GenerateSummaryPrompt, types.PlaceholderValues{ "language": types.LanguageNameFromContext(ctx), }) thinking := false modelCtx := types.WithLLMCallMetadata(ctx, "document_summary", "") summary, err := summaryModel.Chat(modelCtx, []chat.Message{ { Role: "system", Content: summaryPrompt, }, { Role: "user", Content: contentWithMetadata, }, }, &chat.ChatOptions{ Temperature: 0.3, MaxTokens: maxTokens, Thinking: &thinking, }) if err != nil { logger.GetLogger(ctx).WithField("error", err).Errorf("GetSummary failed") return "", err } content, err := validateSummaryOutput(summary) if err != nil { logger.GetLogger(ctx).WithField("error", err).Warnf("GetSummary returned no usable content") return "", err } logger.GetLogger(ctx).WithField("summary", content).Infof("GetSummary success") return content, nil } // sampleLongContent returns content that fits within maxChars. // For short content (≤ maxChars), it is returned as-is. // For long content, it samples: head (60%), tail (20%), and evenly-spaced middle (20%), // joined by "[...content omitted...]" markers so the LLM knows content was skipped. func sampleLongContent(content string, maxChars int) string { runes := []rune(content) if len(runes) <= maxChars { return content } const omitMarker = "\n\n[...content omitted...]\n\n" omitRunes := len([]rune(omitMarker)) // Reserve space for two omit markers (head→middle, middle→tail) usable := maxChars - 2*omitRunes if usable < 100 { // Fallback: just truncate return string(runes[:maxChars]) } headLen := usable * 60 / 100 tailLen := usable * 20 / 100 midLen := usable - headLen - tailLen head := string(runes[:headLen]) tail := string(runes[len(runes)-tailLen:]) // Sample middle portion: take a contiguous block from the center of the document midStart := len(runes)/2 - midLen/2 if midStart < headLen { midStart = headLen } midEnd := midStart + midLen if midEnd > len(runes)-tailLen { midEnd = len(runes) - tailLen midStart = midEnd - midLen if midStart < headLen { midStart = headLen } } middle := string(runes[midStart:midEnd]) return head + omitMarker + middle + omitMarker + tail } // ProcessSummaryGeneration handles async summary generation task func (s *knowledgeService) ProcessSummaryGeneration(ctx context.Context, t *asynq.Task) (retErr error) { var payload types.SummaryGenerationPayload if err := json.Unmarshal(t.Payload(), &payload); err != nil { logger.Errorf(ctx, "Failed to unmarshal summary generation payload: %v", err) return nil // Don't retry on unmarshal error } logger.Infof(ctx, "Processing summary generation for knowledge: %s", payload.KnowledgeID) // Set tenant and language context ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) if payload.Language != "" { ctx = context.WithValue(ctx, types.LanguageContextKey, payload.Language) } // A newer attempt (re-upload / edit / reparse) has superseded this one: // skip before opening the span or registering the FinalizeSubtask defer // so we neither read stale chunks nor decrement the new attempt's counter. if attemptSuperseded(ctx, s.tracker(), payload.KnowledgeID, payload.Attempt) { logger.Infof(ctx, "summary: attempt %d superseded for %s, skipping stale enrichment", payload.Attempt, payload.KnowledgeID) return nil } if payload.Refresh { var err error ctx, err = restoreSummaryRefreshTenantInfo(ctx, s.tenantRepo, payload.TenantID) if err == nil { _, err = s.RegenerateKnowledgeSummary(ctx, payload.KnowledgeID) } if err != nil { if errors.Is(err, ErrSummaryRefreshStale) { logger.Infof(ctx, "Discarding stale summary refresh for knowledge %s", payload.KnowledgeID) return nil } logger.Warnf(ctx, "Summary refresh failed for knowledge %s: %v", payload.KnowledgeID, err) if errors.Is(err, errInsufficientSummaryContent) { return nil } return err } return nil } // Open a subspan under the parent attempt's postprocess stage so the // trace surface shows the real summary-generation duration (LLM call // + chunk write + index) instead of just the upstream's enqueue time. // Closes via the deferred handler below — every return path lands in // the defer, including the early returns ahead. span := s.beginPostprocessSubspan(ctx, payload.KnowledgeID, payload.Attempt, "postprocess.summary", types.JSONMap{ "language": payload.Language, }) var summaryErr error summaryOut := types.JSONMap{} defer func() { // Decrement the parent's enrichment counter on terminal exit. // "Terminal" is keyed on the value RETURNED to asynq, not on // summaryErr: several branches record a failure on the span // (summaryErr != nil) yet deliberately `return nil` so asynq does // NOT retry (e.g. insufficient text content, KB/knowledge fetch // failures). Those are terminal and must drain — keying on // summaryErr would skip them and leave the row stuck in // "finalizing". When we DO return an error asynq will retry, so // we only drain on the final attempt. finalizeSubtaskDetached(ctx, s.repo, payload.KnowledgeID, "summary", retErr, false, isFinalAsynqAttempt(ctx)) if span == nil { return } if summaryErr != nil { s.failPostprocessSubspan(ctx, span, "SUMMARY_FAILED", summaryErr.Error(), summaryErr) } else { s.endPostprocessSubspan(ctx, span, summaryOut) } }() // Get knowledge base kb, err := s.kbService.GetKnowledgeBaseByID(ctx, payload.KnowledgeBaseID) if err != nil { logger.Errorf(ctx, "Failed to get knowledge base: %v", err) summaryErr = err return nil } // Capture the resolved model id on the span output the moment we // know it — debugging "summary stage took 60s" benefits hugely from // seeing WHICH chat model was actually used (kb config drift, fall- // throughs to a slow upstream, etc.). summaryOut["model_id"] = kb.SummaryModelID if kb.SummaryModelID == "" { logger.Warn(ctx, "Knowledge base summary model ID is empty, skipping summary generation") _ = s.repo.UpdateKnowledgeColumn(ctx, payload.KnowledgeID, "summary_status", types.SummaryStatusFailed) summaryOut["skipped"] = "no_summary_model" return nil } // Get knowledge knowledge, err := s.repo.GetKnowledgeByID(ctx, payload.TenantID, payload.KnowledgeID) if err != nil { logger.Errorf(ctx, "Failed to get knowledge: %v", err) summaryErr = err return nil } // Short-circuit when the user cancelled parsing or the row is being deleted. if knowledge != nil { switch knowledge.ParseStatus { case types.ParseStatusCancelled, types.ParseStatusDeleting: logger.Infof(ctx, "Summary generation: knowledge aborted (%s), skipping: %s", knowledge.ParseStatus, payload.KnowledgeID) summaryOut["skipped"] = "knowledge_" + knowledge.ParseStatus return nil } } // Update summary status to processing knowledge.SummaryStatus = types.SummaryStatusProcessing knowledge.UpdatedAt = time.Now() if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { logger.Warnf(ctx, "Failed to update summary status to processing: %v", err) } // Helper function to mark summary as failed markSummaryFailed := func() { knowledge.SummaryStatus = types.SummaryStatusFailed knowledge.UpdatedAt = time.Now() if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { logger.Warnf(ctx, "Failed to update summary status to failed: %v", err) } } // Get text chunks for this knowledge chunks, err := s.chunkService.ListChunksByKnowledgeID(ctx, payload.KnowledgeID) if err != nil { logger.Errorf(ctx, "Failed to get chunks: %v", err) markSummaryFailed() summaryErr = err return nil } // Filter text chunks only textChunks := make([]*types.Chunk, 0) for _, chunk := range chunks { if chunk.ChunkType == types.ChunkTypeText { textChunks = append(textChunks, chunk) } } summaryOut["text_chunks"] = len(textChunks) if len(textChunks) == 0 { logger.Infof(ctx, "No text chunks found for knowledge: %s", payload.KnowledgeID) knowledge.Description = "" knowledge.SummaryStatus = types.SummaryStatusFailed knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) summaryOut["skipped"] = "no_text_chunks" return nil } // Sort chunks by ChunkIndex for proper ordering sort.Slice(textChunks, func(i, j int) bool { return textChunks[i].ChunkIndex < textChunks[j].ChunkIndex }) summaryMetadataVersion := string(knowledge.CustomMetadata) handleRetryableSummaryFailure := func(generationErr error) error { summaryErr = generationErr summaryOut["error"] = previewText(generationErr.Error(), 500) summaryOut["error_type"] = fmt.Sprintf("%T", generationErr) if summaryTaskWillRetry(ctx) { applyRetryableSummaryFailureState(knowledge, textChunks, true) if updateErr := s.repo.UpdateKnowledge(ctx, knowledge); updateErr != nil { logger.Warnf(ctx, "Failed to mark summary pending for retry: %v", updateErr) } summaryOut["retrying"] = true return fmt.Errorf("summary generation attempt failed: %w", generationErr) } // Before publishing the terminal fallback, make sure its source still // matches the chunks and metadata captured for this attempt. stale, staleErr := summarySourceChanged( ctx, s.repo, s.chunkRepo, payload.TenantID, payload.KnowledgeID, summaryMetadataVersion, textChunks, ) if staleErr != nil { logger.Errorf(ctx, "Failed to verify summary fallback freshness for knowledge %s: %v", payload.KnowledgeID, staleErr) markSummaryFailed() summaryErr = staleErr return fmt.Errorf("verify summary fallback freshness: %w", staleErr) } if stale { logger.Infof(ctx, "Discarding stale summary fallback for knowledge %s", payload.KnowledgeID) summaryOut["skipped"] = "content_revision_changed" return nil } fallback := applyRetryableSummaryFailureState(knowledge, textChunks, false) if updateErr := s.repo.UpdateKnowledge(ctx, knowledge); updateErr != nil { logger.Errorf(ctx, "Failed to save terminal summary fallback: %v", updateErr) summaryErr = updateErr return fmt.Errorf("save terminal summary fallback: %w", updateErr) } if fallback == "" { summaryOut["fallback"] = "empty" } else { summaryOut["fallback"] = "first_chunk" } summaryOut["fallback_chars"] = len([]rune(fallback)) return fmt.Errorf("summary generation exhausted retries: %w", generationErr) } // Initialize chat model for summary. Model resolution failures use the same // retry budget and terminal first-chunk fallback as LLM request failures. chatModel, err := s.modelService.GetChatModel(ctx, kb.SummaryModelID) if err != nil { logger.Errorf(ctx, "Failed to get chat model: %v", err) return handleRetryableSummaryFailure(fmt.Errorf("get chat model: %w", err)) } // Generate summary summary, err := s.getSummary(ctx, chatModel, knowledge, textChunks) if err != nil { logger.Errorf(ctx, "Failed to generate summary for knowledge %s: %v", payload.KnowledgeID, err) // Surface the underlying LLM/IO error on the span so the trace UI // can explain "why did this stage take 60s and then fall back?" // without forcing the operator to grep worker logs. We also capture // the error type to disambiguate timeouts from upstream HTTP errors // (deadline exceeded vs unexpected EOF vs 5xx, etc.). summaryOut["error"] = previewText(err.Error(), 500) summaryOut["error_type"] = fmt.Sprintf("%T", err) // For the insufficient-content case (scanned PDF without OCR, etc.) // we deliberately do NOT fall back to the first chunk's raw content, // since that chunk is typically just a bare markdown image reference // and surfacing it in the description is misleading. if errors.Is(err, errInsufficientSummaryContent) { knowledge.Description = "" knowledge.SummaryStatus = types.SummaryStatusFailed knowledge.UpdatedAt = time.Now() if updateErr := s.repo.UpdateKnowledge(ctx, knowledge); updateErr != nil { logger.Errorf(ctx, "Failed to mark summary as failed: %v", updateErr) summaryErr = updateErr return fmt.Errorf("failed to update knowledge: %w", updateErr) } summaryOut["fallback"] = "insufficient_content" summaryErr = err return nil } return handleRetryableSummaryFailure(err) } // Do not publish an answer derived from a superseded chunk or metadata // version. A user can explicitly refresh again from the latest revision. staleSummary, staleErr := summarySourceChanged( ctx, s.repo, s.chunkRepo, payload.TenantID, payload.KnowledgeID, summaryMetadataVersion, textChunks, ) if staleErr != nil { logger.Errorf(ctx, "Failed to verify summary freshness for knowledge %s: %v", payload.KnowledgeID, staleErr) markSummaryFailed() summaryErr = staleErr return fmt.Errorf("verify summary freshness: %w", staleErr) } if staleSummary { logger.Infof(ctx, "Discarding stale summary for knowledge %s", payload.KnowledgeID) summaryOut["skipped"] = "content_revision_changed" return nil } // Update knowledge description knowledge.Description = summary knowledge.SummaryStatus = types.SummaryStatusCompleted knowledge.UpdatedAt = time.Now() summaryOut["summary_chars"] = len([]rune(summary)) // Preview the generated summary on the span output so the trace // viewer can show "this is what the LLM produced" at a glance, // without hopping to the knowledge-detail page. Capped to keep // span rows compact. summaryOut["summary_preview"] = previewText(summary, 240) if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { logger.Errorf(ctx, "Failed to update knowledge description: %v", err) summaryErr = err return fmt.Errorf("failed to update knowledge: %w", err) } // Create summary chunk and index it — only when RAG indexing is enabled. // Wiki-only KBs don't need summary chunks in the vector index. if strings.TrimSpace(summary) != "" && kb.NeedsEmbeddingModel() { // Get max chunk index maxChunkIndex := 0 for _, chunk := range chunks { if chunk.ChunkIndex > maxChunkIndex { maxChunkIndex = chunk.ChunkIndex } } // Embed only the LLM-generated summary in the indexed chunk. // We deliberately omit knowledge.FileName here: filenames are an // unreliable signal (e.g. "MX5280.pdf" for a scanned legal letter) // and surfacing them in retrieved RAG context can re-introduce the // hallucination vector this branch is meant to close. summaryChunk := &types.Chunk{ ID: uuid.New().String(), TenantID: knowledge.TenantID, KnowledgeID: knowledge.ID, KnowledgeBaseID: knowledge.KnowledgeBaseID, Content: fmt.Sprintf("# Summary\n%s", summary), ChunkIndex: maxChunkIndex + 1, IsEnabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), StartAt: 0, EndAt: 0, ChunkType: types.ChunkTypeSummary, ParentChunkID: textChunks[0].ID, } // Save summary chunk if err := s.chunkService.CreateChunks(ctx, []*types.Chunk{summaryChunk}); err != nil { logger.Errorf(ctx, "Failed to create summary chunk: %v", err) summaryErr = err return fmt.Errorf("failed to create summary chunk: %w", err) } // Index summary chunk tenantInfo, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID) if err != nil { logger.Errorf(ctx, "Failed to get tenant info: %v", err) summaryErr = err return fmt.Errorf("failed to get tenant info: %w", err) } ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenantInfo) retrieveEngine, err := retriever.CreateRetrieveEngineForKB( ctx, s.retrieveEngine, s.ownership, tenantInfo.ID, kb.VectorStoreID) if err != nil { logger.Errorf(ctx, "Failed to init retrieve engine: %v", err) summaryErr = err return fmt.Errorf("failed to init retrieve engine: %w", err) } embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID) if err != nil { logger.Errorf(ctx, "Failed to get embedding model: %v", err) summaryErr = err return fmt.Errorf("failed to get embedding model: %w", err) } indexInfo := []*types.IndexInfo{{ Content: summaryChunk.Content, SourceID: summaryChunk.ID, SourceType: types.ChunkSourceType, ChunkID: summaryChunk.ID, KnowledgeID: knowledge.ID, KnowledgeBaseID: knowledge.KnowledgeBaseID, IsEnabled: true, }} if err := retrieveEngine.BatchIndex(ctx, embeddingModel, indexInfo); err != nil { logger.Errorf(ctx, "Failed to index summary chunk: %v", err) summaryErr = err return fmt.Errorf("failed to index summary chunk: %w", err) } logger.Infof(ctx, "Successfully created and indexed summary chunk for knowledge: %s", payload.KnowledgeID) summaryOut["summary_chunk_indexed"] = true } logger.Infof(ctx, "Successfully generated summary for knowledge: %s", payload.KnowledgeID) summaryOut["status"] = "completed" return nil } // ProcessQuestionGeneration handles async question generation task. It // dispatches between the batched fan-out path (current: one task per window of // text chunks, payload.ChunkIDs set) and the legacy whole-knowledge path (kept // for tasks enqueued before fan-out shipped, no chunk ids). A lone ChunkID // (from an interim per-chunk build) is treated as a one-element batch. func (s *knowledgeService) ProcessQuestionGeneration(ctx context.Context, t *asynq.Task) (retErr error) { var payload types.QuestionGenerationPayload if err := json.Unmarshal(t.Payload(), &payload); err != nil { logger.Errorf(ctx, "Failed to unmarshal question generation payload: %v", err) return nil // Don't retry on unmarshal error } if len(payload.ChunkIDs) > 0 || payload.ChunkID != "" { return s.processQuestionGenerationForChunks(ctx, t, payload) } return s.processQuestionGenerationForKnowledge(ctx, t, payload) } // processQuestionGenerationForKnowledge is the legacy whole-knowledge handler: // it iterates every text chunk of the knowledge in one task. Retained for // in-flight tasks queued before per-chunk fan-out; new enqueues always set // payload.ChunkID and take the per-chunk path instead. func (s *knowledgeService) processQuestionGenerationForKnowledge(ctx context.Context, t *asynq.Task, payload types.QuestionGenerationPayload) (retErr error) { taskStartedAt := time.Now() retryCount, _ := asynq.GetRetryCount(ctx) maxRetry, _ := asynq.GetMaxRetry(ctx) exitStatus := "success" totalChunks := 0 totalTextChunks := 0 emptyContentChunks := 0 llmCallAttempts := 0 llmCallSuccess := 0 llmCallFailed := 0 llmCallEmpty := 0 generatedQuestionsTotal := 0 chunkMetadataSetFailed := 0 chunkUpdateFailed := 0 indexEntriesPrepared := 0 indexBatchAttempted := false indexBatchSucceeded := false // Sample question + model id surfaced on the span output so the // trace viewer can answer "what did the LLM actually produce?" and // "which model did it run on?" without joining back to the chunk // store. Captured the first time we see a non-empty question batch. var sampleQuestion string var resolvedModelID string // Postprocess subspan for the trace viewer. Opened lazily after we // unmarshal the payload (so we have payload.Attempt) and closed in // the defer below alongside the stats log so the span output mirrors // what we already log to stdout. var qSpan *Span var qErr error // Set when a newer attempt supersedes this run; suppresses the // FinalizeSubtask decrement so a stale task can't drain the new // attempt's counter. superseded := false // Decrement enrichment counter on terminal exit. Keyed on the value // RETURNED to asynq (retErr), not qErr: some branches record a span // failure (qErr != nil) yet `return nil` so asynq won't retry (KB / // knowledge fetch failures); those are terminal and must drain. // Keying on qErr would skip them and strand the row in "finalizing". // When we return an error asynq retries, so we only drain on the // final attempt. Runs AFTER the stats-log defer below — defers // unwind LIFO, so this one declared first executes last. defer func() { finalizeSubtaskDetached(ctx, s.repo, payload.KnowledgeID, "question_legacy", retErr, superseded, isFinalAsynqAttempt(ctx)) }() defer func() { logger.Infof( ctx, "Question generation stats: knowledge=%s kb=%s retry=%d/%d status=%s elapsed=%s chunks(total=%d,text=%d,empty_text=%d) llm(attempt=%d,success=%d,empty=%d,failed=%d) generated_questions=%d chunk_update_failed=%d metadata_set_failed=%d index(prepared=%d,attempted=%v,succeeded=%v)", payload.KnowledgeID, payload.KnowledgeBaseID, retryCount, maxRetry, exitStatus, time.Since(taskStartedAt).Round(time.Millisecond), totalChunks, totalTextChunks, emptyContentChunks, llmCallAttempts, llmCallSuccess, llmCallEmpty, llmCallFailed, generatedQuestionsTotal, chunkUpdateFailed, chunkMetadataSetFailed, indexEntriesPrepared, indexBatchAttempted, indexBatchSucceeded, ) if qSpan != nil { out := types.JSONMap{ "status": exitStatus, "total_chunks": totalChunks, "text_chunks": totalTextChunks, "empty_content_chunks": emptyContentChunks, "llm_attempts": llmCallAttempts, "llm_success": llmCallSuccess, "llm_empty": llmCallEmpty, "llm_failed": llmCallFailed, "questions_generated": generatedQuestionsTotal, "chunk_update_failed": chunkUpdateFailed, "metadata_set_failed": chunkMetadataSetFailed, "index_entries_prepared": indexEntriesPrepared, "index_batch_attempted": indexBatchAttempted, "index_batch_succeeded": indexBatchSucceeded, "retry": retryCount, "max_retry": maxRetry, } // Surface the resolved model id and a sample question on the // span output. These help debugging "why is question generation // slow" — both questions ("which model was hit?") and ("what // did it produce?") are hard to answer from logs alone. if resolvedModelID != "" { out["model_id"] = resolvedModelID } if sampleQuestion != "" { out["sample_question"] = sampleQuestion } // Treat any non-success exitStatus as a failed run; the // existing stats-string already enumerates them. qErr stays // optional for callers that want to surface a Go error. if exitStatus != "success" || qErr != nil { msg := exitStatus var detailErr error = qErr if qErr != nil { msg = qErr.Error() } s.failPostprocessSubspan(ctx, qSpan, "QUESTION_FAILED", msg, detailErr) } else { s.endPostprocessSubspan(ctx, qSpan, out) } } }() logger.Infof(ctx, "Processing question generation for knowledge: %s", payload.KnowledgeID) // A newer attempt has superseded this one: skip before opening the span // so we don't read stale chunks. superseded suppresses the counter // decrement in the defer above; qSpan stays nil so the stats defer no-ops. if attemptSuperseded(ctx, s.tracker(), payload.KnowledgeID, payload.Attempt) { superseded = true exitStatus = "superseded" logger.Infof(ctx, "question: attempt %d superseded for %s, skipping stale enrichment", payload.Attempt, payload.KnowledgeID) return nil } // Open the postprocess.question subspan now that we have payload.Attempt. // Closes via the defer above. qSpan = s.beginPostprocessSubspan(ctx, payload.KnowledgeID, payload.Attempt, "postprocess.question", types.JSONMap{ "question_count": payload.QuestionCount, "language": payload.Language, }) // Set tenant context ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) if payload.Language != "" { ctx = context.WithValue(ctx, types.LanguageContextKey, payload.Language) } if strings.TrimSpace(s.config.Conversation.GenerateQuestionsPrompt) == "" { exitStatus = "prompt_not_configured" logger.Errorf(ctx, "GenerateQuestionsPrompt is empty: configure conversation.generate_questions_prompt_id") qErr = fmt.Errorf("generate questions prompt not configured") return qErr } // Get knowledge base kb, err := s.kbService.GetKnowledgeBaseByID(ctx, payload.KnowledgeBaseID) if err != nil { exitStatus = "kb_not_found" logger.Errorf(ctx, "Failed to get knowledge base: %v", err) qErr = err return nil } // Get knowledge knowledge, err := s.repo.GetKnowledgeByID(ctx, payload.TenantID, payload.KnowledgeID) if err != nil { exitStatus = "knowledge_not_found" logger.Errorf(ctx, "Failed to get knowledge: %v", err) qErr = err return nil } // Short-circuit when the user cancelled parsing or the row is being deleted. if knowledge != nil { switch knowledge.ParseStatus { case types.ParseStatusCancelled, types.ParseStatusDeleting: exitStatus = "knowledge_" + knowledge.ParseStatus logger.Infof(ctx, "Question generation: knowledge aborted (%s), skipping: %s", knowledge.ParseStatus, payload.KnowledgeID) return nil } } // Get text chunks for this knowledge chunks, err := s.chunkService.ListChunksByKnowledgeID(ctx, payload.KnowledgeID) if err != nil { exitStatus = "list_chunks_failed" logger.Errorf(ctx, "Failed to get chunks: %v", err) return nil } totalChunks = len(chunks) // Filter text chunks only textChunks := make([]*types.Chunk, 0) for _, chunk := range chunks { if chunk.ChunkType == types.ChunkTypeText { textChunks = append(textChunks, chunk) } } totalTextChunks = len(textChunks) if len(textChunks) == 0 { exitStatus = "no_text_chunks" logger.Infof(ctx, "No text chunks found for knowledge: %s", payload.KnowledgeID) return nil } // Sort chunks by StartAt for context building sort.Slice(textChunks, func(i, j int) bool { return textChunks[i].StartAt < textChunks[j].StartAt }) // Initialize chat model chatModel, err := s.modelService.GetChatModel(ctx, kb.SummaryModelID) if err != nil { exitStatus = "get_chat_model_failed" logger.Errorf(ctx, "Failed to get chat model: %v", err) return fmt.Errorf("failed to get chat model: %w", err) } resolvedModelID = kb.SummaryModelID // Initialize embedding model and retrieval engine embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID) if err != nil { exitStatus = "get_embedding_model_failed" logger.Errorf(ctx, "Failed to get embedding model: %v", err) return fmt.Errorf("failed to get embedding model: %w", err) } tenantInfo, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID) if err != nil { exitStatus = "get_tenant_failed" logger.Errorf(ctx, "Failed to get tenant info: %v", err) return fmt.Errorf("failed to get tenant info: %w", err) } ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenantInfo) retrieveEngine, err := retriever.CreateRetrieveEngineForKB( ctx, s.retrieveEngine, s.ownership, tenantInfo.ID, kb.VectorStoreID) if err != nil { exitStatus = "init_retrieve_engine_failed" logger.Errorf(ctx, "Failed to init retrieve engine: %v", err) return fmt.Errorf("failed to init retrieve engine: %w", err) } questionCount := payload.QuestionCount if questionCount <= 0 { questionCount = 3 } if questionCount > 10 { questionCount = 10 } processOverrides, _ := knowledge.ProcessOverrides() questionGenCfg := ResolveProcessConfig(kb, processOverrides).QuestionGenerationConfig customInstructions := questionGenCfg.CustomInstructions // Collect image info for all text chunks so question generation can // see caption / OCR text instead of bare image links. textChunkIDs := make([]string, len(textChunks)) for i, c := range textChunks { textChunkIDs[i] = c.ID } imageInfoMap := searchutil.CollectImageInfoByChunkIDs(ctx, s.chunkRepo, payload.TenantID, textChunkIDs) enrichContent := func(chunk *types.Chunk) string { if info, ok := imageInfoMap[chunk.ID]; ok && info != "" { return searchutil.EnrichContentWithImageInfo(chunk.Content, info) } return chunk.Content } // Generate questions for each chunk with context var indexInfoList []*types.IndexInfo for i, chunk := range textChunks { if strings.TrimSpace(chunk.Content) == "" { emptyContentChunks++ continue } // Build context from adjacent chunks var prevContent, nextContent string if i > 0 { prevContent = enrichContent(textChunks[i-1]) } if i < len(textChunks)-1 { nextContent = enrichContent(textChunks[i+1]) } generationRevision := chunk.ContentRevision llmCallAttempts++ questions, err := s.generateQuestionsWithContext(ctx, chatModel, enrichContent(chunk), prevContent, nextContent, knowledge.Title, questionCount, customInstructions) if err != nil { llmCallFailed++ logger.Warnf(ctx, "Failed to generate questions for chunk %s: %v", chunk.ID, err) continue } if len(questions) == 0 { llmCallEmpty++ continue } latestChunk, latestErr := s.chunkRepo.GetChunkByID(ctx, payload.TenantID, chunk.ID) if latestErr != nil || latestChunk.ContentRevision != generationRevision { logger.Infof(ctx, "Skipping stale generated questions for chunk %s (revision changed)", chunk.ID) continue } chunk = latestChunk llmCallSuccess++ generatedQuestionsTotal += len(questions) if sampleQuestion == "" && len(questions) > 0 { sampleQuestion = previewText(questions[0], 200) } // Update chunk metadata with unique IDs for each question generatedQuestions := make([]types.GeneratedQuestion, len(questions)) questionRevision := chunk.ContentRevision for j, question := range questions { questionID := fmt.Sprintf("q%d", time.Now().UnixNano()+int64(j)) generatedQuestions[j] = types.GeneratedQuestion{ ID: questionID, Question: question, ContentRevision: &questionRevision, } } meta := &types.DocumentChunkMetadata{ GeneratedQuestions: generatedQuestions, GeneratedQuestionsRevision: chunk.ContentRevision, } if err := chunk.SetDocumentMetadata(meta); err != nil { chunkMetadataSetFailed++ logger.Warnf(ctx, "Failed to set document metadata for chunk %s: %v", chunk.ID, err) continue } // Update chunk in database if err := s.chunkService.UpdateChunk(ctx, chunk); err != nil { chunkUpdateFailed++ logger.Warnf(ctx, "Failed to update chunk %s: %v", chunk.ID, err) continue } // Create index entries for generated questions for _, gq := range generatedQuestions { sourceID := types.GeneratedQuestionSourceID(chunk.ID, gq.ID) indexInfoList = append(indexInfoList, &types.IndexInfo{ Content: buildKnowledgeIndexContent(knowledge, gq.Question), SourceID: sourceID, SourceType: types.ChunkSourceType, ChunkID: chunk.ID, KnowledgeID: knowledge.ID, KnowledgeBaseID: knowledge.KnowledgeBaseID, IsEnabled: true, }) } logger.Debugf(ctx, "Generated %d questions for chunk %s", len(questions), chunk.ID) } indexEntriesPrepared = len(indexInfoList) // Index generated questions if len(indexInfoList) > 0 { indexBatchAttempted = true if err := retrieveEngine.BatchIndex(ctx, embeddingModel, indexInfoList); err != nil { exitStatus = "index_questions_failed" logger.Errorf(ctx, "Failed to index generated questions: %v", err) return fmt.Errorf("failed to index questions: %w", err) } indexBatchSucceeded = true logger.Infof(ctx, "Successfully indexed %d generated questions for knowledge: %s", len(indexInfoList), payload.KnowledgeID) } return nil } // processQuestionGenerationForChunks generates questions for a batch (window) // of text chunks. This is the batched fan-out path (one asynq task per // questionGenChunkBatchSize chunks), aligned with the graph-extract // TypeChunkExtract pattern: independent retry, per-batch cancellation, and a // postprocess.question.batch[i] subspan. The payload carries only chunk ids // (never content); content is read fresh here, and all questions for the batch // are indexed in a single embedding BatchIndex call. func (s *knowledgeService) processQuestionGenerationForChunks(ctx context.Context, t *asynq.Task, payload types.QuestionGenerationPayload) (retErr error) { taskStartedAt := time.Now() retryCount, _ := asynq.GetRetryCount(ctx) maxRetry, _ := asynq.GetMaxRetry(ctx) // Normalize the batch: prefer ChunkIDs, fall back to a lone ChunkID // (interim per-chunk build) so those in-flight tasks still run. batchIDs := payload.ChunkIDs if len(batchIDs) == 0 && payload.ChunkID != "" { batchIDs = []string{payload.ChunkID} } exitStatus := "success" chunksInBatch := len(batchIDs) chunksProcessed := 0 emptyChunks := 0 llmCallFailed := 0 generatedQuestionsTotal := 0 indexEntriesPrepared := 0 indexBatchSucceeded := false var sampleQuestion string var resolvedModelID string var qSpan *Span var qErr error // Suppresses the FinalizeSubtask drain when a newer attempt superseded // this run, so a stale task can't decrement the new attempt's counter. superseded := false ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) if payload.Language != "" { ctx = context.WithValue(ctx, types.LanguageContextKey, payload.Language) } // Drain the parent's enrichment counter on terminal exit. Keyed on the // value RETURNED to asynq (retErr), not qErr: some branches record a // span failure yet `return nil` (terminal, must drain). Declared first // so it runs LAST (after the stats/span defer below). defer func() { finalizeSubtaskDetached(ctx, s.repo, payload.KnowledgeID, fmt.Sprintf("question_batch[%d]", payload.BatchIndex), retErr, superseded, isFinalAsynqAttempt(ctx)) }() defer func() { logger.Infof(ctx, "Question generation (batch) stats: knowledge=%s batch=%d chunks(in_batch=%d,processed=%d,empty=%d) llm_failed=%d retry=%d/%d status=%s elapsed=%s generated_questions=%d index(entries=%d,succeeded=%v)", payload.KnowledgeID, payload.BatchIndex, chunksInBatch, chunksProcessed, emptyChunks, llmCallFailed, retryCount, maxRetry, exitStatus, time.Since(taskStartedAt).Round(time.Millisecond), generatedQuestionsTotal, indexEntriesPrepared, indexBatchSucceeded, ) if qSpan != nil { out := types.JSONMap{ "status": exitStatus, "batch_index": payload.BatchIndex, "chunks_in_batch": chunksInBatch, "chunks_processed": chunksProcessed, "empty_chunks": emptyChunks, "llm_failed": llmCallFailed, "questions_generated": generatedQuestionsTotal, "index_entries_prepared": indexEntriesPrepared, "index_batch_succeeded": indexBatchSucceeded, "retry": retryCount, "max_retry": maxRetry, } if resolvedModelID != "" { out["model_id"] = resolvedModelID } if sampleQuestion != "" { out["sample_question"] = sampleQuestion } if exitStatus != "success" || qErr != nil { msg := exitStatus if qErr != nil { msg = qErr.Error() } s.failPostprocessSubspan(ctx, qSpan, "QUESTION_FAILED", msg, qErr) } else { s.endPostprocessSubspan(ctx, qSpan, out) } } }() logger.Infof(ctx, "Processing question generation for knowledge=%s batch=%d chunks=%d", payload.KnowledgeID, payload.BatchIndex, chunksInBatch) if chunksInBatch == 0 { exitStatus = "empty_batch" return nil } // A newer attempt has superseded this one: skip before opening the span // so we don't read stale chunks and don't drain the new attempt. if attemptSuperseded(ctx, s.tracker(), payload.KnowledgeID, payload.Attempt) { superseded = true exitStatus = "superseded" logger.Infof(ctx, "question: attempt %d superseded for %s, skipping stale enrichment", payload.Attempt, payload.KnowledgeID) return nil } qSpan = s.beginQuestionBatchSubspan(ctx, payload.KnowledgeID, payload.Attempt, fmt.Sprintf("postprocess.question.batch[%d]", payload.BatchIndex), types.JSONMap{ "batch_index": payload.BatchIndex, "chunks": chunksInBatch, "question_count": payload.QuestionCount, "language": payload.Language, }) if strings.TrimSpace(s.config.Conversation.GenerateQuestionsPrompt) == "" { exitStatus = "prompt_not_configured" logger.Errorf(ctx, "GenerateQuestionsPrompt is empty: configure conversation.generate_questions_prompt_id") qErr = fmt.Errorf("generate questions prompt not configured") return qErr } kb, err := s.kbService.GetKnowledgeBaseByID(ctx, payload.KnowledgeBaseID) if err != nil { exitStatus = "kb_not_found" logger.Errorf(ctx, "Failed to get knowledge base: %v", err) qErr = err return nil } knowledge, err := s.repo.GetKnowledgeByID(ctx, payload.TenantID, payload.KnowledgeID) if err != nil { exitStatus = "knowledge_not_found" logger.Errorf(ctx, "Failed to get knowledge: %v", err) qErr = err return nil } // Short-circuit when the user cancelled parsing or the row is being // deleted — batched fan-out means we get this check for free on every // batch, so a cancel stops burning LLM quota on the remaining batches. if knowledge != nil { switch knowledge.ParseStatus { case types.ParseStatusCancelled, types.ParseStatusDeleting: exitStatus = "knowledge_" + knowledge.ParseStatus logger.Infof(ctx, "Question generation: knowledge aborted (%s), skipping batch %d", knowledge.ParseStatus, payload.BatchIndex) return nil } } chatModel, err := s.modelService.GetChatModel(ctx, kb.SummaryModelID) if err != nil { exitStatus = "get_chat_model_failed" logger.Errorf(ctx, "Failed to get chat model: %v", err) return fmt.Errorf("failed to get chat model: %w", err) } resolvedModelID = kb.SummaryModelID embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID) if err != nil { exitStatus = "get_embedding_model_failed" logger.Errorf(ctx, "Failed to get embedding model: %v", err) return fmt.Errorf("failed to get embedding model: %w", err) } tenantInfo, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID) if err != nil { exitStatus = "get_tenant_failed" logger.Errorf(ctx, "Failed to get tenant info: %v", err) return fmt.Errorf("failed to get tenant info: %w", err) } ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenantInfo) retrieveEngine, err := retriever.CreateRetrieveEngineForKB( ctx, s.retrieveEngine, s.ownership, tenantInfo.ID, kb.VectorStoreID) if err != nil { exitStatus = "init_retrieve_engine_failed" logger.Errorf(ctx, "Failed to init retrieve engine: %v", err) return fmt.Errorf("failed to init retrieve engine: %w", err) } questionCount := payload.QuestionCount if questionCount <= 0 { questionCount = 3 } if questionCount > 10 { questionCount = 10 } processOverrides, _ := knowledge.ProcessOverrides() questionGenCfg := ResolveProcessConfig(kb, processOverrides).QuestionGenerationConfig customInstructions := questionGenCfg.CustomInstructions // Fetch the batch chunks (in payload order) plus the two boundary // neighbors so we can rebuild the same surrounding context the legacy // loop used, all enriched with image OCR / caption info. A vanished // chunk degrades gracefully (skipped / empty context). getChunk := func(id string) *types.Chunk { if id == "" { return nil } c, gerr := s.chunkRepo.GetChunkByID(ctx, payload.TenantID, id) if gerr != nil { return nil } return c } batchChunks := make([]*types.Chunk, len(batchIDs)) for i, id := range batchIDs { batchChunks[i] = getChunk(id) } prevChunk := getChunk(payload.PrevChunkID) nextChunk := getChunk(payload.NextChunkID) infoIDs := make([]string, 0, len(batchIDs)+2) infoIDs = append(infoIDs, batchIDs...) if payload.PrevChunkID != "" { infoIDs = append(infoIDs, payload.PrevChunkID) } if payload.NextChunkID != "" { infoIDs = append(infoIDs, payload.NextChunkID) } imageInfoMap := searchutil.CollectImageInfoByChunkIDs(ctx, s.chunkRepo, payload.TenantID, infoIDs) enrich := func(c *types.Chunk) string { if c == nil { return "" } if info, ok := imageInfoMap[c.ID]; ok && info != "" { return searchutil.EnrichContentWithImageInfo(c.Content, info) } return c.Content } // neighborContent returns the context content for position i within the // batch: the in-batch neighbor when present, else the boundary chunk. prevContentAt := func(i int) string { if i > 0 { return enrich(batchChunks[i-1]) } return enrich(prevChunk) } nextContentAt := func(i int) string { if i < len(batchChunks)-1 { return enrich(batchChunks[i+1]) } return enrich(nextChunk) } var indexInfoList []*types.IndexInfo for i, chunk := range batchChunks { if chunk == nil || strings.TrimSpace(chunk.Content) == "" { emptyChunks++ continue } generationRevision := chunk.ContentRevision questions, gerr := s.generateQuestionsWithContext( ctx, chatModel, enrich(chunk), prevContentAt(i), nextContentAt(i), knowledge.Title, questionCount, customInstructions) if gerr != nil { llmCallFailed++ logger.Warnf(ctx, "Failed to generate questions for chunk %s: %v", chunk.ID, gerr) continue } if len(questions) == 0 { continue } latestChunk, latestErr := s.chunkRepo.GetChunkByID(ctx, payload.TenantID, chunk.ID) if latestErr != nil || latestChunk.ContentRevision != generationRevision { logger.Infof(ctx, "Skipping stale generated questions for chunk %s (revision changed)", chunk.ID) continue } chunk = latestChunk chunksProcessed++ generatedQuestionsTotal += len(questions) if sampleQuestion == "" { sampleQuestion = previewText(questions[0], 200) } generatedQuestions := make([]types.GeneratedQuestion, len(questions)) questionRevision := chunk.ContentRevision for j, question := range questions { generatedQuestions[j] = types.GeneratedQuestion{ ID: fmt.Sprintf("q%d", time.Now().UnixNano()+int64(j)), Question: question, ContentRevision: &questionRevision, } } meta := &types.DocumentChunkMetadata{ GeneratedQuestions: generatedQuestions, GeneratedQuestionsRevision: chunk.ContentRevision, } if err := chunk.SetDocumentMetadata(meta); err != nil { logger.Warnf(ctx, "Failed to set document metadata for chunk %s: %v", chunk.ID, err) continue } if err := s.chunkService.UpdateChunk(ctx, chunk); err != nil { logger.Warnf(ctx, "Failed to update chunk %s: %v", chunk.ID, err) continue } for _, gq := range generatedQuestions { indexInfoList = append(indexInfoList, &types.IndexInfo{ Content: buildKnowledgeIndexContent(knowledge, gq.Question), SourceID: types.GeneratedQuestionSourceID(chunk.ID, gq.ID), SourceType: types.ChunkSourceType, ChunkID: chunk.ID, KnowledgeID: knowledge.ID, KnowledgeBaseID: knowledge.KnowledgeBaseID, IsEnabled: true, }) } } indexEntriesPrepared = len(indexInfoList) if len(indexInfoList) > 0 { if err := retrieveEngine.BatchIndex(ctx, embeddingModel, indexInfoList); err != nil { exitStatus = "index_questions_failed" qErr = err logger.Errorf(ctx, "Failed to index generated questions for batch %d: %v", payload.BatchIndex, err) return fmt.Errorf("failed to index questions: %w", err) } indexBatchSucceeded = true logger.Infof(ctx, "Indexed %d generated questions for knowledge=%s batch=%d", len(indexInfoList), payload.KnowledgeID, payload.BatchIndex) } return nil } // generateQuestionsWithContext generates questions for a chunk with surrounding context func (s *knowledgeService) generateQuestionsWithContext(ctx context.Context, chatModel chat.Chat, content, prevContent, nextContent, docName string, questionCount int, customInstructions string, ) ([]string, error) { if content == "" || questionCount <= 0 { return nil, nil } prompt := strings.TrimSpace(s.config.Conversation.GenerateQuestionsPrompt) if prompt == "" { return nil, fmt.Errorf("generate questions prompt not configured") } // Build context section var contextSection string if prevContent != "" || nextContent != "" { contextSection = "\n" if prevContent != "" { contextSection += fmt.Sprintf("\n%s\n\n\n\n", prevContent) } if nextContent != "" { contextSection += fmt.Sprintf("\n%s\n\n\n\n", nextContent) } contextSection += "\n\n" } langName := types.LanguageNameFromContext(ctx) prompt = types.RenderPromptPlaceholders(prompt, types.PlaceholderValues{ "question_count": fmt.Sprintf("%d", questionCount), "content": content, "context": contextSection, "doc_name": docName, "language": langName, }) prompt = types.AppendCustomPromptInstructions(prompt, customInstructions, "question_generation") thinking := false modelCtx := types.WithLLMCallMetadata(ctx, "question_generation", "") response, err := chatModel.Chat(modelCtx, []chat.Message{ { Role: "user", Content: prompt, }, }, &chat.ChatOptions{ Temperature: 0.7, MaxTokens: 512, Thinking: &thinking, }) if err != nil { return nil, fmt.Errorf("failed to generate questions: %w", err) } // Parse response lines := strings.Split(response.Content, "\n") questions := make([]string, 0, questionCount) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } line = strings.TrimLeft(line, "0123456789.-*) ") line = strings.TrimSpace(line) if line != "" && len(line) > 5 { questions = append(questions, line) if len(questions) >= questionCount { break } } } return questions, nil } // RegenerateChunkQuestions synchronously refreshes Doc2Query-style auxiliary // questions for the current chunk revision and atomically replaces their // retrieval entries through updateChunkVector. func (s *knowledgeService) RegenerateChunkQuestions( ctx context.Context, chunkID string, ) ([]types.GeneratedQuestion, error) { tenantID := types.MustTenantIDFromContext(ctx) chunk, err := s.chunkRepo.GetChunkByID(ctx, tenantID, chunkID) if err != nil { return nil, err } if chunk.ChunkType != types.ChunkTypeText { return nil, fmt.Errorf("questions can only be generated for text chunks") } generationRevision := chunk.ContentRevision knowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, chunk.KnowledgeID) if err != nil { return nil, err } kb, err := s.kbService.GetKnowledgeBaseByID(ctx, chunk.KnowledgeBaseID) if err != nil { return nil, err } if kb.SummaryModelID == "" { return nil, fmt.Errorf("summary model is required for question generation") } chatModel, err := s.modelService.GetChatModel(ctx, kb.SummaryModelID) if err != nil { return nil, err } resolveNeighbor := func(id string) string { if id == "" { return "" } neighbor, getErr := s.chunkRepo.GetChunkByID(ctx, tenantID, id) if getErr != nil { return "" } return neighbor.Content } overrides, _ := knowledge.ProcessOverrides() config := ResolveProcessConfig(kb, overrides).QuestionGenerationConfig count := config.QuestionCount if count <= 0 { count = 3 } if count > 10 { count = 10 } questions, err := s.generateQuestionsWithContext( ctx, chatModel, chunk.Content, resolveNeighbor(chunk.PreChunkID), resolveNeighbor(chunk.NextChunkID), knowledge.Title, count, config.CustomInstructions, ) if err != nil { return nil, err } latestChunk, err := s.chunkRepo.GetChunkByID(ctx, tenantID, chunkID) if err != nil { return nil, err } if latestChunk.ContentRevision != generationRevision { return nil, ErrChunkRevisionConflict } chunk = latestChunk generated := make([]types.GeneratedQuestion, 0, len(questions)) questionRevision := chunk.ContentRevision for _, question := range questions { generated = append(generated, types.GeneratedQuestion{ ID: uuid.NewString(), Question: question, ContentRevision: &questionRevision, }) } meta := &types.DocumentChunkMetadata{ GeneratedQuestions: generated, GeneratedQuestionsRevision: chunk.ContentRevision, } if err := chunk.SetDocumentMetadata(meta); err != nil { return nil, err } if err := s.chunkRepo.UpdateChunk(ctx, chunk); err != nil { return nil, err } if err := s.updateChunkVector(ctx, chunk.KnowledgeBaseID, []*types.Chunk{chunk}); err != nil { return nil, err } return generated, nil } // RegenerateKnowledgeSummary refreshes both the knowledge description and the // summary chunk(s), then reindexes only those summary chunks. It is // intentionally idempotent. func (s *knowledgeService) RegenerateKnowledgeSummary( ctx context.Context, knowledgeID string, ) (*types.Knowledge, error) { tenantID := types.MustTenantIDFromContext(ctx) knowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, knowledgeID) if err != nil { return nil, err } kb, err := s.kbService.GetKnowledgeBaseByID(ctx, knowledge.KnowledgeBaseID) if err != nil { return nil, err } if kb.SummaryModelID == "" { return nil, fmt.Errorf("summary model is not configured") } allChunks, err := s.chunkRepo.ListChunksByKnowledgeID(ctx, tenantID, knowledgeID) if err != nil { return nil, err } textChunks := make([]*types.Chunk, 0) for _, chunk := range allChunks { if chunk.ChunkType == types.ChunkTypeText && chunk.IsEnabled { textChunks = append(textChunks, chunk) } } if len(textChunks) == 0 { knowledge.Description = "" knowledge.SummaryStatus = types.SummaryStatusFailed knowledge.UpdatedAt = time.Now() if updateErr := s.repo.UpdateKnowledge(ctx, knowledge); updateErr != nil { return knowledge, updateErr } return knowledge, errInsufficientSummaryContent } sort.Slice(textChunks, func(i, j int) bool { return textChunks[i].ChunkIndex < textChunks[j].ChunkIndex }) metadataVersion := string(knowledge.CustomMetadata) knowledge.SummaryStatus = types.SummaryStatusProcessing if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { return nil, err } handleGenerationFailure := func(generationErr error) (*types.Knowledge, error) { if errors.Is(generationErr, errInsufficientSummaryContent) { knowledge.Description = "" knowledge.SummaryStatus = types.SummaryStatusFailed knowledge.UpdatedAt = time.Now() if updateErr := s.repo.UpdateKnowledge(ctx, knowledge); updateErr != nil { return knowledge, updateErr } return knowledge, generationErr } if summaryTaskWillRetry(ctx) { applyRetryableSummaryFailureState(knowledge, textChunks, true) if updateErr := s.repo.UpdateKnowledge(ctx, knowledge); updateErr != nil { logger.Warnf(ctx, "Failed to mark summary refresh pending for retry: %v", updateErr) } return knowledge, generationErr } stale, staleErr := summarySourceChanged( ctx, s.repo, s.chunkRepo, tenantID, knowledgeID, metadataVersion, textChunks, ) if staleErr != nil { knowledge.SummaryStatus = types.SummaryStatusFailed _ = s.repo.UpdateKnowledge(ctx, knowledge) return knowledge, fmt.Errorf("verify summary fallback freshness: %w", staleErr) } if stale { return knowledge, ErrSummaryRefreshStale } applyRetryableSummaryFailureState(knowledge, textChunks, false) if updateErr := s.repo.UpdateKnowledge(ctx, knowledge); updateErr != nil { return knowledge, updateErr } return knowledge, generationErr } chatModel, err := s.modelService.GetChatModel(ctx, kb.SummaryModelID) if err != nil { return handleGenerationFailure(fmt.Errorf("get chat model: %w", err)) } summary, err := s.getSummary(ctx, chatModel, knowledge, textChunks) if err != nil { return handleGenerationFailure(err) } stale, err := summarySourceChanged( ctx, s.repo, s.chunkRepo, tenantID, knowledgeID, metadataVersion, textChunks, ) if err != nil { return nil, fmt.Errorf("verify summary freshness: %w", err) } if stale { logger.Infof(ctx, "Discarding stale summary refresh for knowledge %s", knowledgeID) return nil, ErrSummaryRefreshStale } knowledge.Description = summary knowledge.SummaryStatus = types.SummaryStatusCompleted knowledge.UpdatedAt = time.Now() if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { return nil, err } if kb.NeedsEmbeddingModel() { maxIndex := 0 for _, chunk := range allChunks { if chunk.ChunkIndex > maxIndex { maxIndex = chunk.ChunkIndex } } // allChunks holds text chunks only, so it can never carry the existing // summary chunk. Scanning it for one always came up empty, which left // every refresh appending a new summary chunk beside the stale one -- // and a stale summary stays enabled and indexed, so content the user // edited out of the document kept being retrievable through it. existingSummaries, err := s.chunkRepo.ListChunksByKnowledgeIDAndTypes( ctx, tenantID, knowledgeID, []types.ChunkType{types.ChunkTypeSummary}, ) if err != nil { return nil, err } summaryChunks := make([]*types.Chunk, 0, len(existingSummaries)) for _, chunk := range existingSummaries { chunk.Content = "# Summary\n" + summary chunk.SourceContent = chunk.Content chunk.IsEnabled = true chunk.UpdatedAt = time.Now() if err := s.chunkRepo.UpdateChunk(ctx, chunk); err != nil { return nil, err } summaryChunks = append(summaryChunks, chunk) } if len(summaryChunks) == 0 { summaryChunk := &types.Chunk{ ID: uuid.NewString(), TenantID: tenantID, KnowledgeID: knowledge.ID, KnowledgeBaseID: knowledge.KnowledgeBaseID, Content: "# Summary\n" + summary, ChunkIndex: maxIndex + 1, IsEnabled: true, ChunkType: types.ChunkTypeSummary, ParentChunkID: textChunks[0].ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := s.chunkRepo.CreateChunks(ctx, []*types.Chunk{summaryChunk}); err != nil { return nil, err } summaryChunks = append(summaryChunks, summaryChunk) } if err := s.updateChunkVector(ctx, knowledge.KnowledgeBaseID, summaryChunks); err != nil { return nil, err } } return knowledge, nil } // ReparseKnowledge deletes existing document content and re-parses the knowledge asynchronously. // This method reuses the logic from UpdateManualKnowledge for resource cleanup and async parsing. func (s *knowledgeService) ReparseKnowledge( ctx context.Context, knowledgeID string, processOverrides *types.KnowledgeProcessOverrides, ) (*types.Knowledge, error) { logger.Info(ctx, "Start re-parsing knowledge") tenantID := ctx.Value(types.TenantIDContextKey).(uint64) existing, err := s.repo.GetKnowledgeByID(ctx, tenantID, knowledgeID) if err != nil { logger.Errorf(ctx, "Failed to load knowledge: %v", err) return nil, err } // Allocate a fresh span tree attempt up front. Doing this BEFORE // the cleanup + enqueue means: (a) the UI immediately sees a new // attempt with all five stages back to "pending" instead of the // previous run's "failed" badge lingering; (b) the worker's // fallback path won't double-allocate when payload.Attempt is // already set on the queued task. reparseAttempt := 0 if root, n, err := s.tracker().OpenAttempt(ctx, existing.ID, ""); err == nil && root != nil { reparseAttempt = n } else if err != nil { logger.Warnf(ctx, "[Reparse] OpenAttempt failed for %s: %v (will fall back in worker)", existing.ID, err) } // Get knowledge base configuration kb, err := s.kbService.GetKnowledgeBaseByID(ctx, existing.KnowledgeBaseID) if err != nil { logger.Errorf(ctx, "Failed to get knowledge base for reparse: %v", err) return nil, err } // When the caller supplies new overrides (e.g. via the reparse confirm // dialog), validate them against this knowledge's file type, then persist // to metadata so both this call's enqueue and the worker re-read the same // config. nil keeps whatever was stored at upload time. if processOverrides != nil { if err := ValidateProcessOverrides(ctx, kb, processOverrides, reparseFileTypes(existing)); err != nil { return nil, err } if err := existing.SetProcessOverrides(processOverrides); err != nil { logger.Errorf(ctx, "Failed to set process overrides on reparse: %v", err) return nil, err } if err := s.repo.UpdateKnowledgeColumn(ctx, existing.ID, "metadata", existing.Metadata); err != nil { logger.Errorf(ctx, "Failed to persist process overrides on reparse: %v", err) return nil, err } } processOverrides, _ = existing.ProcessOverrides() reparseEff := ResolveProcessConfig(kb, processOverrides) // Keep wiki's pending queue consistent across both manual and non-manual // paths. The destructive work (swapping old wiki contributions for new) // happens asynchronously inside mapOneDocument — see its oldPageSlugs // handling — once post-process re-enqueues wiki ingest. All we need to // do here is stop any stale pending ingest op from firing against the // pre-reparse chunk set. if kb != nil && kb.IsWikiEnabled() { s.prepareWikiForReparse(ctx, existing) } recordReparseStarted := func() { recordKBActivity(ctx, s.audit, tenantID, existing.KnowledgeBaseID, types.AuditActionKnowledgeReparseStarted, "knowledge", existing.ID, types.AuditOutcomeAccepted, map[string]any{"title": existing.Title, "type": existing.Type, "attempt": reparseAttempt}) } // For manual knowledge, use async manual processing (cleanup + re-indexing in worker) if existing.IsManual() { meta, metaErr := existing.ManualMetadata() if metaErr != nil || meta == nil { logger.Errorf(ctx, "Failed to get manual metadata for reparse: %v", metaErr) return nil, werrors.NewBadRequestError("无法获取手工知识内容") } resetKnowledgeForReparse(existing, kb) if err := s.repo.UpdateKnowledge(ctx, existing); err != nil { logger.Errorf(ctx, "Failed to update knowledge status before reparse: %v", err) return nil, err } if err := s.repo.UpdateKnowledgeColumn(ctx, existing.ID, "pending_subtasks_count", 0); err != nil { logger.Errorf(ctx, "Failed to reset pending_subtasks_count before reparse: %v", err) return nil, err } if _, err := s.enqueueManualProcessing(ctx, existing, meta.Content, true); err != nil { logger.Errorf(ctx, "Failed to enqueue manual reparse task: %v", err) s.markKnowledgeEnqueueFailed(ctx, existing) return existing, werrors.NewInternalServerError("Failed to submit processing task") } else { recordReparseStarted() } return existing, nil } // For non-manual knowledge, cleanup synchronously then enqueue document processing logger.Infof(ctx, "Cleaning up existing resources for knowledge: %s", knowledgeID) if err := s.cleanupKnowledgeResources(ctx, existing); err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "knowledge_id": knowledgeID, }) return nil, err } // Step 2: Update knowledge status and metadata resetKnowledgeForReparse(existing, kb) if err := s.repo.UpdateKnowledge(ctx, existing); err != nil { logger.Errorf(ctx, "Failed to update knowledge status before reparse: %v", err) return nil, err } if err := s.repo.UpdateKnowledgeColumn(ctx, existing.ID, "pending_subtasks_count", 0); err != nil { logger.Errorf(ctx, "Failed to reset pending_subtasks_count before reparse: %v", err) return nil, err } // Step 3: Trigger async re-parsing based on knowledge type logger.Infof(ctx, "Knowledge status updated, scheduling async reparse, ID: %s, Type: %s", existing.ID, existing.Type) // For file-based knowledge, enqueue document processing task if existing.FilePath != "" { tenantID := ctx.Value(types.TenantIDContextKey).(uint64) enableMultimodel := reparseEff.EnableMultimodel enableQuestionGeneration := reparseEff.QuestionGenerationConfig.Enabled questionCount := reparseEff.QuestionGenerationConfig.QuestionCount if questionCount <= 0 { questionCount = 3 } lang := types.LanguageFromContextOrDefault(ctx) taskPayload := types.DocumentProcessPayload{ TenantID: tenantID, KnowledgeID: existing.ID, KnowledgeBaseID: existing.KnowledgeBaseID, FilePath: existing.FilePath, FileName: existing.FileName, FileType: getFileType(existing.FileName), EnableMultimodel: enableMultimodel, EnableQuestionGeneration: enableQuestionGeneration, QuestionCount: questionCount, Language: lang, Attempt: reparseAttempt, } langfuse.InjectTracing(ctx, &taskPayload) payloadBytes, err := json.Marshal(taskPayload) if err != nil { logger.Errorf(ctx, "Failed to marshal reparse task payload: %v", err) s.markKnowledgeEnqueueFailed(ctx, existing) return existing, werrors.NewInternalServerError("Failed to submit processing task") } task := asynq.NewTask( types.TypeDocumentProcess, payloadBytes, ) info, err := s.task.Enqueue(task, documentProcessTaskOptions(s.config, asynq.MaxRetry(3))...) if err != nil { logger.Errorf(ctx, "Failed to enqueue reparse task: %v", err) s.markKnowledgeEnqueueFailed(ctx, existing) return existing, werrors.NewInternalServerError("Failed to submit processing task") } logger.Infof(ctx, "Enqueued reparse task: id=%s queue=%s knowledge_id=%s", info.ID, info.Queue, existing.ID) recordReparseStarted() // For data tables (csv, xlsx, xls), also enqueue summary task enqueueDataTableSummaryIfNeeded(ctx, s.task, tenantID, existing.ID, existing.FileName, existing.FileType, kb.SummaryModelID, kb.EmbeddingModelID) return existing, nil } // For file-URL-based knowledge, enqueue document processing task with FileURL field if existing.Type == "file_url" && existing.Source != "" { tenantID := ctx.Value(types.TenantIDContextKey).(uint64) enableMultimodel := reparseEff.EnableMultimodel enableQuestionGeneration := reparseEff.QuestionGenerationConfig.Enabled questionCount := reparseEff.QuestionGenerationConfig.QuestionCount if questionCount <= 0 { questionCount = 3 } lang := types.LanguageFromContextOrDefault(ctx) taskPayload := types.DocumentProcessPayload{ TenantID: tenantID, KnowledgeID: existing.ID, KnowledgeBaseID: existing.KnowledgeBaseID, FileURL: existing.Source, FileName: existing.FileName, FileType: existing.FileType, EnableMultimodel: enableMultimodel, EnableQuestionGeneration: enableQuestionGeneration, QuestionCount: questionCount, Language: lang, Attempt: reparseAttempt, } langfuse.InjectTracing(ctx, &taskPayload) payloadBytes, err := json.Marshal(taskPayload) if err != nil { logger.Errorf(ctx, "Failed to marshal file URL reparse task payload: %v", err) s.markKnowledgeEnqueueFailed(ctx, existing) return existing, werrors.NewInternalServerError("Failed to submit processing task") } task := asynq.NewTask( types.TypeDocumentProcess, payloadBytes, ) info, err := s.task.Enqueue(task, documentProcessTaskOptions(s.config)...) if err != nil { logger.Errorf(ctx, "Failed to enqueue file URL reparse task: %v", err) s.markKnowledgeEnqueueFailed(ctx, existing) return existing, werrors.NewInternalServerError("Failed to submit processing task") } logger.Infof(ctx, "Enqueued file URL reparse task: id=%s queue=%s knowledge_id=%s", info.ID, info.Queue, existing.ID) recordReparseStarted() enqueueDataTableSummaryIfNeeded(ctx, s.task, tenantID, existing.ID, existing.FileName, existing.FileType, kb.SummaryModelID, kb.EmbeddingModelID) return existing, nil } // For URL-based knowledge, enqueue URL processing task if existing.Type == "url" && existing.Source != "" { tenantID := ctx.Value(types.TenantIDContextKey).(uint64) enableMultimodel := reparseEff.EnableMultimodel enableQuestionGeneration := reparseEff.QuestionGenerationConfig.Enabled questionCount := reparseEff.QuestionGenerationConfig.QuestionCount if questionCount <= 0 { questionCount = 3 } lang := types.LanguageFromContextOrDefault(ctx) taskPayload := types.DocumentProcessPayload{ TenantID: tenantID, KnowledgeID: existing.ID, KnowledgeBaseID: existing.KnowledgeBaseID, URL: existing.Source, EnableMultimodel: enableMultimodel, EnableQuestionGeneration: enableQuestionGeneration, QuestionCount: questionCount, Language: lang, Attempt: reparseAttempt, } langfuse.InjectTracing(ctx, &taskPayload) payloadBytes, err := json.Marshal(taskPayload) if err != nil { logger.Errorf(ctx, "Failed to marshal URL reparse task payload: %v", err) s.markKnowledgeEnqueueFailed(ctx, existing) return existing, werrors.NewInternalServerError("Failed to submit processing task") } task := asynq.NewTask( types.TypeDocumentProcess, payloadBytes, ) info, err := s.task.Enqueue(task, documentProcessTaskOptions(s.config, asynq.MaxRetry(3))...) if err != nil { logger.Errorf(ctx, "Failed to enqueue URL reparse task: %v", err) s.markKnowledgeEnqueueFailed(ctx, existing) return existing, werrors.NewInternalServerError("Failed to submit processing task") } logger.Infof(ctx, "Enqueued URL reparse task: id=%s queue=%s knowledge_id=%s", info.ID, info.Queue, existing.ID) recordReparseStarted() return existing, nil } logger.Warnf(ctx, "Knowledge %s has no parseable content (no file, URL, or manual content)", knowledgeID) existing.ParseStatus = types.ParseStatusFailed existing.ErrorMessage = "Knowledge has no parseable content" if err := s.repo.UpdateKnowledge(ctx, existing); err != nil { logger.Errorf(ctx, "Failed to persist unparseable knowledge state for %s: %v", secutils.SanitizeForLog(knowledgeID), err) } return existing, werrors.NewBadRequestError("Knowledge has no parseable content") } // resetKnowledgeForReparse makes the top-level knowledge state describe the // new processing attempt rather than retaining terminal state from the // previous one. func resetKnowledgeForReparse(knowledge *types.Knowledge, kb *types.KnowledgeBase) { knowledge.ParseStatus = types.ParseStatusPending knowledge.EnableStatus = "disabled" knowledge.Description = "" knowledge.ProcessedAt = nil knowledge.ErrorMessage = "" knowledge.EmbeddingModelID = kb.EmbeddingModelID // UpdateKnowledge deliberately omits pending_subtasks_count, so callers // must still persist this reset through an explicit column update. knowledge.PendingSubtasksCount = 0 } // CancelKnowledgeParse marks an in-progress parse as cancelled by the user. // // Semantics (kept aligned with the existing deleting path, but partial work // is preserved instead of cleaned up): // - parse_status is set to "cancelled"; partial chunks/index already written // to the database remain on disk. The user can re-trigger parsing via the // existing ReparseKnowledge API, which overwrites status back to pending. // - Any in-flight worker reads the new status at its next checkpoint and // bails (see processChunks / ProcessDocument / downstream handlers). // - The asynq inspector (if available) dequeues pending / scheduled / retry // tasks for this knowledge_id across all queues used by the pipeline // and signals active workers to stop. Lite mode (no Redis) skips the // dequeue step — the checkpoint-based abort is the only stop signal there. // - Idempotent: re-calling on an already-cancelled row is a no-op. // // Errors: // - ParseStatusCompleted / ParseStatusFailed: the parse has already finished. // - ParseStatusDeleting: a delete is in progress; cancel cannot supersede it. func (s *knowledgeService) CancelKnowledgeParse( ctx context.Context, knowledgeID string, ) (*types.Knowledge, error) { tenantID := ctx.Value(types.TenantIDContextKey).(uint64) existing, err := s.repo.GetKnowledgeByID(ctx, tenantID, knowledgeID) if err != nil { logger.Errorf(ctx, "CancelKnowledgeParse: failed to load knowledge: %v", err) return nil, err } if existing == nil { return nil, werrors.NewNotFoundError("knowledge not found") } switch existing.ParseStatus { case types.ParseStatusCancelled: // Idempotent — still attempt the dequeue in case earlier calls // raced an enqueue, but skip the row update / span close path. s.dequeueKnowledgeTasks(ctx, knowledgeID) return existing, nil case types.ParseStatusCompleted, types.ParseStatusFailed: return nil, werrors.NewBadRequestError("解析已结束,无法取消") case types.ParseStatusDeleting: return nil, werrors.NewBadRequestError("知识正在删除中,无法取消解析") case types.ParseStatusPending, types.ParseStatusProcessing, types.ParseStatusFinalizing: // Cancellable. `finalizing` is the post-process fan-out window // where graph-extract / summary / question subtasks are still // running; cancel here stops the LLM cost they would burn. default: // Unknown status — let it through but log. Should never happen // outside test fixtures or hand-edited rows. logger.Warnf(ctx, "CancelKnowledgeParse: unexpected status %q for %s, proceeding", existing.ParseStatus, knowledgeID) } // Flip the row to cancelled and zero the enrichment counter in one // update so a late subtask FinalizeSubtask call can't race-promote // the row back to completed. Persisted partial data is left in // place — the user can reuse it on the next reparse attempt. now := time.Now() if err := s.repo.UpdateKnowledgeColumns(ctx, existing.ID, map[string]interface{}{ "parse_status": types.ParseStatusCancelled, "error_message": "用户已取消解析", "pending_subtasks_count": 0, "updated_at": now, }); err != nil { logger.Errorf(ctx, "CancelKnowledgeParse: failed to mark knowledge cancelled: %v", err) return nil, err } existing.ParseStatus = types.ParseStatusCancelled existing.ErrorMessage = "用户已取消解析" existing.PendingSubtasksCount = 0 existing.UpdatedAt = now logger.Infof(ctx, "Knowledge %s marked as cancelled by user", knowledgeID) // Close the active attempt span tree so the UI stops showing "进行中" // for the cancelled run. AbortAttempt cascade-cancels every still- // running descendant (multimodal per-image, postprocess subtasks, // graph chunks) BEFORE closing the root, otherwise the trace // viewer would leave those striped/running bars hanging forever // because workers exit via their abort-guard without ever calling // FailSpan on their own subspan. Best-effort: nil tracker / missing // attempt no-ops. if attempt := s.tracker().LatestAttempt(ctx, knowledgeID); attempt > 0 { s.tracker().AbortAttempt(ctx, knowledgeID, attempt, "USER_CANCELLED", "用户已取消解析", "用户已取消解析") } // Best-effort dequeue. Failures here don't block the cancel — the // downstream tasks will still self-abort at their entry guards. s.dequeueKnowledgeTasks(ctx, knowledgeID) // Wiki ingest lives in its own per-KB pending queue (task_pending_ops) // rather than asynq, so dequeueKnowledgeTasks above can't see it. // Mirror the deletion path's scrub so a cancelled knowledge doesn't // get picked up by the next 30s batch and burn a wiki LLM call on a // doc the user already abandoned. The in-flight worker would skip it // at isWikiKnowledgeAborted anyway, but scrubbing avoids waking the // batch in the first place. s.scrubWikiPendingIngest(ctx, existing.KnowledgeBaseID, knowledgeID, "cancel") recordKBActivity(ctx, s.audit, tenantID, existing.KnowledgeBaseID, types.AuditActionKnowledgeParseCanceled, "knowledge", existing.ID, types.AuditOutcomeCanceled, map[string]any{"title": existing.Title, "type": existing.Type}) return existing, nil } // dequeueKnowledgeTasks asks the task inspector to remove any queued // tasks for this knowledge and signal active workers to stop. Safe to // call when the inspector is a no-op (Lite mode). func (s *knowledgeService) dequeueKnowledgeTasks(ctx context.Context, knowledgeID string) { if s.taskInspector == nil { return } if _, _, err := s.taskInspector.CancelTasksForKnowledge(ctx, knowledgeID); err != nil { logger.Warnf(ctx, "CancelKnowledgeParse: dequeue best-effort failed for %s: %v", knowledgeID, err) } } func (s *knowledgeService) updateChunkVector(ctx context.Context, kbID string, chunks []*types.Chunk) error { // Get embedding model from knowledge base sourceKB, err := s.kbService.GetKnowledgeBaseByID(ctx, kbID) if err != nil { return err } if !sourceKB.NeedsEmbeddingModel() { return nil } embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, sourceKB.EmbeddingModelID) if err != nil { return err } // Initialize composite retrieve engine from tenant configuration indexInfo := make([]*types.IndexInfo, 0, len(chunks)) ids := make([]string, 0, len(chunks)) knowledgeCache := make(map[string]*types.Knowledge) for _, chunk := range chunks { if chunk.KnowledgeBaseID != kbID { logger.Warnf(ctx, "Knowledge base ID mismatch: %s != %s", chunk.KnowledgeBaseID, kbID) continue } ids = append(ids, chunk.ID) if !chunk.IsEnabled || chunk.ChunkType == types.ChunkTypeParentText { continue } knowledge := knowledgeCache[chunk.KnowledgeID] if knowledge == nil { knowledge, err = s.repo.GetKnowledgeByID(ctx, chunk.TenantID, chunk.KnowledgeID) if err != nil { return err } knowledgeCache[chunk.KnowledgeID] = knowledge } indexInfo = append(indexInfo, &types.IndexInfo{ Content: buildKnowledgeIndexContent(knowledge, chunk.EmbeddingContent()), SourceID: chunk.ID, SourceType: types.ChunkSourceType, ChunkID: chunk.ID, KnowledgeID: chunk.KnowledgeID, KnowledgeBaseID: chunk.KnowledgeBaseID, KnowledgeType: sourceKB.Type, IsEnabled: chunk.IsEnabled, }) meta, metaErr := chunk.DocumentMetadata() if metaErr != nil { return metaErr } if meta != nil { for _, q := range meta.GeneratedQuestions { if strings.TrimSpace(q.Question) != "" { indexInfo = append(indexInfo, &types.IndexInfo{ Content: buildKnowledgeIndexContent(knowledge, q.Question), SourceID: types.GeneratedQuestionSourceID(chunk.ID, q.ID), SourceType: types.ChunkSourceType, ChunkID: chunk.ID, KnowledgeID: chunk.KnowledgeID, KnowledgeBaseID: chunk.KnowledgeBaseID, KnowledgeType: sourceKB.Type, IsEnabled: true, }) } } } } retrieveEngine, err := retriever.CreateRetrieveEngineForKB( ctx, s.retrieveEngine, s.ownership, types.MustTenantIDFromContext(ctx), sourceKB.VectorStoreID) if err != nil { return err } // Delete old vector representation of the chunk err = retrieveEngine.DeleteByChunkIDList(ctx, ids, embeddingModel.GetDimensions(), sourceKB.Type) if err != nil { return err } // Index updated chunk content with new vector representation err = retrieveEngine.BatchIndex(ctx, embeddingModel, indexInfo) if err != nil { return err } return nil } func (s *knowledgeService) UpdateImageInfo( ctx context.Context, knowledgeID string, chunkID string, imageInfo string, ) error { imageInfo = common.CleanInvalidUTF8(imageInfo) var images []*types.ImageInfo if err := json.Unmarshal([]byte(imageInfo), &images); err != nil { logger.Errorf(ctx, "Failed to unmarshal image info: %v", err) return err } if len(images) != 1 { logger.Warnf(ctx, "Expected exactly one image info, got %d", len(images)) return nil } image := images[0] // Retrieve all chunks with the given parent chunk ID chunk, err := s.chunkService.GetChunkByID(ctx, chunkID) if err != nil { logger.Errorf(ctx, "Failed to get chunk: %v", err) return err } chunk.ImageInfo = imageInfo tenantID := ctx.Value(types.TenantIDContextKey).(uint64) chunkChildren, err := s.chunkService.ListChunkByParentID(ctx, tenantID, chunkID) if err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "parent_chunk_id": chunkID, "tenant_id": tenantID, }) return err } logger.Infof(ctx, "Found %d chunks with parent chunk ID: %s", len(chunkChildren), chunkID) // Iterate through each chunk and update its content based on the image information updateChunk := []*types.Chunk{chunk} var addChunk []*types.Chunk // Track whether we've found OCR and caption child chunks for this image hasOCRChunk := false hasCaptionChunk := false for i, child := range chunkChildren { // Skip chunks that are not image types var cImageInfo []*types.ImageInfo err = json.Unmarshal([]byte(child.ImageInfo), &cImageInfo) if err != nil { logger.Warnf(ctx, "Failed to unmarshal image %s info: %v", child.ID, err) continue } if len(cImageInfo) == 0 { continue } if cImageInfo[0].OriginalURL != image.OriginalURL { logger.Warnf(ctx, "Skipping chunk ID: %s, image URL mismatch: %s != %s", child.ID, cImageInfo[0].OriginalURL, image.OriginalURL) continue } // Mark that we've found chunks for this image switch child.ChunkType { case types.ChunkTypeImageCaption: hasCaptionChunk = true // Update caption if it has changed if image.Caption != cImageInfo[0].Caption { child.Content = image.Caption child.ImageInfo = imageInfo updateChunk = append(updateChunk, chunkChildren[i]) } case types.ChunkTypeImageOCR: hasOCRChunk = true // Update OCR if it has changed if image.OCRText != cImageInfo[0].OCRText { child.Content = image.OCRText child.ImageInfo = imageInfo updateChunk = append(updateChunk, chunkChildren[i]) } } } // Create a new caption chunk if it doesn't exist and we have caption data if !hasCaptionChunk && image.Caption != "" { captionChunk := &types.Chunk{ ID: uuid.New().String(), TenantID: tenantID, KnowledgeID: chunk.KnowledgeID, KnowledgeBaseID: chunk.KnowledgeBaseID, Content: image.Caption, ChunkType: types.ChunkTypeImageCaption, ParentChunkID: chunk.ID, ImageInfo: imageInfo, // CreateChunks inserts with Select("*"), so the gorm default:true never // applies -- an unset IsEnabled lands in the database as false and the // chunk is silently excluded from retrieval and model context. IsEnabled: true, } addChunk = append(addChunk, captionChunk) logger.Infof(ctx, "Created new caption chunk ID: %s for image URL: %s", captionChunk.ID, image.OriginalURL) } // Create a new OCR chunk if it doesn't exist and we have OCR data if !hasOCRChunk && image.OCRText != "" { ocrChunk := &types.Chunk{ ID: uuid.New().String(), TenantID: tenantID, KnowledgeID: chunk.KnowledgeID, KnowledgeBaseID: chunk.KnowledgeBaseID, Content: image.OCRText, ChunkType: types.ChunkTypeImageOCR, ParentChunkID: chunk.ID, ImageInfo: imageInfo, IsEnabled: true, } addChunk = append(addChunk, ocrChunk) logger.Infof(ctx, "Created new OCR chunk ID: %s for image URL: %s", ocrChunk.ID, image.OriginalURL) } logger.Infof(ctx, "Updated %d chunks out of %d total chunks", len(updateChunk), len(chunkChildren)+1) if len(addChunk) > 0 { err := s.chunkService.CreateChunks(ctx, addChunk) if err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "add_chunk_size": len(addChunk), }) return err } } // Update the chunks for _, c := range updateChunk { err := s.chunkService.UpdateChunk(ctx, c) if err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "chunk_id": c.ID, "knowledge_id": c.KnowledgeID, }) return err } } // Update the chunk vector err = s.updateChunkVector(ctx, chunk.KnowledgeBaseID, append(updateChunk, addChunk...)) if err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "chunk_id": chunk.ID, "knowledge_id": chunk.KnowledgeID, }) return err } // Update the knowledge file hash knowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, knowledgeID) if err != nil { logger.Errorf(ctx, "Failed to get knowledge: %v", err) return err } fileHash := calculateStr(knowledgeID, knowledge.FileHash, imageInfo) knowledge.FileHash = fileHash err = s.repo.UpdateKnowledge(ctx, knowledge) if err != nil { logger.Warnf(ctx, "Failed to update knowledge file hash: %v", err) } logger.Infof(ctx, "Updated chunk successfully, chunk ID: %s, knowledge ID: %s", chunk.ID, chunk.KnowledgeID) return nil } // ProcessManualUpdate handles Asynq manual knowledge update tasks. // It performs cleanup of old indexes/chunks (when NeedCleanup is true) and re-indexes the content. func (s *knowledgeService) ProcessManualUpdate(ctx context.Context, t *asynq.Task) error { var payload types.ManualProcessPayload if err := json.Unmarshal(t.Payload(), &payload); err != nil { logger.Errorf(ctx, "failed to unmarshal manual process task payload: %v", err) return nil } ctx = logger.WithRequestID(ctx, payload.RequestId) ctx = logger.WithField(ctx, "manual_process", payload.KnowledgeID) ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) tenantInfo, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID) if err != nil { logger.Errorf(ctx, "ProcessManualUpdate: failed to get tenant: %v", err) return nil } ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenantInfo) knowledge, err := s.repo.GetKnowledgeByID(ctx, payload.TenantID, payload.KnowledgeID) if err != nil { logger.Errorf(ctx, "ProcessManualUpdate: failed to get knowledge: %v", err) return nil } if knowledge == nil { logger.Warnf(ctx, "ProcessManualUpdate: knowledge not found: %s", payload.KnowledgeID) return nil } // Skip if already completed or being deleted if knowledge.ParseStatus == types.ParseStatusCompleted { logger.Infof(ctx, "ProcessManualUpdate: already completed, skipping: %s", payload.KnowledgeID) return nil } if knowledge.ParseStatus == types.ParseStatusDeleting { logger.Infof(ctx, "ProcessManualUpdate: being deleted, skipping: %s", payload.KnowledgeID) return nil } if knowledge.ParseStatus == types.ParseStatusCancelled { logger.Infof(ctx, "ProcessManualUpdate: cancelled by user, skipping: %s", payload.KnowledgeID) return nil } kb, err := s.kbService.GetKnowledgeBaseByID(ctx, payload.KnowledgeBaseID) if err != nil { logger.Errorf(ctx, "ProcessManualUpdate: failed to get knowledge base: %v", err) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = fmt.Sprintf("failed to get knowledge base: %v", err) knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } // Re-check abort status right before marking processing — see the same // note in ProcessDocument for the cancel race this guards. if aborted, status := s.isKnowledgeAborted(ctx, knowledge.TenantID, knowledge.ID); aborted { logger.Infof(ctx, "ProcessManualUpdate: knowledge aborted (%s), skipping: %s", status, knowledge.ID) return nil } // Update status to processing markKnowledgeProcessing(knowledge, time.Now()) if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { logger.Errorf(ctx, "ProcessManualUpdate: failed to update status to processing: %v", err) return nil } // Allocate a fresh span-tracking attempt for this manual (re)index. // Without it attemptFromCtx stays 0, so processChunks drops all stage // spans and KnowledgePostProcess falls back to LatestAttempt — piling // this run's summary/wiki subspans onto the previous attempt's trace. attempt := 0 if root, n, err := s.tracker().OpenAttempt(ctx, knowledge.ID, payload.LangfuseTraceID); err == nil && root != nil { attempt = n } else if err != nil { logger.Warnf(ctx, "ProcessManualUpdate: OpenAttempt failed for %s: %v", knowledge.ID, err) } ctx = withAttempt(ctx, attempt) // Cleanup old resources (indexes, chunks, graph) for update operations if payload.NeedCleanup { if err := s.cleanupKnowledgeResources(ctx, knowledge); err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "knowledge_id": payload.KnowledgeID, }) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = fmt.Sprintf("failed to cleanup old resources: %v", err) knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } } // Run manual processing (image resolution + chunking + embedding) synchronously within the worker s.triggerManualProcessing(ctx, kb, knowledge, payload.Content, true) return nil } // ProcessDocument handles Asynq document processing tasks func (s *knowledgeService) ProcessDocument(ctx context.Context, t *asynq.Task) error { var payload types.DocumentProcessPayload if err := json.Unmarshal(t.Payload(), &payload); err != nil { logger.Errorf(ctx, "failed to unmarshal document process task payload: %v", err) return nil } ctx = logger.WithRequestID(ctx, payload.RequestId) ctx = logger.WithField(ctx, "document_process", payload.KnowledgeID) ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) if payload.Language != "" { ctx = context.WithValue(ctx, types.LanguageContextKey, payload.Language) } // 获取任务重试信息,用于判断是否是最后一次重试 retryCount, retryOK := asynq.GetRetryCount(ctx) maxRetry, maxRetryOK := asynq.GetMaxRetry(ctx) if !retryOK || !maxRetryOK { if retried, maximum, ok := types.TaskRetryMetadataFromContext(ctx); ok { retryCount, maxRetry = retried, maximum } } isLastRetry := retryCount >= maxRetry tenantInfo, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID) if err != nil { logger.Errorf(ctx, "failed to get tenant: %v", err) return nil } ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenantInfo) logger.Infof(ctx, "Processing document task: knowledge_id=%s, file_path=%s, retry=%d/%d", payload.KnowledgeID, payload.FilePath, retryCount, maxRetry) // 幂等性检查:获取knowledge记录 knowledge, err := s.repo.GetKnowledgeByID(ctx, payload.TenantID, payload.KnowledgeID) if err != nil { logger.Errorf(ctx, "failed to get knowledge: %v", err) return nil } if knowledge == nil { return nil } // 检查是否正在删除 / 已被用户取消 - 如果是则直接退出 if knowledge.ParseStatus == types.ParseStatusDeleting { logger.Infof(ctx, "Knowledge is being deleted, aborting processing: %s", payload.KnowledgeID) return nil } if knowledge.ParseStatus == types.ParseStatusCancelled { logger.Infof(ctx, "Knowledge cancelled by user, aborting processing: %s", payload.KnowledgeID) return nil } // 检查任务状态 - 幂等性处理 if knowledge.ParseStatus == types.ParseStatusCompleted { logger.Infof(ctx, "Document already completed, skipping: %s", payload.KnowledgeID) return nil // 幂等:已完成的任务直接返回 } if knowledge.ParseStatus == types.ParseStatusFailed { // 检查是否可恢复(例如:超时、临时错误等) // 对于不可恢复的错误,直接返回 logger.Warnf( ctx, "Document processing previously failed: %s, error: %s", payload.KnowledgeID, knowledge.ErrorMessage, ) // 这里可以根据错误类型判断是否可恢复,暂时允许重试 } // 检查是否有部分处理(有chunks但状态不是completed) if knowledge.ParseStatus != "completed" && knowledge.ParseStatus != "pending" && knowledge.ParseStatus != "processing" { // 状态异常,记录日志但继续处理 logger.Warnf(ctx, "Unexpected parse status: %s for knowledge: %s", knowledge.ParseStatus, payload.KnowledgeID) } // 获取知识库信息 kb, err := s.kbService.GetKnowledgeBaseByID(ctx, payload.KnowledgeBaseID) if err != nil { logger.Errorf(ctx, "failed to get knowledge base: %v", err) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = fmt.Sprintf("failed to get knowledge base: %v", err) knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } processOverrides, _ := knowledge.ProcessOverrides() eff := ResolveProcessConfig(kb, processOverrides) // Re-check abort status right before flipping to "processing" — closes // the race where the user cancels between the entry guard above and // this write (otherwise the worker would overwrite cancelled→processing // and downstream checkpoints would treat the run as live). if aborted, status := s.isKnowledgeAborted(ctx, knowledge.TenantID, knowledge.ID); aborted { logger.Infof(ctx, "Knowledge aborted (%s) before marking processing: %s", status, knowledge.ID) return nil } markKnowledgeProcessing(knowledge, time.Now()) if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { logger.Errorf(ctx, "failed to update knowledge status to processing: %v", err) return nil } // Resolve the attempt for span tracking. The enqueue site sets // payload.Attempt to a fresh number for the initial parse and to // max+1 for each user-initiated reparse. Asynq retries within a // single user action keep the same payload (so retries record // onto the same attempt). For payloads predating this code we // fall back to OpenAttempt. attempt := payload.Attempt if attempt <= 0 { if root, n, err := s.tracker().OpenAttempt(ctx, knowledge.ID, payload.LangfuseTraceID); err == nil && root != nil { attempt = n } } ctx = withAttempt(ctx, attempt) // 检查多模态配置(仅对文件导入) if payload.FilePath != "" && !payload.EnableMultimodel && IsImageType(payload.FileType) { logger.GetLogger(ctx).WithField("knowledge_id", knowledge.ID). WithField("error", ErrImageNotParse).Errorf("processDocument image without enable multimodel") knowledge.ParseStatus = "failed" knowledge.ErrorMessage = ErrImageNotParse.Error() knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } // 检查音频ASR配置(仅对文件导入) if payload.FilePath != "" && IsAudioType(payload.FileType) && !eff.ASRConfig.IsASREnabled() { logger.GetLogger(ctx).WithField("knowledge_id", knowledge.ID). Errorf("processDocument audio without ASR model configured") knowledge.ParseStatus = "failed" knowledge.ErrorMessage = "上传音频文件需要设置ASR语音识别模型" knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } // 视频文件不再支持入库解析 if payload.FilePath != "" && IsVideoType(payload.FileType) { logger.GetLogger(ctx).WithField("knowledge_id", knowledge.ID). Errorf("processDocument video not supported") knowledge.ParseStatus = "failed" knowledge.ErrorMessage = "暂不支持视频文件" knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } // New pipeline: convert -> store images -> chunk -> vectorize -> multimodal tasks var convertResult *types.ReadResult var chunks []types.ParsedChunk if payload.FileURL != "" { // file_url import: SSRF re-check (防 DNS 重绑定), download, persist, then delegate to convert() if err := secutils.ValidateURLForSSRF(payload.FileURL); err != nil { logger.Errorf(ctx, "File URL rejected for SSRF protection in ProcessDocument: %s, err: %v", payload.FileURL, err) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = "File URL is not allowed for security reasons" knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } resolvedFileName := payload.FileName resolvedFileType := payload.FileType contentBytes, err := downloadFileFromURL(ctx, payload.FileURL, &resolvedFileName, &resolvedFileType) if err != nil { logger.Errorf(ctx, "Failed to download file from URL: %s, error: %v", payload.FileURL, err) if isLastRetry { knowledge.ParseStatus = "failed" knowledge.ErrorMessage = err.Error() knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) } return fmt.Errorf("failed to download file from URL: %w", err) } if resolvedFileType != "" && !isSupportedImportExtension(resolvedFileType) { logger.Errorf(ctx, "Unsupported file type resolved from file URL: %s", resolvedFileType) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = fmt.Sprintf("unsupported file type: %s", resolvedFileType) knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } if resolvedFileName != "" && knowledge.FileName == "" { knowledge.FileName = resolvedFileName } if resolvedFileType != "" && knowledge.FileType == "" { knowledge.FileType = resolvedFileType s.repo.UpdateKnowledge(ctx, knowledge) } fileSvc := s.resolveFileService(ctx, kb) filePath, err := fileSvc.SaveBytes(ctx, contentBytes, payload.TenantID, resolvedFileName, true) if err != nil { if isLastRetry { knowledge.ParseStatus = "failed" knowledge.ErrorMessage = err.Error() knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) } return fmt.Errorf("failed to save downloaded file: %w", err) } payload.FilePath = filePath payload.FileName = resolvedFileName payload.FileType = resolvedFileType convertResult, err = s.convert(ctx, payload, kb, knowledge, eff, isLastRetry) if err != nil { return err } if convertResult == nil { return nil } } else if payload.URL != "" { // URL import convertResult, err = s.convert(ctx, payload, kb, knowledge, eff, isLastRetry) if err != nil { return err } if convertResult == nil { return nil } // Update knowledge title from extracted page title if not already set if knowledge.Title == "" || knowledge.Title == payload.URL { if extractedTitle := convertResult.Metadata["title"]; extractedTitle != "" { knowledge.Title = extractedTitle knowledge.UpdatedAt = time.Now() if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { logger.Warnf(ctx, "Failed to update knowledge title from extracted page title: %v", err) } else { logger.Infof(ctx, "Updated knowledge title to extracted page title: %s", extractedTitle) } } } } else if len(payload.Passages) > 0 { // Text passage import - direct chunking, no conversion needed passageChunks := make([]types.ParsedChunk, 0, len(payload.Passages)) start, end := 0, 0 for i, p := range payload.Passages { if p == "" { continue } end += len([]rune(p)) passageChunks = append(passageChunks, types.ParsedChunk{ Content: p, Seq: i, Start: start, End: end, }) start = end } passageOpts := ProcessChunksOptions{ EnableQuestionGeneration: payload.EnableQuestionGeneration, QuestionCount: payload.QuestionCount, } s.processChunks(ctx, kb, knowledge, passageChunks, passageOpts) return nil } else { // File import convertResult, err = s.convert(ctx, payload, kb, knowledge, eff, isLastRetry) if err != nil { return err } if convertResult == nil { return nil } } // Step 1.5: ASR transcription for audio files if convertResult != nil && convertResult.IsAudio && len(convertResult.AudioData) > 0 { if !eff.ASRConfig.IsASREnabled() { logger.Error(ctx, "Audio file detected but ASR is not configured") knowledge.ParseStatus = "failed" knowledge.ErrorMessage = "ASR model is not configured for audio transcription" knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } logger.Infof(ctx, "[ASR] Starting audio transcription for knowledge %s, audio size=%d bytes", knowledge.ID, len(convertResult.AudioData)) asrModel, err := s.modelService.GetASRModel(ctx, eff.ASRConfig.ModelID) if err != nil { logger.Errorf(ctx, "[ASR] Failed to get ASR model: %v", err) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = fmt.Sprintf("failed to get ASR model: %v", err) knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) return nil } transcriptionResult, err := asrModel.Transcribe(ctx, convertResult.AudioData, knowledge.FileName) if err != nil { logger.Errorf(ctx, "[ASR] Transcription failed: %v", err) if isLastRetry { knowledge.ParseStatus = "failed" knowledge.ErrorMessage = fmt.Sprintf("audio transcription failed: %v", err) knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) } return fmt.Errorf("audio transcription failed: %w", err) } var transcribedText string if transcriptionResult != nil { transcribedText = transcriptionResult.Text } if transcribedText == "" { logger.Warn(ctx, "[ASR] Transcription returned empty text") transcribedText = "[No speech detected in audio file]" } logger.Infof(ctx, "[ASR] Transcription completed, text length=%d", len(transcribedText)) // Replace the audio placeholder with the transcribed text convertResult.MarkdownContent = transcribedText convertResult.IsAudio = false convertResult.AudioData = nil } // Step 2: Store images and update markdown references var storedImages []docparser.StoredImage if s.imageResolver != nil && convertResult != nil { fileSvc := s.resolveFileService(ctx, kb) tenantID, _ := ctx.Value(types.TenantIDContextKey).(uint64) updatedMarkdown, images, resolveErr := s.imageResolver.ResolveAndStore(ctx, convertResult, fileSvc, tenantID) if resolveErr != nil { logger.Warnf(ctx, "Image resolution partially failed: %v", resolveErr) } if updatedMarkdown != "" { convertResult.MarkdownContent = updatedMarkdown } storedImages = images // Resolve remote http(s) images (e.g. markdown external URLs) → download + upload to storage. // ResolveAndStore handles inline bytes and base64; ResolveRemoteImages handles http/https URLs. updatedContent, remoteImages, remoteErr := s.imageResolver.ResolveRemoteImages(ctx, convertResult.MarkdownContent, fileSvc, tenantID) if remoteErr != nil { logger.Warnf(ctx, "Remote image resolution partially failed: %v", remoteErr) } if len(remoteImages) > 0 { logger.Infof(ctx, "Resolved %d remote images for knowledge %s", len(remoteImages), knowledge.ID) convertResult.MarkdownContent = updatedContent storedImages = append(storedImages, remoteImages...) } logger.Infof(ctx, "Resolved %d total images for knowledge %s", len(storedImages), knowledge.ID) } // Step 3: Split into chunks using Go chunker. Browser textareas normalize // pasted content to LF, so normalize uploaded source text before calculating // chunk boundaries as well. sanitizeReadResult(convertResult) convertResult.MarkdownContent = chunker.NormalizeLineEndings(convertResult.MarkdownContent) chunkCfg := buildSplitterConfigFromChunking(eff.ChunkingConfig) processOpts := ProcessChunksOptions{ EnableQuestionGeneration: payload.EnableQuestionGeneration, QuestionCount: payload.QuestionCount, EnableMultimodel: payload.EnableMultimodel, StoredImages: storedImages, } if convertResult != nil { processOpts.Metadata = convertResult.Metadata } if eff.ChunkingConfig.EnableParentChild { parentCfg, childCfg := buildParentChildConfigs(eff.ChunkingConfig, chunkCfg) pcResult := chunker.SplitParentChild(convertResult.MarkdownContent, parentCfg, childCfg) chunks = make([]types.ParsedChunk, len(pcResult.Children)) for i, c := range pcResult.Children { chunks[i] = types.ParsedChunk{ Content: c.Content, ContextHeader: c.ContextHeader, Seq: c.Seq, Start: c.Start, End: c.End, ParentIndex: c.ParentIndex, } } parentChunks := make([]types.ParsedParentChunk, len(pcResult.Parents)) for i, p := range pcResult.Parents { parentChunks[i] = types.ParsedParentChunk{Content: p.Content, Seq: p.Seq, Start: p.Start, End: p.End} } processOpts.ParentChunks = parentChunks logger.Infof(ctx, "Split document into %d parent + %d child chunks for knowledge %s", len(pcResult.Parents), len(pcResult.Children), knowledge.ID) } else { splitChunks := chunker.Split(convertResult.MarkdownContent, chunkCfg) chunks = make([]types.ParsedChunk, len(splitChunks)) for i, c := range splitChunks { chunks[i] = types.ParsedChunk{ Content: c.Content, ContextHeader: c.ContextHeader, Seq: c.Seq, Start: c.Start, End: c.End, } } logger.Infof(ctx, "Split document into %d chunks for knowledge %s", len(chunks), knowledge.ID) } // Step 4: Process chunks (vectorize + index + enqueue async tasks) s.processChunks(ctx, kb, knowledge, chunks, processOpts) return nil } // sanitizeReadResult protects every text field that can cross from a parser // into the embedding, storage, or tracing layers. A parser may return a Go // string containing arbitrary bytes even though the string type itself does // not enforce UTF-8 validity. func sanitizeReadResult(result *types.ReadResult) { if result == nil { return } result.MarkdownContent = common.CleanInvalidUTF8(result.MarkdownContent) result.ImageDirPath = common.CleanInvalidUTF8(result.ImageDirPath) result.Error = common.CleanInvalidUTF8(result.Error) for key, value := range result.Metadata { result.Metadata[key] = common.CleanInvalidUTF8(value) } for i := range result.ImageRefs { result.ImageRefs[i].Filename = common.CleanInvalidUTF8(result.ImageRefs[i].Filename) result.ImageRefs[i].OriginalRef = common.CleanInvalidUTF8(result.ImageRefs[i].OriginalRef) result.ImageRefs[i].MimeType = common.CleanInvalidUTF8(result.ImageRefs[i].MimeType) result.ImageRefs[i].StorageKey = common.CleanInvalidUTF8(result.ImageRefs[i].StorageKey) } } // convert handles both file and URL reading using a unified ReadRequest. func (s *knowledgeService) convert( ctx context.Context, payload types.DocumentProcessPayload, kb *types.KnowledgeBase, knowledge *types.Knowledge, eff types.EffectiveProcessConfig, isLastRetry bool, ) (*types.ReadResult, error) { // Stage tracking: docreader. Mark the stage as running here so the // timeline reflects "DocReader" the moment a worker picks the task // up — before that, the stage stays "pending" from the initial // upload. Failure/skip transitions are emitted at the specific // failure points below; success is emitted at the bottom. docInput := types.JSONMap{ "file_name": payload.FileName, "file_type": payload.FileType, "is_url": payload.URL != "", } if payload.URL != "" { docInput["url"] = payload.URL } s.beginStage(ctx, knowledge.ID, types.StageDocReader, docInput) isURL := payload.URL != "" fileType := payload.FileType tenantOverrides := s.getParserEngineOverridesFromContext(ctx) var uploadOverrides map[string]string if processOverrides, err := knowledge.ProcessOverrides(); err == nil && processOverrides != nil { uploadOverrides = processOverrides.ParserEngineOverrides } mergedOverrides := MergeParserEngineOverrides(tenantOverrides, uploadOverrides) applyParserRuleOverrides(mergedOverrides, eff.ChunkingConfig, fileType) if err := validateParserEngineOverrideURLs(mergedOverrides); err != nil { logger.Errorf(ctx, "Parser endpoint rejected for SSRF protection: %v", err) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = "Parser endpoint is not allowed for security reasons" knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) s.failStage(ctx, knowledge.ID, types.StageDocReader, werrors.ErrCodeDocReaderParseFailed, knowledge.ErrorMessage, err) return nil, nil } if isURL { if err := secutils.ValidateURLForSSRF(payload.URL); err != nil { logger.Errorf(ctx, "URL rejected for SSRF protection: %s, err: %v", payload.URL, err) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = "URL is not allowed for security reasons" knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) s.failStage(ctx, knowledge.ID, types.StageDocReader, werrors.ErrCodeDocReaderParseFailed, "URL rejected for security reasons", err) return nil, nil } } parserEngine := eff.ChunkingConfig.ResolveParserEngine(fileType) if isURL { parserEngine = eff.ChunkingConfig.ResolveParserEngine("url") } logger.Infof(ctx, "[convert] kb=%s fileType=%s isURL=%v engine=%q rules=%+v", kb.ID, fileType, isURL, parserEngine, eff.ChunkingConfig.ParserEngineRules) var reader interfaces.DocReader = s.resolveDocReader(ctx, parserEngine, fileType, isURL, mergedOverrides) if reader == nil { logger.Errorf(ctx, "[convert] no doc reader for kb=%s knowledge=%s fileType=%s engine=%q isURL=%v", kb.ID, knowledge.ID, fileType, parserEngine, isURL) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = "Document parsing service is not configured. Please use text/paragraph import or set DOCREADER_ADDR." knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) s.failStage(ctx, knowledge.ID, types.StageDocReader, werrors.ErrCodeDocReaderUnavailable, knowledge.ErrorMessage, nil) return nil, nil } req := &types.ReadRequest{ URL: payload.URL, Title: knowledge.Title, ParserEngine: parserEngine, RequestID: payload.RequestId, ParserEngineOverrides: mergedOverrides, } if !isURL { fileReader, err := s.resolveFileServiceForPath(ctx, kb, payload.FilePath).GetFile(ctx, payload.FilePath) if err != nil { s.failStage(ctx, knowledge.ID, types.StageDocReader, werrors.ErrCodeDocReaderParseFailed, "failed to get file", err) return s.failKnowledge(ctx, knowledge, isLastRetry, "failed to get file: %v", err) } defer fileReader.Close() contentBytes, err := io.ReadAll(fileReader) if err != nil { s.failStage(ctx, knowledge.ID, types.StageDocReader, werrors.ErrCodeDocReaderParseFailed, "failed to read file", err) return s.failKnowledge(ctx, knowledge, isLastRetry, "failed to read file: %v", err) } req.FileContent = contentBytes req.FileName = payload.FileName req.FileType = fileType } result, err := s.callDocReaderWithTimeout(ctx, reader, req) if err != nil { // Distinguish DocReader timeout (a knowable user-facing // failure) from generic read errors so the UI can suggest // "split this large file" specifically when relevant. code := werrors.ErrCodeDocReaderParseFailed if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "docreader call timeout") { code = werrors.ErrCodeDocReaderTimeout } s.failStage(ctx, knowledge.ID, types.StageDocReader, code, "document read failed", err) return s.failKnowledge(ctx, knowledge, isLastRetry, "document read failed: %v", err) } sanitizeReadResult(result) if result.Error != "" { logger.Errorf(ctx, "[convert] parser returned error kb=%s knowledge=%s file=%q type=%s engine=%q: %s", kb.ID, knowledge.ID, req.FileName, fileType, parserEngine, result.Error) knowledge.ParseStatus = "failed" knowledge.ErrorMessage = result.Error knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) s.failStage(ctx, knowledge.ID, types.StageDocReader, werrors.ErrCodeDocReaderParseFailed, result.Error, nil) return nil, nil } docOutput := types.JSONMap{ "text_length": len(result.MarkdownContent), "images_found": len(result.ImageRefs), "is_audio": result.IsAudio, } if pages := result.Metadata["pages"]; pages != "" { docOutput["pages"] = pages } s.endStage(ctx, knowledge.ID, types.StageDocReader, docOutput) return result, nil } // callDocReaderWithTimeout wraps the DocReader RPC in a child context whose // deadline is min(parent_deadline, DocReaderCallTimeout). Without this cap, // a hung docreader (network partition, GC pause, OCR runaway) silently // burns the whole DocumentProcessTimeout budget and pins a worker for hours // — the #1 cause of "knowledge stuck in processing" reports. // // On timeout we annotate the error so retries / dead-letter consumers can // distinguish "docreader was slow" from "docreader returned an error". func (s *knowledgeService) callDocReaderWithTimeout( ctx context.Context, reader interfaces.DocReader, req *types.ReadRequest, ) (*types.ReadResult, error) { timeout := 30 * time.Minute if s.config != nil && s.config.KnowledgeBase != nil && s.config.KnowledgeBase.DocReaderCallTimeout > 0 { timeout = s.config.KnowledgeBase.DocReaderCallTimeout } callCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() start := time.Now() result, err := reader.Read(callCtx, req) elapsed := time.Since(start) if err != nil { // Promote DeadlineExceeded into a clearer message; retain underlying // error via %w so errors.Is(callCtx.Err(), context.DeadlineExceeded) // still works for upstream classification. if errors.Is(callCtx.Err(), context.DeadlineExceeded) && !errors.Is(ctx.Err(), context.DeadlineExceeded) { logger.Errorf(ctx, "[convert] docreader call timed out after %s (limit %s) for %q", elapsed, timeout, req.FileName) return nil, fmt.Errorf("docreader call timeout after %s: %w", timeout, err) } return nil, err } logger.Infof(ctx, "[convert] docreader call ok in %s for %q", elapsed, req.FileName) return result, nil } // isLikelyRateLimitError performs a fuzzy classification of an error as a // rate-limit / quota / backpressure failure. We only need a hint — the // caller maps to one of two error_codes so the UI can offer "retry later" // vs. "fix configuration" advice. False positives are harmless (the // detail is preserved in error_detail anyway). func isLikelyRateLimitError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) for _, needle := range []string{"rate limit", "ratelimit", "429", "too many requests", "quota"} { if strings.Contains(msg, needle) { return true } } return false } // resolveDocReader picks the reader for one parse request. The engine catalog // itself lives in the docparser registry; this only supplies the dependencies // the service owns. Returns nil when the chosen engine cannot run — an // unconfigured cloud engine, a disconnected docreader — after logging why. func (s *knowledgeService) resolveDocReader( ctx context.Context, engine, fileType string, isURL bool, overrides map[string]string, ) interfaces.DocReader { reader, err := docparser.NewReader(ctx, engine, fileType, isURL, docparser.ReaderDeps{ Overrides: overrides, Remote: s.documentReader, WeKnoraCloudCredentials: s.tenantService.GetWeKnoraCloudCredentials, }) if err != nil { logger.Warnf(ctx, "[resolveDocReader] engine=%q fileType=%q unusable: %v", engine, fileType, err) return nil } return reader } // failKnowledge marks knowledge as failed (only on last retry) and returns an error. func (s *knowledgeService) failKnowledge( ctx context.Context, knowledge *types.Knowledge, isLastRetry bool, format string, args ...interface{}, ) (*types.ReadResult, error) { errMsg := fmt.Sprintf(format, args...) if isLastRetry { knowledge.ParseStatus = "failed" knowledge.ErrorMessage = errMsg knowledge.UpdatedAt = time.Now() s.repo.UpdateKnowledge(ctx, knowledge) } return nil, fmt.Errorf(format, args...) } // enqueueImageMultimodalTasks enqueues asynq tasks for multimodal image processing. func (s *knowledgeService) enqueueImageMultimodalTasks( ctx context.Context, knowledge *types.Knowledge, kb *types.KnowledgeBase, images []docparser.StoredImage, chunks []types.ParsedChunk, metadata map[string]string, ) { if s.task == nil || len(images) == 0 { return } attempt := attemptFromCtx(ctx) redisKey := fmt.Sprintf("multimodal:pending:%s", knowledge.ID) if s.redisClient != nil { if err := s.redisClient.Set(ctx, redisKey, len(images), 24*time.Hour).Err(); err != nil { logger.Warnf(ctx, "Failed to set multimodal pending count for %s: %v", knowledge.ID, err) } } for idx, img := range images { // Match image to the ParsedChunk whose content contains the image URL. // ChunkID was populated by processChunks with the real DB UUID. chunkID := "" for _, c := range chunks { if strings.Contains(c.Content, img.ServingURL) { chunkID = c.ChunkID break } } if chunkID == "" && len(chunks) > 0 { chunkID = chunks[0].ChunkID } lang := types.LanguageFromContextOrDefault(ctx) payload := types.ImageMultimodalPayload{ TenantID: knowledge.TenantID, KnowledgeID: knowledge.ID, KnowledgeBaseID: kb.ID, ChunkID: chunkID, ImageURL: img.ServingURL, EnableOCR: true, EnableCaption: true, Language: lang, ImageSourceType: metadata["image_source_type"], Attempt: attempt, ImageIndex: idx, } langfuse.InjectTracing(ctx, &payload) payloadBytes, err := json.Marshal(payload) if err != nil { logger.Warnf(ctx, "Failed to marshal image multimodal payload: %v", err) continue } task := asynq.NewTask(types.TypeImageMultimodal, payloadBytes, asynq.Queue(types.QueueMultimodal), asynq.MaxRetry(3), asynq.Timeout(30*time.Minute)) if _, err := s.task.Enqueue(task); err != nil { logger.Warnf(ctx, "Failed to enqueue image multimodal task for %s: %v", img.ServingURL, err) } else { logger.Infof(ctx, "Enqueued image:multimodal task for %s", img.ServingURL) } } } // ProcessKnowledgeListReparse handles Asynq knowledge list reparse tasks. func (s *knowledgeService) ProcessKnowledgeListReparse(ctx context.Context, t *asynq.Task) error { var payload types.KnowledgeListReparsePayload if err := json.Unmarshal(t.Payload(), &payload); err != nil { logger.Errorf(ctx, "Failed to unmarshal knowledge list reparse payload: %v", err) return err } ctx = payload.Initiator.Apply(ctx) taskID, _ := asynq.GetTaskID(ctx) ctx = withKBActivityTask(ctx, taskID, kbActivityTrigger(ctx)) logger.Infof(ctx, "Processing knowledge list reparse task for %d knowledge items", len(payload.KnowledgeIDs)) tenant, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID) if err != nil { logger.Errorf(ctx, "Failed to get tenant %d: %v", payload.TenantID, err) return err } ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant) outcome, err := runKnowledgeListReparseSubmissions(payload.KnowledgeIDs, func(id string) error { _, err := s.ReparseKnowledge(ctx, id, payload.ProcessConfig) return err }) logger.Infof(ctx, "Knowledge list reparse task finished: %d submitted, %d failed", outcome.Submitted, outcome.Failed) return err } type knowledgeListReparseOutcome struct { Submitted int Failed int } // runKnowledgeListReparseSubmissions attempts every item so one bad document // cannot block the remainder of the batch. A partial failure is non-retryable: // retrying the wrapper task would destructively reparse items that were already // submitted successfully. Failed rows remain selectable for an explicit retry. func runKnowledgeListReparseSubmissions( ids []string, submit func(string) error, ) (knowledgeListReparseOutcome, error) { var outcome knowledgeListReparseOutcome failures := make([]error, 0) for _, id := range ids { if err := submit(id); err != nil { failures = append(failures, fmt.Errorf("knowledge %s: %w", secutils.SanitizeForLog(id), err)) outcome.Failed++ continue } outcome.Submitted++ } if len(failures) == 0 { return outcome, nil } return outcome, fmt.Errorf( "%w: batch reparse submitted %d item(s) and failed %d: %w", asynq.SkipRetry, outcome.Submitted, outcome.Failed, errors.Join(failures...), ) }