4011 lines
151 KiB
Go
4011 lines
151 KiB
Go
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 = "<surrounding_context>\n"
|
|||
|
|
if prevContent != "" {
|
|||
|
|
contextSection += fmt.Sprintf("<preceding_content>\n%s\n\n</preceding_content>\n\n", prevContent)
|
|||
|
|
}
|
|||
|
|
if nextContent != "" {
|
|||
|
|
contextSection += fmt.Sprintf("<following_content>\n%s\n\n</following_content>\n\n", nextContent)
|
|||
|
|
}
|
|||
|
|
contextSection += "</surrounding_context>\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...),
|
|||
|
|
)
|
|||
|
|
}
|