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