Files
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

4011 lines
151 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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...),
)
}