Files
xinmi/internal/application/service/knowledge_create.go
T
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

1361 lines
51 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/url"
"path"
"strings"
"time"
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/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"
)
// CreateKnowledgeFromFile creates a knowledge entry from an uploaded file
func (s *knowledgeService) CreateKnowledgeFromFile(ctx context.Context,
kbID string, file *multipart.FileHeader, metadata map[string]string, enableMultimodel *bool, customFileName string, tagIDs []string, channel string,
processOverrides *types.KnowledgeProcessOverrides,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start creating knowledge from file")
// Use custom filename if provided, otherwise use original filename. Folder
// uploads pass a path-qualified name ("docs/spec/design.md"): the directory
// part becomes the knowledge's folder_path and only the base name is kept as
// the display / storage file name.
fileName := file.Filename
folderPath := ""
if customFileName != "" {
folderPath, fileName = types.SplitKnowledgeRelativePath(customFileName)
if fileName == "" {
fileName = file.Filename
}
logger.Infof(ctx, "Using custom filename: %s (original: %s, folder: %s)",
fileName, file.Filename, folderPath)
}
logger.Infof(ctx, "Knowledge base ID: %s, file: %s", kbID, fileName)
if IsVideoType(getFileType(fileName)) {
logger.Error(ctx, "Video file upload is not supported")
return nil, werrors.NewBadRequestError("暂不支持上传视频文件")
}
// Get knowledge base configuration
logger.Info(ctx, "Getting knowledge base configuration")
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
logger.Errorf(ctx, "Failed to get knowledge base: %v", err)
return nil, err
}
// FAQ knowledge bases should not accept file uploads — use the FAQ import API instead
if kb.Type == types.KnowledgeBaseTypeFAQ {
return nil, werrors.NewBadRequestError("FAQ 知识库不支持文件上传,请使用 FAQ 导入功能")
}
if err := s.checkStorageEngineConfigured(ctx, kb); err != nil {
return nil, err
}
// Early reject before the whole-file hash below. resolveFileImportProcessConfig
// gates the same extension set, but this path must keep returning
// ErrInvalidFileType rather than the shared gate's localized message.
logger.Infof(ctx, "Checking file type: %s", fileName)
if !isValidFileType(fileName) {
logger.Error(ctx, "Invalid file type")
return nil, ErrInvalidFileType
}
// Calculate file hash for deduplication
logger.Info(ctx, "Calculating file hash")
hash, err := calculateFileHash(file)
if err != nil {
logger.Errorf(ctx, "Failed to calculate file hash: %v", err)
return nil, err
}
// Check if file already exists
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
logger.Infof(ctx, "Checking if file exists, tenant ID: %d", tenantID)
exists, existingKnowledge, err := s.repo.CheckKnowledgeExists(ctx, tenantID, kbID, &types.KnowledgeCheckParams{
Type: "file",
FileName: fileName,
FileType: getFileType(fileName),
FileSize: file.Size,
FileHash: hash,
})
if err != nil {
logger.Errorf(ctx, "Failed to check knowledge existence: %v", err)
return nil, err
}
_, resumableImport := ctx.Value(resumableKnowledgeIDKey{}).(string)
if exists && !resumableImport {
logger.Infof(ctx, "File already exists: %s", fileName)
// Update creation time for existing knowledge
if err := s.repo.UpdateKnowledgeColumn(ctx, existingKnowledge.ID, "created_at", time.Now()); err != nil {
logger.Errorf(ctx, "Failed to update existing knowledge: %v", err)
return nil, err
}
return existingKnowledge, types.NewDuplicateFileError(existingKnowledge)
}
// Check storage quota
tenantInfo := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenantInfo.StorageQuota > 0 && tenantInfo.StorageUsed >= tenantInfo.StorageQuota {
logger.Error(ctx, "Storage quota exceeded")
return nil, types.NewStorageQuotaExceededError()
}
// Convert metadata to JSON format if provided
var metadataJSON types.JSON
if metadata != nil {
metadataBytes, err := json.Marshal(metadata)
if err != nil {
logger.Errorf(ctx, "Failed to marshal metadata: %v", err)
return nil, err
}
metadataJSON = types.JSON(metadataBytes)
}
// 验证文件名安全性
safeFilename, isValid := secutils.ValidateInput(fileName)
if !isValid {
logger.Errorf(ctx, "Invalid filename: %s", fileName)
return nil, werrors.NewValidationError("文件名包含非法字符")
}
// The folder path is rendered as sidebar tree labels, so it goes through the
// same input validation as the file name before it is stored.
if folderPath != "" {
safeFolderPath, folderValid := secutils.ValidateInput(folderPath)
if !folderValid {
logger.Errorf(ctx, "Invalid folder path: %s", folderPath)
return nil, werrors.NewValidationError("文件夹路径包含非法字符")
}
folderPath = types.NormalizeKnowledgeFolderPath(safeFolderPath)
}
eff, err := resolveFileImportProcessConfig(ctx, kb, getFileType(safeFilename), processOverrides, enableMultimodel)
if err != nil {
return nil, err
}
// Prepare knowledge record
logger.Info(ctx, "Preparing knowledge record")
knowledgeID := uuid.New().String()
if reserved, ok := ctx.Value(resumableKnowledgeIDKey{}).(string); ok && reserved != "" {
knowledgeID = reserved
}
knowledge := &types.Knowledge{
ID: knowledgeID,
TenantID: tenantID,
KnowledgeBaseID: kbID,
Type: "file",
Channel: defaultChannel(channel),
Title: safeFilename,
FileName: safeFilename,
FolderPath: folderPath,
FileType: getFileType(safeFilename),
FileSize: file.Size,
FileHash: hash,
ParseStatus: "pending",
EnableStatus: "disabled",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
EmbeddingModelID: kb.EmbeddingModelID,
Metadata: metadataJSON,
}
if processOverrides != nil {
if err := knowledge.SetProcessOverrides(processOverrides); err != nil {
logger.Errorf(ctx, "Failed to set process overrides: %v", err)
return nil, err
}
}
// Save the file to storage (use KB-level storage engine if configured)
logger.Infof(ctx, "Saving file, knowledge ID: %s", knowledge.ID)
fileSvc := s.resolveFileService(ctx, kb)
filePath, err := fileSvc.SaveFile(ctx, file, knowledge.TenantID, knowledge.ID)
if err != nil {
logger.Errorf(ctx, "Failed to save file, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
knowledge.FilePath = filePath
// Save knowledge record to database after the file is safely stored.
logger.Info(ctx, "Saving knowledge record to database")
if err := s.repo.CreateKnowledge(ctx, knowledge); err != nil {
logger.Errorf(ctx, "Failed to create knowledge record, ID: %s, error: %v", knowledge.ID, err)
// A resumed upload reserves one document primary key per byte range.
// Concurrent workers may save separate objects, but only one row wins.
if _, reserved := ctx.Value(resumableKnowledgeIDKey{}).(string); reserved {
if existing, lookupErr := s.repo.GetKnowledgeByID(ctx, tenantID, knowledge.ID); lookupErr == nil && existing.KnowledgeBaseID == kbID && existing.FileHash == hash {
if filePath != existing.FilePath {
_ = fileSvc.DeleteFile(ctx, filePath)
}
return existing, types.NewDuplicateFileError(existing)
}
}
if deleteErr := fileSvc.DeleteFile(ctx, filePath); deleteErr != nil {
logger.Errorf(ctx, "Failed to delete saved file after knowledge creation failed, path: %s, error: %v", filePath, deleteErr)
}
return nil, err
}
// Set tag relations
if err := s.setAndAttachKnowledgeTags(ctx, tenantID, kbID, knowledge, tagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
// Enqueue document processing task to Asynq
logger.Info(ctx, "Enqueuing document processing task to Asynq")
enableMultimodelValue := eff.EnableMultimodel
enableQuestionGeneration := eff.QuestionGenerationConfig.Enabled
questionCount := eff.QuestionGenerationConfig.QuestionCount
if questionCount <= 0 {
questionCount = 3
}
lang := types.LanguageFromContextOrDefault(ctx)
taskPayload := types.DocumentProcessPayload{
TenantID: tenantID,
KnowledgeID: knowledge.ID,
KnowledgeBaseID: kbID,
FilePath: filePath,
FileName: safeFilename,
FileType: getFileType(safeFilename),
EnableMultimodel: enableMultimodelValue,
EnableQuestionGeneration: enableQuestionGeneration,
QuestionCount: questionCount,
Language: lang,
}
langfuse.InjectTracing(ctx, &taskPayload)
payloadBytes, err := json.Marshal(taskPayload)
if err != nil {
logger.Errorf(ctx, "Failed to marshal document process task payload: %v", err)
s.markKnowledgeEnqueueFailed(ctx, knowledge)
recordKBActivity(ctx, s.audit, knowledge.TenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeFailed, map[string]any{
"title": knowledge.Title, "source_type": "file", "file_type": knowledge.FileType,
"processing_status": "failed", "failure_stage": "enqueue",
})
// 即使入队失败,也返回knowledge,因为文件已保存
return knowledge, nil
}
taskOptions := documentProcessTaskOptions(s.config, asynq.MaxRetry(3))
if _, reserved := ctx.Value(resumableKnowledgeIDKey{}).(string); reserved {
taskOptions = append(taskOptions, asynq.TaskID("resumable:"+knowledge.ID), asynq.Retention(24*time.Hour))
}
task := asynq.NewTask(
types.TypeDocumentProcess,
payloadBytes,
)
info, err := s.task.Enqueue(task, taskOptions...)
if resumableImport && (errors.Is(err, asynq.ErrTaskIDConflict) || errors.Is(err, asynq.ErrDuplicateTask)) {
info, err = &asynq.TaskInfo{ID: "resumable:" + knowledge.ID, Queue: types.QueueDefault}, nil
}
if err != nil {
logger.Errorf(ctx, "Failed to enqueue document process task: %v", err)
s.markKnowledgeEnqueueFailed(ctx, knowledge)
recordKBActivity(ctx, s.audit, knowledge.TenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeFailed, map[string]any{
"title": knowledge.Title, "source_type": "file", "file_type": knowledge.FileType,
"processing_status": "failed", "failure_stage": "enqueue",
})
// 即使入队失败,也返回knowledge,因为文件已保存
return knowledge, nil
}
recordKBActivity(ctx, s.audit, knowledge.TenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeAccepted, map[string]any{
"title": knowledge.Title, "source_type": "file", "file_type": knowledge.FileType,
"processing_status": "pending", "task_id": info.ID, "trigger": kbActivityTrigger(ctx),
})
logger.Infof(
ctx,
"Enqueued document process task: id=%s queue=%s knowledge_id=%s",
info.ID,
info.Queue,
knowledge.ID,
)
enqueueDataTableSummaryIfNeeded(ctx, s.task, tenantID, knowledge.ID, safeFilename, getFileType(safeFilename), kb.SummaryModelID, kb.EmbeddingModelID)
logger.Infof(ctx, "Knowledge from file created successfully, ID: %s", knowledge.ID)
return knowledge, nil
}
// CreateKnowledgeFromURL creates a knowledge entry from a URL source
// tagID is optional - when provided, the knowledge will be assigned to the specified tag/category.
// isFileURL reports whether the given URL should be treated as a direct file download.
// Priority: URL path has a known file extension first, then fall back to user-provided fileName/fileType hints.
func isFileURL(rawURL, fileName, fileType string) bool {
u, err := url.Parse(rawURL)
if err == nil {
ext := strings.ToLower(strings.TrimPrefix(path.Ext(u.Path), "."))
if ext != "" && isSupportedImportExtension(ext) {
return true
}
}
// Fall back to user-provided hints
return fileName != "" || fileType != ""
}
func (s *knowledgeService) CreateKnowledgeFromURL(ctx context.Context,
kbID string, rawURL string, fileName string, fileType string, enableMultimodel *bool, title string, tagIDs []string, channel string,
processOverrides *types.KnowledgeProcessOverrides,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start creating knowledge from URL")
logger.Infof(ctx, "Knowledge base ID: %s, URL: %s", kbID, rawURL)
// Route to file_url logic when the URL points to a downloadable file
if isFileURL(rawURL, fileName, fileType) {
return s.createKnowledgeFromFileURL(
ctx, kbID, rawURL, fileName, fileType, enableMultimodel, title, tagIDs, channel, processOverrides,
)
}
url := rawURL
// Get knowledge base configuration
logger.Info(ctx, "Getting knowledge base configuration")
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
logger.Errorf(ctx, "Failed to get knowledge base: %v", err)
return nil, err
}
if err := s.checkStorageEngineConfigured(ctx, kb); err != nil {
return nil, err
}
// Validate URL format and security
logger.Info(ctx, "Validating URL")
if !isValidURL(url) || !secutils.IsValidURL(url) {
logger.Error(ctx, "Invalid or unsafe URL format")
return nil, ErrInvalidURL
}
// SSRF protection: validate URL is safe to fetch (uses centralised entry-point with whitelist support)
if err := secutils.ValidateURLForSSRF(url); err != nil {
logger.Errorf(ctx, "URL rejected for SSRF protection: %s, err: %v", url, err)
return nil, ErrInvalidURL
}
// Check if URL already exists in the knowledge base
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
logger.Infof(ctx, "Checking if URL exists, tenant ID: %d", tenantID)
fileHash := calculateStr(url)
exists, existingKnowledge, err := s.repo.CheckKnowledgeExists(ctx, tenantID, kbID, &types.KnowledgeCheckParams{
Type: "url",
URL: url,
FileHash: fileHash,
})
if err != nil {
logger.Errorf(ctx, "Failed to check knowledge existence: %v", err)
return nil, err
}
if exists {
logger.Infof(ctx, "URL already exists: %s", url)
// Update creation time for existing knowledge
existingKnowledge.CreatedAt = time.Now()
existingKnowledge.UpdatedAt = time.Now()
if err := s.repo.UpdateKnowledge(ctx, existingKnowledge); err != nil {
logger.Errorf(ctx, "Failed to update existing knowledge: %v", err)
return nil, err
}
return existingKnowledge, types.NewDuplicateURLError(existingKnowledge)
}
// Check storage quota
tenantInfo := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenantInfo.StorageQuota > 0 && tenantInfo.StorageUsed >= tenantInfo.StorageQuota {
logger.Error(ctx, "Storage quota exceeded")
return nil, types.NewStorageQuotaExceededError()
}
// Create knowledge record
logger.Info(ctx, "Creating knowledge record")
knowledge := &types.Knowledge{
ID: uuid.New().String(),
TenantID: tenantID,
KnowledgeBaseID: kbID,
Type: "url",
Channel: defaultChannel(channel),
Title: title,
Source: url,
FileType: "html",
FileHash: fileHash,
ParseStatus: "pending",
EnableStatus: "disabled",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
EmbeddingModelID: kb.EmbeddingModelID,
}
// Save knowledge record
logger.Infof(ctx, "Saving knowledge record to database, ID: %s", knowledge.ID)
eff, err := ApplyKnowledgeProcessOverrides(ctx, kb, knowledge, processOverrides, []string{"html"}, enableMultimodel)
if err != nil {
return nil, err
}
if err := s.repo.CreateKnowledge(ctx, knowledge); err != nil {
logger.Errorf(ctx, "Failed to create knowledge record: %v", err)
return nil, err
}
// Set tag relations
if err := s.setAndAttachKnowledgeTags(ctx, tenantID, kbID, knowledge, tagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
// Enqueue URL processing task to Asynq
logger.Info(ctx, "Enqueuing URL processing task to Asynq")
enableMultimodelValue := eff.EnableMultimodel
enableQuestionGeneration := eff.QuestionGenerationConfig.Enabled
questionCount := eff.QuestionGenerationConfig.QuestionCount
if questionCount <= 0 {
questionCount = 3
}
lang := types.LanguageFromContextOrDefault(ctx)
taskPayload := types.DocumentProcessPayload{
TenantID: tenantID,
KnowledgeID: knowledge.ID,
KnowledgeBaseID: kbID,
URL: url,
EnableMultimodel: enableMultimodelValue,
EnableQuestionGeneration: enableQuestionGeneration,
QuestionCount: questionCount,
Language: lang,
}
langfuse.InjectTracing(ctx, &taskPayload)
payloadBytes, err := json.Marshal(taskPayload)
if err != nil {
logger.Errorf(ctx, "Failed to marshal URL process task payload: %v", err)
s.markKnowledgeEnqueueFailed(ctx, knowledge)
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeFailed, map[string]any{
"title": knowledge.Title, "source_type": "url", "file_type": knowledge.FileType,
"processing_status": "failed", "failure_stage": "enqueue",
})
return knowledge, nil
}
task := asynq.NewTask(
types.TypeDocumentProcess,
payloadBytes,
documentProcessTaskOptions(s.config, asynq.MaxRetry(3))...,
)
info, err := s.task.Enqueue(task)
if err != nil {
logger.Errorf(ctx, "Failed to enqueue URL process task: %v", err)
s.markKnowledgeEnqueueFailed(ctx, knowledge)
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeFailed, map[string]any{
"title": knowledge.Title, "source_type": "url", "file_type": knowledge.FileType,
"processing_status": "failed", "failure_stage": "enqueue",
})
return knowledge, nil
}
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeAccepted, map[string]any{
"title": knowledge.Title, "source_type": "url", "file_type": knowledge.FileType,
"processing_status": "pending", "task_id": info.ID, "trigger": kbActivityTrigger(ctx),
})
logger.Infof(ctx, "Enqueued URL process task: id=%s queue=%s knowledge_id=%s", info.ID, info.Queue, knowledge.ID)
logger.Infof(ctx, "Knowledge from URL created successfully, ID: %s", knowledge.ID)
return knowledge, nil
}
// maxFileURLSize is the maximum allowed file size for file URL import (10MB)
const maxFileURLSize = 10 * 1024 * 1024
// extractFileNameFromURL extracts the filename from a URL path
func extractFileNameFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
base := path.Base(u.Path)
if base == "." || base == "/" {
return ""
}
return base
}
// extractFileNameFromContentDisposition extracts filename from Content-Disposition header
func extractFileNameFromContentDisposition(header string) string {
// e.g. attachment; filename="document.pdf" or filename*=UTF-8''document.pdf
for _, part := range strings.Split(header, ";") {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToLower(part), "filename=") {
name := strings.TrimPrefix(part, "filename=")
name = strings.TrimPrefix(part[len("filename="):], "")
name = strings.Trim(name, `"'`)
if name != "" {
return name
}
}
}
return ""
}
// createKnowledgeFromFileURL is the internal implementation for file URL knowledge creation.
// Called by CreateKnowledgeFromURL when the URL is detected as a direct file download.
func (s *knowledgeService) createKnowledgeFromFileURL(
ctx context.Context,
kbID string,
fileURL string,
fileName string,
fileType string,
enableMultimodel *bool,
title string,
tagIDs []string,
channel string,
processOverrides *types.KnowledgeProcessOverrides,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start creating knowledge from file URL")
logger.Infof(ctx, "Knowledge base ID: %s, file URL: %s", kbID, fileURL)
// Get knowledge base configuration
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
logger.Errorf(ctx, "Failed to get knowledge base: %v", err)
return nil, err
}
if kb.Type == types.KnowledgeBaseTypeFAQ {
return nil, werrors.NewBadRequestError("FAQ 知识库不支持文件上传,请使用 FAQ 导入功能")
}
if err := s.checkStorageEngineConfigured(ctx, kb); err != nil {
return nil, err
}
// Validate URL format and security (static check only, no HEAD request)
if !isValidURL(fileURL) || !secutils.IsValidURL(fileURL) {
logger.Error(ctx, "Invalid or unsafe file URL format")
return nil, ErrInvalidURL
}
if err := secutils.ValidateURLForSSRF(fileURL); err != nil {
logger.Errorf(ctx, "File URL rejected for SSRF protection: %s, err: %v", fileURL, err)
return nil, ErrInvalidURL
}
// Resolve fileName: user-provided > extracted from URL path
if fileName == "" {
fileName = extractFileNameFromURL(fileURL)
}
if fileName != "" {
safeFilename, ok := secutils.ValidateInput(fileName)
if !ok {
logger.Errorf(ctx, "Invalid filename: %s", fileName)
return nil, werrors.NewValidationError("文件名包含非法字符")
}
fileName = safeFilename
}
// Resolve fileType: user-provided > inferred from fileName (which already
// falls back to the URL path above). getFileType never returns empty, so an
// undeterminable type surfaces as "unknown" and is rejected below.
if fileType == "" {
fileType = getFileType(fileName)
}
fileType = normalizeFileExtension(fileType)
// Use title as display name if fileName is still empty
displayName := fileName
if displayName == "" {
displayName = title
}
if displayName == "" {
// Fallback: use last segment of URL
displayName = extractFileNameFromURL(fileURL)
}
if displayName == "" {
displayName = fileURL
}
// Check for duplicate (by URL hash)
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
fileHash := calculateStr(fileURL)
exists, existingKnowledge, err := s.repo.CheckKnowledgeExists(ctx, tenantID, kbID, &types.KnowledgeCheckParams{
Type: "file_url",
URL: fileURL,
FileHash: fileHash,
})
if err != nil {
logger.Errorf(ctx, "Failed to check knowledge existence: %v", err)
return nil, err
}
if exists {
logger.Infof(ctx, "File URL already exists: %s", fileURL)
existingKnowledge.CreatedAt = time.Now()
existingKnowledge.UpdatedAt = time.Now()
if err := s.repo.UpdateKnowledge(ctx, existingKnowledge); err != nil {
logger.Errorf(ctx, "Failed to update existing knowledge: %v", err)
return nil, err
}
return existingKnowledge, types.NewDuplicateURLError(existingKnowledge)
}
// Check storage quota
tenantInfo := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenantInfo.StorageQuota > 0 && tenantInfo.StorageUsed >= tenantInfo.StorageQuota {
logger.Error(ctx, "Storage quota exceeded")
return nil, types.NewStorageQuotaExceededError()
}
// Create knowledge record
knowledge := &types.Knowledge{
ID: uuid.New().String(),
TenantID: tenantID,
KnowledgeBaseID: kbID,
Type: "file_url",
Channel: defaultChannel(channel),
Title: title,
FileName: displayName,
FileType: fileType,
Source: fileURL,
FileHash: fileHash,
ParseStatus: "pending",
EnableStatus: "disabled",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
EmbeddingModelID: kb.EmbeddingModelID,
}
if knowledge.Title == "" {
knowledge.Title = displayName
}
eff, err := resolveFileImportProcessConfig(ctx, kb, fileType, processOverrides, enableMultimodel)
if err != nil {
return nil, err
}
if processOverrides != nil {
if err := knowledge.SetProcessOverrides(processOverrides); err != nil {
logger.Errorf(ctx, "Failed to set process overrides: %v", err)
return nil, err
}
}
if err := s.repo.CreateKnowledge(ctx, knowledge); err != nil {
logger.Errorf(ctx, "Failed to create knowledge record: %v", err)
return nil, err
}
// Set tag relations
if err := s.setAndAttachKnowledgeTags(ctx, tenantID, kbID, knowledge, tagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
// Build async task payload
enableMultimodelValue := eff.EnableMultimodel
enableQuestionGeneration := eff.QuestionGenerationConfig.Enabled
questionCount := eff.QuestionGenerationConfig.QuestionCount
if questionCount <= 0 {
questionCount = 3
}
lang := types.LanguageFromContextOrDefault(ctx)
taskPayload := types.DocumentProcessPayload{
TenantID: tenantID,
KnowledgeID: knowledge.ID,
KnowledgeBaseID: kbID,
FileURL: fileURL,
FileName: fileName,
FileType: fileType,
EnableMultimodel: enableMultimodelValue,
EnableQuestionGeneration: enableQuestionGeneration,
QuestionCount: questionCount,
Language: lang,
}
langfuse.InjectTracing(ctx, &taskPayload)
payloadBytes, err := json.Marshal(taskPayload)
if err != nil {
logger.Errorf(ctx, "Failed to marshal file URL process task payload: %v", err)
s.markKnowledgeEnqueueFailed(ctx, knowledge)
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeFailed, map[string]any{
"title": knowledge.Title, "source_type": "file_url", "file_type": knowledge.FileType,
"processing_status": "failed", "failure_stage": "enqueue",
})
return knowledge, nil
}
task := asynq.NewTask(
types.TypeDocumentProcess,
payloadBytes,
documentProcessTaskOptions(s.config)...,
)
info, err := s.task.Enqueue(task)
if err != nil {
logger.Errorf(ctx, "Failed to enqueue file URL process task: %v", err)
s.markKnowledgeEnqueueFailed(ctx, knowledge)
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeFailed, map[string]any{
"title": knowledge.Title, "source_type": "file_url", "file_type": knowledge.FileType,
"processing_status": "failed", "failure_stage": "enqueue",
})
return knowledge, nil
}
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeAccepted, map[string]any{
"title": knowledge.Title, "source_type": "file_url", "file_type": knowledge.FileType,
"processing_status": "pending", "task_id": info.ID, "trigger": kbActivityTrigger(ctx),
})
logger.Infof(ctx, "Enqueued file URL process task: id=%s queue=%s knowledge_id=%s", info.ID, info.Queue, knowledge.ID)
enqueueDataTableSummaryIfNeeded(ctx, s.task, tenantID, knowledge.ID, fileName, fileType, kb.SummaryModelID, kb.EmbeddingModelID)
logger.Infof(ctx, "Knowledge from file URL created successfully, ID: %s", knowledge.ID)
return knowledge, nil
}
// CreateKnowledgeFromPassage creates a knowledge entry from text passages
func (s *knowledgeService) CreateKnowledgeFromPassage(ctx context.Context,
kbID string, passage []string, channel string,
) (*types.Knowledge, error) {
return s.createKnowledgeFromPassageInternal(ctx, kbID, passage, false, channel)
}
// CreateKnowledgeFromPassageSync creates a knowledge entry from text passages and waits for indexing to complete.
func (s *knowledgeService) CreateKnowledgeFromPassageSync(ctx context.Context,
kbID string, passage []string, channel string,
) (*types.Knowledge, error) {
return s.createKnowledgeFromPassageInternal(ctx, kbID, passage, true, channel)
}
// CreateKnowledgeFromManual creates or saves manual Markdown knowledge content.
func (s *knowledgeService) CreateKnowledgeFromManual(ctx context.Context,
kbID string, payload *types.ManualKnowledgePayload, channel string,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start creating manual knowledge entry")
if payload == nil {
return nil, werrors.NewBadRequestError("请求内容不能为空")
}
cleanContent := secutils.CleanMarkdown(payload.Content)
if strings.TrimSpace(cleanContent) == "" {
return nil, werrors.NewValidationError("内容不能为空")
}
if len([]rune(cleanContent)) > manualContentMaxLength {
return nil, werrors.NewValidationError(fmt.Sprintf("内容长度超出限制(最多%d个字符)", manualContentMaxLength))
}
safeTitle, ok := secutils.ValidateInput(payload.Title)
if !ok {
return nil, werrors.NewValidationError("标题包含非法字符或超出长度限制")
}
status := strings.ToLower(strings.TrimSpace(payload.Status))
if status == "" {
status = types.ManualKnowledgeStatusDraft
}
if status != types.ManualKnowledgeStatusDraft && status != types.ManualKnowledgeStatusPublish {
return nil, werrors.NewValidationError("状态仅支持 draft 或 publish")
}
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
logger.Errorf(ctx, "Failed to get knowledge base: %v", err)
return nil, err
}
if err := s.checkStorageEngineConfigured(ctx, kb); err != nil {
return nil, err
}
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
now := time.Now()
title := safeTitle
if title == "" {
title = fmt.Sprintf("Knowledge-%s", now.Format("20060102-150405"))
}
fileName := ensureManualFileName(title)
meta := types.NewManualKnowledgeMetadata(cleanContent, status, 1)
knowledge := &types.Knowledge{
TenantID: tenantID,
KnowledgeBaseID: kbID,
Type: types.KnowledgeTypeManual,
Channel: defaultChannel(channel),
Title: title,
Description: "",
Source: types.KnowledgeTypeManual,
ParseStatus: types.ManualKnowledgeStatusDraft,
EnableStatus: "disabled",
CreatedAt: now,
UpdatedAt: now,
EmbeddingModelID: kb.EmbeddingModelID,
FileName: fileName,
FileType: types.KnowledgeTypeManual,
}
if err := knowledge.SetManualMetadata(meta); err != nil {
logger.Errorf(ctx, "Failed to set manual metadata: %v", err)
return nil, err
}
knowledge.EnsureManualDefaults()
if status == types.ManualKnowledgeStatusPublish {
knowledge.ParseStatus = "pending"
}
if status == types.ManualKnowledgeStatusPublish {
if _, err := ApplyKnowledgeProcessOverrides(ctx, kb, knowledge, payload.ProcessConfig, nil, nil); err != nil {
return nil, err
}
}
if err := s.repo.CreateKnowledge(ctx, knowledge); err != nil {
logger.Errorf(ctx, "Failed to create manual knowledge record: %v", err)
return nil, err
}
// Set tag relations
if err := s.setAndAttachKnowledgeTags(ctx, tenantID, kbID, knowledge, payload.TagIDs); err != nil {
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
return nil, err
}
// Claim the body's files immediately, before publishing. A draft saved from
// a chat answer must already own them: the message it came from could be
// deleted while the draft is still unpublished.
s.bindContentResources(ctx, tenantID, knowledge.ID, cleanContent)
if status == types.ManualKnowledgeStatusPublish {
logger.Infof(ctx, "Manual knowledge created, enqueuing async processing task, ID: %s", knowledge.ID)
taskID, err := s.enqueueManualProcessing(ctx, knowledge, cleanContent, false)
if err != nil {
logger.Errorf(ctx, "Failed to enqueue manual processing task for new knowledge: %v", err)
// Non-fatal: mark as failed so user can retry
knowledge.ParseStatus = "failed"
knowledge.ErrorMessage = "Failed to enqueue processing task"
s.repo.UpdateKnowledge(ctx, knowledge)
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeFailed, map[string]any{
"title": knowledge.Title, "source_type": "manual", "status": status,
"processing_status": "failed", "failure_stage": "enqueue",
})
} else {
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeAccepted, map[string]any{
"title": knowledge.Title, "source_type": "manual", "status": status,
"processing_status": "pending", "task_id": taskID, "trigger": kbActivityTrigger(ctx),
})
}
} else {
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeSuccess, map[string]any{
"title": knowledge.Title, "source_type": "manual", "status": status,
})
}
return knowledge, nil
}
// createKnowledgeFromPassageInternal consolidates the common logic for creating knowledge from passages.
// When syncMode is true, chunk processing is performed synchronously; otherwise, it's processed asynchronously.
func (s *knowledgeService) createKnowledgeFromPassageInternal(ctx context.Context,
kbID string, passage []string, syncMode bool, channel string,
) (*types.Knowledge, error) {
if syncMode {
logger.Info(ctx, "Start creating knowledge from passage (sync)")
} else {
logger.Info(ctx, "Start creating knowledge from passage")
}
logger.Infof(ctx, "Knowledge base ID: %s, passage count: %d", kbID, len(passage))
// 验证段落内容安全性
safePassages := make([]string, 0, len(passage))
for i, p := range passage {
safePassage, isValid := secutils.ValidateInput(p)
if !isValid {
logger.Errorf(ctx, "Invalid passage content at index %d", i)
return nil, werrors.NewValidationError(fmt.Sprintf("段落 %d 包含非法内容", i+1))
}
safePassages = append(safePassages, safePassage)
}
// Get knowledge base configuration
logger.Info(ctx, "Getting knowledge base configuration")
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
logger.Errorf(ctx, "Failed to get knowledge base: %v", err)
return nil, err
}
// Create knowledge record
if syncMode {
logger.Info(ctx, "Creating knowledge record (sync)")
} else {
logger.Info(ctx, "Creating knowledge record")
}
knowledge := &types.Knowledge{
ID: uuid.New().String(),
TenantID: ctx.Value(types.TenantIDContextKey).(uint64),
KnowledgeBaseID: kbID,
Type: "passage",
Channel: defaultChannel(channel),
ParseStatus: "pending",
EnableStatus: "disabled",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
EmbeddingModelID: kb.EmbeddingModelID,
}
// Save knowledge record
logger.Infof(ctx, "Saving knowledge record to database, ID: %s", knowledge.ID)
if err := s.repo.CreateKnowledge(ctx, knowledge); err != nil {
logger.Errorf(ctx, "Failed to create knowledge record: %v", err)
return nil, err
}
// Process passages
if syncMode {
logger.Info(ctx, "Processing passage synchronously")
s.processDocumentFromPassage(ctx, kb, knowledge, safePassages)
recordKBActivity(ctx, s.audit, knowledge.TenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeSuccess, map[string]any{
"title": knowledge.Title, "source_type": "passage", "processing_status": knowledge.ParseStatus,
})
logger.Infof(ctx, "Knowledge from passage created successfully (sync), ID: %s", knowledge.ID)
} else {
// Enqueue passage processing task to Asynq
logger.Info(ctx, "Enqueuing passage processing task to Asynq")
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
// Check question generation config
enableQuestionGeneration := false
questionCount := 3 // default
if kb.QuestionGenerationConfig != nil && kb.QuestionGenerationConfig.Enabled {
enableQuestionGeneration = true
if kb.QuestionGenerationConfig.QuestionCount > 0 {
questionCount = kb.QuestionGenerationConfig.QuestionCount
}
}
lang := types.LanguageFromContextOrDefault(ctx)
taskPayload := types.DocumentProcessPayload{
TenantID: tenantID,
KnowledgeID: knowledge.ID,
KnowledgeBaseID: kbID,
Passages: safePassages,
EnableMultimodel: false, // 文本段落不支持多模态
EnableQuestionGeneration: enableQuestionGeneration,
QuestionCount: questionCount,
Language: lang,
}
langfuse.InjectTracing(ctx, &taskPayload)
payloadBytes, err := json.Marshal(taskPayload)
if err != nil {
logger.Errorf(ctx, "Failed to marshal passage process task payload: %v", err)
s.markKnowledgeEnqueueFailed(ctx, knowledge)
recordKBActivity(ctx, s.audit, knowledge.TenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeFailed, map[string]any{
"title": knowledge.Title, "source_type": "passage",
"processing_status": "failed", "failure_stage": "enqueue",
})
// 即使入队失败,也返回knowledge
return knowledge, nil
}
task := asynq.NewTask(
types.TypeDocumentProcess,
payloadBytes,
documentProcessTaskOptions(s.config, asynq.MaxRetry(3))...,
)
info, err := s.task.Enqueue(task)
if err != nil {
logger.Errorf(ctx, "Failed to enqueue passage process task: %v", err)
s.markKnowledgeEnqueueFailed(ctx, knowledge)
recordKBActivity(ctx, s.audit, knowledge.TenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeFailed, map[string]any{
"title": knowledge.Title, "source_type": "passage",
"processing_status": "failed", "failure_stage": "enqueue",
})
return knowledge, nil
}
recordKBActivity(ctx, s.audit, knowledge.TenantID, kbID, types.AuditActionKnowledgeCreated,
"knowledge", knowledge.ID, types.AuditOutcomeAccepted, map[string]any{
"title": knowledge.Title, "source_type": "passage", "processing_status": "pending",
"task_id": info.ID, "trigger": kbActivityTrigger(ctx),
})
logger.Infof(ctx, "Enqueued passage process task: id=%s queue=%s knowledge_id=%s", info.ID, info.Queue, knowledge.ID)
logger.Infof(ctx, "Knowledge from passage created successfully, ID: %s", knowledge.ID)
}
return knowledge, nil
}
// UpdateManualKnowledge updates manual Markdown knowledge content.
// For publish status, the heavy operations (cleanup old indexes, re-chunking,
// re-embedding) are offloaded to an Asynq task so the HTTP response returns quickly.
func (s *knowledgeService) UpdateManualKnowledge(ctx context.Context,
knowledgeID string, payload *types.ManualKnowledgePayload,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start updating manual knowledge entry")
if payload == nil {
return nil, werrors.NewBadRequestError("请求内容不能为空")
}
cleanContent := secutils.CleanMarkdown(payload.Content)
if strings.TrimSpace(cleanContent) == "" {
return nil, werrors.NewValidationError("内容不能为空")
}
if len([]rune(cleanContent)) > manualContentMaxLength {
return nil, werrors.NewValidationError(fmt.Sprintf("内容长度超出限制(最多%d个字符)", manualContentMaxLength))
}
safeTitle, ok := secutils.ValidateInput(payload.Title)
if !ok {
return nil, werrors.NewValidationError("标题包含非法字符或超出长度限制")
}
status := strings.ToLower(strings.TrimSpace(payload.Status))
if status == "" {
status = types.ManualKnowledgeStatusDraft
}
if status != types.ManualKnowledgeStatusDraft && status != types.ManualKnowledgeStatusPublish {
return nil, werrors.NewValidationError("状态仅支持 draft 或 publish")
}
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
}
if !existing.IsManual() {
return nil, werrors.NewBadRequestError("仅支持手工知识的在线编辑")
}
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, existing.KnowledgeBaseID)
if err != nil {
logger.Errorf(ctx, "Failed to get knowledge base for manual update: %v", err)
return nil, err
}
var version int
if meta, err := existing.ManualMetadata(); err == nil && meta != nil {
version = meta.Version + 1
} else {
version = 1
}
meta := types.NewManualKnowledgeMetadata(cleanContent, status, version)
if err := existing.SetManualMetadata(meta); err != nil {
logger.Errorf(ctx, "Failed to set manual metadata during update: %v", err)
return nil, err
}
if safeTitle != "" {
existing.Title = safeTitle
} else if existing.Title == "" {
existing.Title = fmt.Sprintf("手工知识-%s", time.Now().Format("20060102-150405"))
}
existing.FileName = ensureManualFileName(existing.Title)
existing.FileType = types.KnowledgeTypeManual
existing.Type = types.KnowledgeTypeManual
existing.Source = types.KnowledgeTypeManual
existing.EnableStatus = "disabled"
existing.UpdatedAt = time.Now()
existing.EmbeddingModelID = kb.EmbeddingModelID
if status == types.ManualKnowledgeStatusDraft {
existing.ParseStatus = types.ManualKnowledgeStatusDraft
existing.Description = ""
existing.ProcessedAt = nil
if err := s.repo.UpdateKnowledge(ctx, existing); err != nil {
logger.Errorf(ctx, "Failed to persist manual draft: %v", err)
return nil, err
}
s.bindContentResources(ctx, tenantID, existing.ID, cleanContent)
recordKBActivity(ctx, s.audit, tenantID, existing.KnowledgeBaseID, types.AuditActionKnowledgeUpdated,
"knowledge", existing.ID, types.AuditOutcomeSuccess, map[string]any{
"title": existing.Title, "status": status,
})
return existing, nil
}
// Publish: persist pending status and enqueue async task for cleanup + re-indexing
existing.ParseStatus = "pending"
existing.Description = ""
existing.ProcessedAt = nil
if _, err := ApplyKnowledgeProcessOverrides(ctx, kb, existing, payload.ProcessConfig, nil, nil); err != nil {
return nil, err
}
if err := s.repo.UpdateKnowledge(ctx, existing); err != nil {
logger.Errorf(ctx, "Failed to persist manual knowledge before indexing: %v", err)
return nil, err
}
logger.Infof(ctx, "Manual knowledge updated, enqueuing async processing task, ID: %s", existing.ID)
taskID, err := s.enqueueManualProcessing(ctx, existing, cleanContent, true)
if err != nil {
logger.Errorf(ctx, "Failed to enqueue manual processing task: %v", err)
// Non-fatal: mark as failed so user can retry
existing.ParseStatus = "failed"
existing.ErrorMessage = "Failed to enqueue processing task"
s.repo.UpdateKnowledge(ctx, existing)
recordKBActivity(ctx, s.audit, tenantID, existing.KnowledgeBaseID, types.AuditActionKnowledgeUpdated,
"knowledge", existing.ID, types.AuditOutcomeFailed, map[string]any{
"title": existing.Title, "status": status,
"processing_status": "failed", "failure_stage": "enqueue",
})
return nil, werrors.NewInternalServerError("Failed to submit processing task")
}
recordKBActivity(ctx, s.audit, tenantID, existing.KnowledgeBaseID, types.AuditActionKnowledgeUpdated,
"knowledge", existing.ID, types.AuditOutcomeAccepted, map[string]any{
"title": existing.Title, "status": status, "processing_status": "pending",
"task_id": taskID, "trigger": kbActivityTrigger(ctx),
})
return existing, nil
}
// enqueueManualProcessing enqueues a manual:process Asynq task for async cleanup + re-indexing.
func (s *knowledgeService) enqueueManualProcessing(ctx context.Context,
knowledge *types.Knowledge, content string, needCleanup bool,
) (string, error) {
requestID, _ := types.RequestIDFromContext(ctx)
payload := types.ManualProcessPayload{
RequestId: requestID,
TenantID: knowledge.TenantID,
KnowledgeID: knowledge.ID,
KnowledgeBaseID: knowledge.KnowledgeBaseID,
Content: content,
NeedCleanup: needCleanup,
}
langfuse.InjectTracing(ctx, &payload)
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal manual process payload: %w", err)
}
task := asynq.NewTask(types.TypeManualProcess, payloadBytes,
asynq.Queue(types.QueueDefault), asynq.MaxRetry(3), asynq.Timeout(30*time.Minute))
info, err := s.task.Enqueue(task)
if err != nil {
return "", fmt.Errorf("failed to enqueue manual process task: %w", err)
}
logger.Infof(ctx, "Enqueued manual process task: knowledge_id=%s, asynq_id=%s", knowledge.ID, info.ID)
return info.ID, nil
}
// markKnowledgeEnqueueFailed prevents a durable knowledge row from remaining
// indefinitely "pending" when its background processing task was never
// created. The API may still return the row so callers can retry it.
func (s *knowledgeService) markKnowledgeEnqueueFailed(ctx context.Context, knowledge *types.Knowledge) {
if knowledge == nil {
return
}
knowledge.ParseStatus = "failed"
knowledge.ErrorMessage = "Failed to enqueue processing task"
if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil {
logger.Errorf(ctx, "Failed to mark knowledge as failed after enqueue error: %v", err)
}
}
func ensureManualFileName(title string) string {
if title == "" {
return fmt.Sprintf("manual-%s%s", time.Now().Format("20060102-150405"), manualFileExtension)
}
trimmed := strings.TrimSpace(title)
if strings.HasSuffix(strings.ToLower(trimmed), manualFileExtension) {
return trimmed
}
return trimmed + manualFileExtension
}
// sanitizeManualDownloadFilename converts a knowledge title into a safe .md
// download filename. Characters that are illegal or dangerous in HTTP header
// values and file-system paths are removed or replaced; a blank result falls
// back to "untitled".
func sanitizeManualDownloadFilename(title string) string {
safeName := strings.NewReplacer(
"\n", "", "\r", "", "\t", "", "/", "-", "\\", "-", "\"", "'",
).Replace(title)
if strings.TrimSpace(safeName) == "" {
safeName = "untitled"
}
if !strings.HasSuffix(strings.ToLower(safeName), manualFileExtension) {
safeName += manualFileExtension
}
return safeName
}
// bindContentResources claims every stored file the body references on behalf
// of a knowledge entry.
//
// A manual document routinely points at files it did not upload: an answer saved
// from a chat carries the very `resource://` handles the assistant message still
// shows. Claiming them is what makes that copy safe — no bytes are duplicated,
// and neither the message nor the document can delete a file the other still
// needs. Handles belonging to another workspace are skipped, so a pasted
// reference cannot pull in a file the caller may not read.
//
// Best-effort by design: the document is already saved, and a missed claim
// degrades to the old behaviour rather than failing the save.
func (s *knowledgeService) bindContentResources(
ctx context.Context, tenantID uint64, knowledgeID, content string,
) {
if s.resourceCatalog == nil || knowledgeID == "" {
return
}
for _, ref := range types.ScanResourceReferences(content) {
resource, err := s.resourceCatalog.Resolve(ctx, ref)
if err != nil || resource == nil {
logger.Warnf(ctx, "Skip binding unknown resource %s to knowledge %s: %v", ref, knowledgeID, err)
continue
}
if resource.TenantID != tenantID {
logger.Warnf(ctx, "Skip binding cross-workspace resource %s to knowledge %s", ref, knowledgeID)
continue
}
if authorizer, ok := s.resourceCatalog.(interfaces.ResourceFileAuthorizer); ok {
if err := authorizer.AuthorizeFileRead(ctx, ref, interfaces.ResourceFileScope{TenantID: tenantID}); err != nil {
logger.Warnf(ctx, "Skip binding inaccessible resource to knowledge %s", knowledgeID)
continue
}
}
if err := s.resourceCatalog.Bind(
ctx, ref, types.ResourceOwnerKnowledge, knowledgeID, types.ResourceRelationAttachment,
); err != nil {
logger.Warnf(ctx, "Failed to bind resource %s to knowledge %s: %v", ref, knowledgeID, err)
}
}
}
func (s *knowledgeService) triggerManualProcessing(ctx context.Context,
kb *types.KnowledgeBase, knowledge *types.Knowledge, content string, doSync bool,
) {
clean := strings.TrimSpace(content)
if clean == "" {
return
}
// Resolve embedded data:base64 images and remote http(s) images → storage, replace URLs.
// Runs before chunking so chunks contain stable provider:// URLs.
var resolvedImages []docparser.StoredImage
if s.imageResolver != nil {
fileSvc := s.resolveFileService(ctx, kb)
afterDataURI, fromDataURI, _ := s.imageResolver.ResolveDataURIImages(ctx, clean, fileSvc, knowledge.TenantID)
if len(fromDataURI) > 0 {
logger.Infof(ctx, "Resolved %d data-URI images for manual knowledge %s", len(fromDataURI), knowledge.ID)
clean = afterDataURI
resolvedImages = append(resolvedImages, fromDataURI...)
}
updatedContent, storedImages, resolveErr := s.imageResolver.ResolveRemoteImages(ctx, clean, fileSvc, knowledge.TenantID)
if resolveErr != nil {
logger.Warnf(ctx, "Remote image resolution partially failed: %v", resolveErr)
}
if len(storedImages) > 0 {
logger.Infof(ctx, "Resolved %d remote images for manual knowledge %s", len(storedImages), knowledge.ID)
clean = updatedContent
resolvedImages = append(resolvedImages, storedImages...)
}
}
// Claim newly imported files. Existing body references keep their claims
// through cleanup, so republishing never temporarily removes ownership.
if s.resourceCatalog != nil {
// These files were produced by this trusted import, rather than supplied
// as opaque references in the user's markdown.
for _, image := range resolvedImages {
if _, ok := types.ParseResourcePath(image.ServingURL); ok {
if err := s.resourceCatalog.Bind(ctx, image.ServingURL, types.ResourceOwnerKnowledge, knowledge.ID, types.ResourceRelationExtractedImage); err != nil {
logger.Warnf(ctx, "Failed to bind imported image to knowledge %s: %v", knowledge.ID, err)
}
}
}
}
s.bindContentResources(ctx, knowledge.TenantID, knowledge.ID, clean)
// Keep manually entered CRLF text aligned with the LF values sent by the
// chunking preview endpoint.
clean = chunker.NormalizeLineEndings(clean)
processOverrides, _ := knowledge.ProcessOverrides()
eff := ResolveProcessConfig(kb, processOverrides)
// Manual content is markdown - chunk directly with Go chunker
chunkCfg := buildSplitterConfigFromChunking(eff.ChunkingConfig)
var parsed []types.ParsedChunk
opts := ProcessChunksOptions{
EnableMultimodel: eff.EnableMultimodel && len(resolvedImages) > 0,
StoredImages: resolvedImages,
}
if eff.QuestionGenerationConfig.Enabled {
opts.EnableQuestionGeneration = true
opts.QuestionCount = eff.QuestionGenerationConfig.QuestionCount
if opts.QuestionCount <= 0 {
opts.QuestionCount = 3
}
}
if eff.ChunkingConfig.EnableParentChild {
parentCfg, childCfg := buildParentChildConfigs(eff.ChunkingConfig, chunkCfg)
pcResult := chunker.SplitParentChild(clean, parentCfg, childCfg)
parsed = make([]types.ParsedChunk, len(pcResult.Children))
for i, c := range pcResult.Children {
parsed[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}
}
opts.ParentChunks = parentChunks
} else {
splitChunks := chunker.Split(clean, chunkCfg)
parsed = make([]types.ParsedChunk, len(splitChunks))
for i, c := range splitChunks {
parsed[i] = types.ParsedChunk{
Content: c.Content,
ContextHeader: c.ContextHeader,
Seq: c.Seq,
Start: c.Start,
End: c.End,
}
}
}
if doSync {
s.processChunks(ctx, kb, knowledge, parsed, opts)
return
}
newCtx := logger.CloneContext(ctx)
go s.processChunks(newCtx, kb, knowledge, parsed, opts)
}