Files
xinmi/internal/application/service/knowledge_faq_import.go
T

2859 lines
104 KiB
Go
Raw Normal View History

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"runtime"
"slices"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/application/service/retriever"
werrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/google/uuid"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
)
// UpsertFAQEntries imports or appends FAQ entries asynchronously.
// Returns task ID (UUID) for tracking import progress.
func (s *knowledgeService) UpsertFAQEntries(ctx context.Context,
kbID string, payload *types.FAQBatchUpsertPayload,
) (string, error) {
if payload == nil || len(payload.Entries) == 0 {
return "", werrors.NewBadRequestError("FAQ 条目不能为空")
}
if payload.Mode == "" {
payload.Mode = types.FAQBatchModeAppend
}
if payload.Mode != types.FAQBatchModeAppend && payload.Mode != types.FAQBatchModeReplace {
return "", werrors.NewBadRequestError("模式仅支持 append 或 replace")
}
// 验证知识库是否存在且有效
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
if err != nil {
return "", err
}
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
// 使用传入的TaskID,如果没传则生成增强的TaskID
taskID := payload.TaskID
if taskID == "" {
taskID = secutils.GenerateTaskID("faq_import", tenantID, kbID)
}
var knowledgeID string
// 检查是否有正在进行的导入任务(通过Redis)
runningTaskID, err := s.getRunningFAQImportTaskID(ctx, kbID)
if err != nil {
logger.Errorf(ctx, "Failed to check running import task: %v", err)
// 检查失败不影响导入,继续执行
} else if runningTaskID != "" {
logger.Warnf(ctx, "Import task already running for KB %s: %s", kbID, runningTaskID)
return "", werrors.NewBadRequestError(fmt.Sprintf("该知识库已有导入任务正在进行中(任务ID: %s),请等待完成后再试", runningTaskID))
}
// 确保 FAQ knowledge 存在
faqKnowledge, err := s.ensureFAQKnowledge(ctx, tenantID, kb)
if err != nil {
return "", fmt.Errorf("failed to ensure FAQ knowledge: %w", err)
}
knowledgeID = faqKnowledge.ID
// 记录任务入队时间
enqueuedAt := time.Now().Unix()
instanceID := uuid.NewString()
runningInfoSet := false
enqueueSucceeded := false
// 设置 KB 的运行中任务信息
if err := s.setRunningFAQImportInfo(ctx, kbID, &runningFAQImportInfo{
TaskID: taskID,
EnqueuedAt: enqueuedAt,
InstanceID: instanceID,
}); err != nil {
logger.Errorf(ctx, "Failed to set running FAQ import task info: %v", err)
// 不影响任务执行,继续
} else {
runningInfoSet = true
}
defer func() {
if !runningInfoSet || enqueueSucceeded {
return
}
if clearErr := s.clearRunningFAQImportInfoIfMatches(ctx, kbID, taskID, instanceID, enqueuedAt); clearErr != nil {
logger.Warnf(ctx, "Failed to clear FAQ import running info after setup failure: %v", clearErr)
}
}()
// 初始化导入任务状态到Redis
progress := &types.FAQImportProgress{
TaskID: taskID,
KBID: kbID,
KnowledgeID: knowledgeID,
Status: types.FAQImportStatusPending,
Progress: 0,
Total: len(payload.Entries),
Processed: 0,
SuccessCount: 0,
FailedCount: 0,
FailedEntries: make([]types.FAQFailedEntry, 0),
Message: "任务已创建,等待处理",
CreatedAt: time.Now().Unix(),
UpdatedAt: time.Now().Unix(),
DryRun: payload.DryRun,
}
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Errorf(ctx, "Failed to initialize FAQ import task status: %v", err)
return "", fmt.Errorf("failed to initialize task: %w", err)
}
logger.Infof(ctx, "FAQ import task initialized: %s, kb_id: %s, total entries: %d, dry_run: %v",
taskID, kbID, len(payload.Entries), payload.DryRun)
// Enqueue FAQ import task to Asynq
logger.Info(ctx, "Enqueuing FAQ import task to Asynq")
// 构建任务 payload
taskPayload := types.FAQImportPayload{
TenantID: tenantID,
TaskID: taskID,
KBID: kbID,
KnowledgeID: knowledgeID,
Mode: payload.Mode,
DryRun: payload.DryRun,
EnqueuedAt: enqueuedAt,
InstanceID: instanceID,
Initiator: types.TaskInitiatorFromContext(ctx),
}
// 阈值:超过 200 条或序列化后超过 50KB 时使用对象存储
const (
entryCountThreshold = 200
payloadSizeThreshold = 50 * 1024 // 50KB
)
entryCount := len(payload.Entries)
if entryCount > entryCountThreshold {
// 数据量较大,上传到对象存储
entriesData, err := json.Marshal(payload.Entries)
if err != nil {
logger.Errorf(ctx, "Failed to marshal FAQ entries: %v", err)
return "", fmt.Errorf("failed to marshal entries: %w", err)
}
logger.Infof(ctx, "FAQ entries size: %d bytes, uploading to object storage", len(entriesData))
// 上传到私有桶(主桶),任务处理完成后清理
fileName := fmt.Sprintf("faq_import_entries_%s_%d.json", taskID, enqueuedAt)
entriesURL, err := s.fileSvc.SaveBytes(ctx, entriesData, tenantID, fileName, false)
if err != nil {
logger.Errorf(ctx, "Failed to upload FAQ entries to object storage: %v", err)
return "", fmt.Errorf("failed to upload entries: %w", err)
}
logger.Infof(ctx, "FAQ entries uploaded to: %s", entriesURL)
taskPayload.EntriesURL = entriesURL
taskPayload.EntryCount = entryCount
} else {
// 数据量较小,直接存储在 payload 中
taskPayload.Entries = payload.Entries
}
langfuse.InjectTracing(ctx, &taskPayload)
payloadBytes, err := json.Marshal(taskPayload)
if err != nil {
logger.Errorf(ctx, "Failed to marshal FAQ import task payload: %v", err)
return "", fmt.Errorf("failed to marshal task payload: %w", err)
}
// 再次检查 payload 大小
if len(payloadBytes) > payloadSizeThreshold && taskPayload.EntriesURL == "" {
// payload 太大但还没上传,现在上传
entriesData, _ := json.Marshal(payload.Entries)
fileName := fmt.Sprintf("faq_import_entries_%s_%d.json", taskID, enqueuedAt)
entriesURL, err := s.fileSvc.SaveBytes(ctx, entriesData, tenantID, fileName, false)
if err != nil {
logger.Errorf(ctx, "Failed to upload FAQ entries to object storage: %v", err)
return "", fmt.Errorf("failed to upload entries: %w", err)
}
logger.Infof(ctx, "FAQ entries uploaded to (size exceeded): %s", entriesURL)
taskPayload.Entries = nil
taskPayload.EntriesURL = entriesURL
taskPayload.EntryCount = entryCount
payloadBytes, _ = json.Marshal(taskPayload)
}
logger.Infof(ctx, "FAQ import task payload size: %d bytes", len(payloadBytes))
maxRetry := 5
if payload.DryRun {
maxRetry = 3 // dry run 重试次数少一些
}
// 使用 taskID:instanceID 作为 asynq 的唯一任务标识
asynqTaskID := fmt.Sprintf("%s:%s", taskID, instanceID)
task := asynq.NewTask(
types.TypeFAQImport,
payloadBytes,
asynq.TaskID(asynqTaskID),
asynq.Queue(types.QueueMaintenance),
asynq.MaxRetry(maxRetry),
asynq.Timeout(2*time.Hour),
)
info, err := s.task.Enqueue(task)
if err != nil {
logger.Errorf(ctx, "Failed to enqueue FAQ import task: %v", err)
return "", fmt.Errorf("failed to enqueue task: %w", err)
}
logger.Infof(ctx, "Enqueued FAQ import task: id=%s queue=%s task_id=%s dry_run=%v", info.ID, info.Queue, taskID, payload.DryRun)
enqueueSucceeded = true
if !payload.DryRun {
recordKBActivity(ctx, s.audit, tenantID, kbID, types.AuditActionFAQImportStarted,
"faq_entry", knowledgeID, types.AuditOutcomeAccepted,
map[string]any{
"task_id": taskID, "mode": payload.Mode, "total": len(payload.Entries),
"trigger": kbActivityTrigger(ctx), "processing_status": "pending",
})
}
return taskID, nil
}
// generateFailedEntriesCSV 生成失败条目的 CSV 文件并上传
func (s *knowledgeService) generateFailedEntriesCSV(ctx context.Context,
tenantID uint64, taskID string, failedEntries []types.FAQFailedEntry,
) (string, error) {
// 生成 CSV 内容
var buf strings.Builder
// 写入 BOM 以支持 Excel 正确识别 UTF-8
buf.WriteString("\xEF\xBB\xBF")
// 写入表头
buf.WriteString("错误原因,分类(必填),问题(必填),相似问题(选填-多个用##分隔),反例问题(选填-多个用##分隔),机器人回答(必填-多个用##分隔),是否全部回复(选填-默认FALSE),是否停用(选填-默认FALSE)\n")
// 写入数据行
for _, entry := range failedEntries {
// CSV 转义:如果内容包含逗号、引号或换行,需要用引号包裹并转义内部引号
reason := csvEscape(entry.Reason)
tagName := csvEscape(entry.TagName)
standardQ := csvEscape(entry.StandardQuestion)
similarQs := ""
if len(entry.SimilarQuestions) > 0 {
similarQs = csvEscape(strings.Join(entry.SimilarQuestions, "##"))
}
negativeQs := ""
if len(entry.NegativeQuestions) > 0 {
negativeQs = csvEscape(strings.Join(entry.NegativeQuestions, "##"))
}
answers := ""
if len(entry.Answers) > 0 {
answers = csvEscape(strings.Join(entry.Answers, "##"))
}
answerAll := "false"
if entry.AnswerAll {
answerAll = "true"
}
isDisabled := "false"
if entry.IsDisabled {
isDisabled = "true"
}
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%s,%s,%s,%s\n",
reason, tagName, standardQ, similarQs, negativeQs, answers, answerAll, isDisabled))
}
// 上传 CSV 文件到临时存储(会自动过期)
fileName := fmt.Sprintf("faq_dryrun_failed_%s.csv", taskID)
filePath, err := s.fileSvc.SaveBytes(ctx, []byte(buf.String()), tenantID, fileName, true)
if err != nil {
return "", fmt.Errorf("failed to save CSV file: %w", err)
}
// 获取下载 URL
fileURL, err := s.fileSvc.GetFileURL(ctx, filePath)
if err != nil {
return "", fmt.Errorf("failed to get file URL: %w", err)
}
logger.Infof(ctx, "Generated failed entries CSV: %s, entries: %d", fileURL, len(failedEntries))
return fileURL, nil
}
// csvEscape 转义 CSV 字段
func csvEscape(s string) string {
if strings.ContainsAny(s, ",\"\n\r") {
// 将内部引号替换为两个引号,并用引号包裹整个字段
return "\"" + strings.ReplaceAll(s, "\"", "\"\"") + "\""
}
return s
}
// saveFAQImportResultToDatabase 保存FAQ导入结果统计到数据库
func (s *knowledgeService) saveFAQImportResultToDatabase(ctx context.Context,
payload *types.FAQImportPayload, progress *types.FAQImportProgress, originalTotalEntries int,
) error {
// 获取FAQ知识库实例
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
knowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, payload.KnowledgeID)
if err != nil {
return fmt.Errorf("failed to get FAQ knowledge: %w", err)
}
// 计算跳过的条目数(总数 - 完全成功 - 部分失败 - 完全失败)
skippedCount := originalTotalEntries - progress.SuccessCount - progress.PartialFailedCount - progress.FailedCount
if skippedCount < 0 {
skippedCount = 0
}
// 创建导入结果统计
importResult := &types.FAQImportResult{
TotalEntries: originalTotalEntries,
SuccessCount: progress.SuccessCount,
FailedCount: progress.FailedCount,
PartialFailedCount: progress.PartialFailedCount,
SkippedCount: skippedCount,
MergedCount: progress.MergedCount,
AddedCount: progress.AddedCount,
ImportMode: payload.Mode,
ImportedAt: time.Now(),
TaskID: payload.TaskID,
ProcessingTime: time.Now().Unix() - progress.CreatedAt, // 处理耗时(秒)
DisplayStatus: "open", // 新导入的结果默认显示
}
// 如果有失败/部分失败条目 CSV 下载 URL,则写入结果(FailedEntries 包含完全失败与部分失败)
if progress.FailedEntriesURL != "" {
importResult.FailedEntriesURL = progress.FailedEntriesURL
}
// 设置导入结果到Knowledge的metadata中
if err := knowledge.SetLastFAQImportResult(importResult); err != nil {
return fmt.Errorf("failed to set FAQ import result: %w", err)
}
// 更新数据库
if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil {
return fmt.Errorf("failed to update knowledge with import result: %w", err)
}
logger.Infof(ctx, "Saved FAQ import result to database: knowledge_id=%s, task_id=%s, total=%d, success=%d, added=%d, merged=%d, failed=%d, partial_failed=%d, skipped=%d",
payload.KnowledgeID, payload.TaskID, originalTotalEntries, progress.SuccessCount, progress.AddedCount, progress.MergedCount, progress.FailedCount, progress.PartialFailedCount, skippedCount)
return nil
}
// buildFAQFailedEntry 构建 FAQFailedEntry
func buildFAQFailedEntry(idx int, reason string, entry *types.FAQEntryPayload) types.FAQFailedEntry {
answerAll := false
if entry.AnswerStrategy != nil && *entry.AnswerStrategy == types.AnswerStrategyAll {
answerAll = true
}
isDisabled := false
if entry.IsEnabled != nil && !*entry.IsEnabled {
isDisabled = true
}
return types.FAQFailedEntry{
Index: idx,
Reason: reason,
TagName: entry.TagName,
StandardQuestion: strings.TrimSpace(entry.StandardQuestion),
SimilarQuestions: entry.SimilarQuestions,
NegativeQuestions: entry.NegativeQuestions,
Answers: entry.Answers,
AnswerAll: answerAll,
IsDisabled: isDisabled,
}
}
func buildFAQPartialFailedEntry(idx int, entry *types.FAQEntryPayload,
removedSimilarQuestions, removedNegativeQuestions []string,
) types.FAQFailedEntry {
answerAll := false
if entry.AnswerStrategy != nil && *entry.AnswerStrategy == types.AnswerStrategyAll {
answerAll = true
}
isDisabled := false
if entry.IsEnabled != nil && !*entry.IsEnabled {
isDisabled = true
}
// 构建失败原因描述:汇总信息 + 详细信息
var summary []string
if len(removedSimilarQuestions) > 0 {
summary = append(summary, fmt.Sprintf("%d条相似问被移除", len(removedSimilarQuestions)))
}
if len(removedNegativeQuestions) > 0 {
summary = append(summary, fmt.Sprintf("%d条反例被移除", len(removedNegativeQuestions)))
}
// 完整的 reason:汇总 | 相似问详情 | 反例详情
var reasonParts []string
reasonParts = append(reasonParts, "部分成功:"+strings.Join(summary, ","))
if len(removedSimilarQuestions) > 0 {
reasonParts = append(reasonParts, strings.Join(removedSimilarQuestions, "; "))
}
if len(removedNegativeQuestions) > 0 {
reasonParts = append(reasonParts, strings.Join(removedNegativeQuestions, "; "))
}
return types.FAQFailedEntry{
Index: idx,
Reason: strings.Join(reasonParts, " | "),
IsPartialFailure: true,
TagName: entry.TagName,
StandardQuestion: strings.TrimSpace(entry.StandardQuestion),
SimilarQuestions: entry.SimilarQuestions,
NegativeQuestions: entry.NegativeQuestions,
Answers: entry.Answers,
AnswerAll: answerAll,
IsDisabled: isDisabled,
RemovedSimilarQuestions: removedSimilarQuestions,
RemovedNegativeQuestions: removedNegativeQuestions,
}
}
// executeFAQDryRunValidation 执行 FAQ dry run 验证,返回通过验证的条目索引
func (s *knowledgeService) executeFAQDryRunValidation(ctx context.Context,
payload *types.FAQImportPayload, progress *types.FAQImportProgress,
) []int {
entries := payload.Entries
// 用于记录已通过基本验证和重复检查的条目索引,后续进行安全检查
validEntryIndices := make([]int, 0, len(entries))
// 根据模式选择不同的验证逻辑
if payload.Mode == types.FAQBatchModeAppend {
validEntryIndices = s.validateEntriesForAppendModeWithProgress(ctx, payload.TenantID, payload.KBID, entries, progress)
} else {
validEntryIndices = s.validateEntriesForReplaceModeWithProgress(ctx, entries, progress)
}
return validEntryIndices
}
// validateEntriesForAppendModeWithProgress 验证 Append 模式下的条目(带进度更新)
// 注意:验证阶段不更新 Processed,只有实际导入时才更新
// validateEntriesForAppendModeWithProgress 验证 Append 模式下的条目(带进度更新)
// 注意:验证阶段不更新 Processed,只有实际导入时才更新
//
// 分四阶段校验:
// 阶段一(预校验):
// 1. 标准问 - 仅文件内部去重;如已有KB标准问则标记为合并候选
// 2. 相似问 - 对比文件+知识库(合并候选排除自身已有问题) → 单条移除冲突相似问
// 3. 反例 - 仅对比当前QA自身标准问+相似问 → 单条移除冲突反例
//
// 阶段二(后校验,仅合并候选):
// 4. 对合并后的完整数据重跑反例校验 → 冲突则整条回退到合并前状态
func (s *knowledgeService) validateEntriesForAppendModeWithProgress(ctx context.Context,
tenantID uint64, kbID string, entries []types.FAQEntryPayload, progress *types.FAQImportProgress,
) []int {
totalEntries := len(entries)
// 查询知识库中已有的所有 FAQ chunks 的 metadata
existingChunks, err := s.chunkRepo.ListAllFAQChunksWithMetadataByKnowledgeBaseID(ctx, tenantID, kbID)
if err != nil {
logger.Warnf(ctx, "Failed to list existing FAQ chunks for dry run: %v", err)
}
// 构建已存在的标准问 → chunk 映射(用于合并候选识别)
existingStdQToChunk := make(map[string]*types.Chunk)
// 构建已存在的所有问题 → 所属 chunkID 映射(用于相似问冲突检测)
existingQuestionToChunkID := make(map[string]string)
// 构建每个 chunk 拥有的问题集合(用于合并时排除自身)
existingChunkQuestions := make(map[string]map[string]bool)
// 构建 chunkID → 标准问 映射(用于冲突失败原因展示)
existingChunkIDToStdQ := make(map[string]string)
for _, chunk := range existingChunks {
meta, err := chunk.FAQMetadata()
if err != nil || meta == nil {
continue
}
qs := make(map[string]bool)
if meta.StandardQuestion != "" {
existingStdQToChunk[meta.StandardQuestion] = chunk
existingQuestionToChunkID[meta.StandardQuestion] = chunk.ID
qs[meta.StandardQuestion] = true
}
for _, q := range meta.SimilarQuestions {
if q != "" {
existingQuestionToChunkID[q] = chunk.ID
qs[q] = true
}
}
existingChunkQuestions[chunk.ID] = qs
existingChunkIDToStdQ[chunk.ID] = meta.StandardQuestion
}
// 合并候选跟踪:entry index → 目标已有 chunk
mergeChunkMap := make(map[int]*types.Chunk)
// ==================== 第一次迭代:基本格式验证 + 文件内标准问去重 + 合并候选识别 ====================
batchStandardQuestions := make(map[string]int) // value 为首次出现的索引
validIndicesAfterStdQ := make([]int, 0, totalEntries)
for i, entry := range entries {
if err := validateFAQEntryPayloadBasic(&entry); err != nil {
progress.FailedCount++
fe := buildFAQFailedEntry(i, err.Error(), &entry)
fe.FailureType = "pre_validation"
progress.FailedEntries = append(progress.FailedEntries, fe)
continue
}
standardQ := strings.TrimSpace(entry.StandardQuestion)
// 文件内标准问去重
if firstIdx, exists := batchStandardQuestions[standardQ]; exists {
progress.FailedCount++
fe := buildFAQFailedEntry(i, fmt.Sprintf("标准问冲突:与批次内第 %d 条标准问重复", firstIdx+1), &entry)
fe.FailureType = "pre_validation"
progress.FailedEntries = append(progress.FailedEntries, fe)
continue
}
// 判断是否为合并候选
if chunk, exists := existingStdQToChunk[standardQ]; exists {
// 标准问在 KB 中已存在 → 标记为合并候选
mergeChunkMap[i] = chunk
logger.Infof(ctx, "FAQ entry %d: standard question '%s' exists in KB, marking as merge candidate (chunk_id=%s)", i, standardQ, chunk.ID)
} else if conflictChunkID, hit := existingQuestionToChunkID[standardQ]; hit {
// 标准问未与任何已有标准问重复,但撞上了 KB 中其他条目的相似问
// → 前置校验失败,避免下游 calculateAppendOperations 静默丢弃导致统计失真
conflictStdQ := existingChunkIDToStdQ[conflictChunkID]
progress.FailedCount++
fe := buildFAQFailedEntry(i,
fmt.Sprintf(`标准问冲突:与知识库中标准问"%s"的相似问"%s"重复`, conflictStdQ, standardQ),
&entry)
fe.FailureType = "pre_validation"
progress.FailedEntries = append(progress.FailedEntries, fe)
logger.Infof(ctx,
"FAQ entry %d: standard question '%s' conflicts with existing similar question of chunk_id=%s (std='%s')",
i, standardQ, conflictChunkID, conflictStdQ)
continue
}
batchStandardQuestions[standardQ] = i
validIndicesAfterStdQ = append(validIndicesAfterStdQ, i)
if (i+1)%100 == 0 {
progress.Message = fmt.Sprintf("正在验证标准问 %d/%d...", i+1, totalEntries)
progress.UpdatedAt = time.Now().Unix()
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to update FAQ dry run progress: %v", err)
}
}
}
// ==================== 第二次迭代:相似问冲突检测 ====================
// 构建批次内所有标准问 + 相似问的集合
batchAllQuestions := make(map[string]int)
for _, i := range validIndicesAfterStdQ {
standardQ := strings.TrimSpace(entries[i].StandardQuestion)
batchAllQuestions[standardQ] = i
for _, q := range entries[i].SimilarQuestions {
q = strings.TrimSpace(q)
if q != "" {
if _, exists := batchAllQuestions[q]; !exists {
batchAllQuestions[q] = i
}
}
}
}
removedSimilarQuestionsMap := make(map[int][]string)
removedNegativeQuestionsMap := make(map[int][]string)
for idx, i := range validIndicesAfterStdQ {
entry := &entries[i]
standardQ := strings.TrimSpace(entry.StandardQuestion)
// 合并候选:获取目标 chunk 自身的问题集合,用于排除
var ownChunkQuestions map[string]bool
if mergeChunk, isMerge := mergeChunkMap[i]; isMerge {
ownChunkQuestions = existingChunkQuestions[mergeChunk.ID]
}
validSimilarQuestions := make([]string, 0, len(entry.SimilarQuestions))
var removedSimilarQuestions []string
for _, q := range entry.SimilarQuestions {
q = strings.TrimSpace(q)
if q == "" {
continue
}
// 相似问不能与自己的标准问相同
if q == standardQ {
removedSimilarQuestions = append(removedSimilarQuestions, fmt.Sprintf(`「相似问冲突」:"%s"与本条"标准问"冲突`, q))
continue
}
// 相似问校验:对比知识库已有标准问 + 相似问
if _, exists := existingQuestionToChunkID[q]; exists {
// 合并候选:如果问题属于合并目标自身的 chunk,允许(去重合并时会处理)
if ownChunkQuestions != nil && ownChunkQuestions[q] {
validSimilarQuestions = append(validSimilarQuestions, q)
continue
}
removedSimilarQuestions = append(removedSimilarQuestions, fmt.Sprintf(`「相似问冲突」:"%s"与知识库已有"标准问/相似问"冲突`, q))
continue
}
// 相似问校验:对比批次内标准问 + 相似问
if firstIdx, exists := batchAllQuestions[q]; exists && firstIdx != i {
removedSimilarQuestions = append(removedSimilarQuestions, fmt.Sprintf(`「相似问冲突」:"%s"与第 %d 行"标准问/相似问"冲突`, q, firstIdx+1))
continue
}
validSimilarQuestions = append(validSimilarQuestions, q)
}
entries[i].SimilarQuestions = validSimilarQuestions
if len(removedSimilarQuestions) > 0 {
removedSimilarQuestionsMap[i] = removedSimilarQuestions
}
if (idx+1)%100 == 0 {
progress.Message = fmt.Sprintf("正在验证相似问 %d/%d...", idx+1, len(validIndicesAfterStdQ))
progress.UpdatedAt = time.Now().Unix()
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to update FAQ dry run progress: %v", err)
}
}
}
// ==================== 第三次迭代:反例冲突检测(预校验,仅检查新条目自身数据) ====================
for idx, i := range validIndicesAfterStdQ {
entry := &entries[i]
standardQ := strings.TrimSpace(entry.StandardQuestion)
currentQAQuestions := make(map[string]bool)
currentQAQuestions[standardQ] = true
for _, q := range entry.SimilarQuestions {
currentQAQuestions[q] = true
}
validNegativeQuestions := make([]string, 0, len(entry.NegativeQuestions))
var removedNegativeQuestions []string
for _, q := range entry.NegativeQuestions {
q = strings.TrimSpace(q)
if q == "" {
continue
}
if currentQAQuestions[q] {
removedNegativeQuestions = append(removedNegativeQuestions, fmt.Sprintf(`「反例冲突」:"%s"与本条"标准问/相似问"冲突`, q))
continue
}
validNegativeQuestions = append(validNegativeQuestions, q)
}
entries[i].NegativeQuestions = validNegativeQuestions
if len(removedNegativeQuestions) > 0 {
removedNegativeQuestionsMap[i] = removedNegativeQuestions
}
if (idx+1)%100 == 0 {
progress.Message = fmt.Sprintf("正在验证反例 %d/%d...", idx+1, len(validIndicesAfterStdQ))
progress.UpdatedAt = time.Now().Unix()
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to update FAQ dry run progress: %v", err)
}
}
}
// ==================== 第四次迭代:后校验(仅合并候选) ====================
// 对合并后的完整数据重跑反例校验,冲突则整条回退到合并前状态
postValidationFailed := make(map[int]bool)
mergeCount := 0
for _, i := range validIndicesAfterStdQ {
mergeChunk, isMerge := mergeChunkMap[i]
if !isMerge {
continue
}
existingMeta, err := mergeChunk.FAQMetadata()
if err != nil || existingMeta == nil {
logger.Warnf(ctx, "FAQ entry %d: failed to get merge target metadata, skipping post-validation", i)
continue
}
entry := &entries[i]
// 计算合并后的完整数据
mergedSimilar := unionStrings(existingMeta.SimilarQuestions, entry.SimilarQuestions)
mergedNegative := unionStrings(existingMeta.NegativeQuestions, entry.NegativeQuestions)
// 构建合并后的冲突集合(标准问 + 所有合并后的相似问)
mergedPositiveSet := make(map[string]bool)
mergedPositiveSet[existingMeta.StandardQuestion] = true
for _, q := range mergedSimilar {
mergedPositiveSet[q] = true
}
// 检查每个合并后的反例是否与合并后的标准问 / 相似问冲突
var conflictingNegatives []string
for _, q := range mergedNegative {
if mergedPositiveSet[q] {
conflictingNegatives = append(conflictingNegatives, q)
}
}
if len(conflictingNegatives) > 0 {
// 后校验失败 → 整条回退到合并前状态
postValidationFailed[i] = true
delete(mergeChunkMap, i)
progress.FailedCount++
reason := fmt.Sprintf("后校验失败:合并后反例「%s」与相似问冲突", strings.Join(conflictingNegatives, "、"))
fe := buildFAQFailedEntry(i, reason, entry)
fe.FailureType = "post_validation"
progress.FailedEntries = append(progress.FailedEntries, fe)
logger.Infof(ctx, "FAQ entry %d: post-validation failed, conflicting negatives after merge: %v", i, conflictingNegatives)
} else {
mergeCount++
}
}
// 从有效索引中移除后校验失败的条目
if len(postValidationFailed) > 0 {
filtered := make([]int, 0, len(validIndicesAfterStdQ))
for _, i := range validIndicesAfterStdQ {
if !postValidationFailed[i] {
filtered = append(filtered, i)
}
}
validIndicesAfterStdQ = filtered
}
// 将部分失败信息添加到 FailedEntries
for _, i := range validIndicesAfterStdQ {
removedSimilar := removedSimilarQuestionsMap[i]
removedNegative := removedNegativeQuestionsMap[i]
if len(removedSimilar) > 0 || len(removedNegative) > 0 {
pf := buildFAQPartialFailedEntry(i, &entries[i], removedSimilar, removedNegative)
progress.FailedEntries = append(progress.FailedEntries, pf)
progress.PartialFailedCount++
}
}
// 记录合并条目索引到 progress(用于执行阶段和重试)
mergeIndices := make([]int, 0, mergeCount)
for _, i := range validIndicesAfterStdQ {
if _, isMerge := mergeChunkMap[i]; isMerge {
mergeIndices = append(mergeIndices, i)
}
}
progress.MergeEntryIndices = mergeIndices
logger.Infof(ctx, "Append mode validation completed: total=%d, valid=%d, merge_candidates=%d, failed=%d, partial_failed=%d",
totalEntries, len(validIndicesAfterStdQ), mergeCount, progress.FailedCount, progress.PartialFailedCount)
return validIndicesAfterStdQ
}
// validateEntriesForReplaceModeWithProgress 验证 Replace 模式下的条目(带进度更新)
// 注意:验证阶段不更新 Processed,只有实际导入时才更新
// 分三次迭代校验,确保前面过滤掉的数据不会被后面考虑:
// 1. 标准问 - 对比所有标准问 → 整条QA失败「标准问冲突」
// 2. 相似问 - 对比所有标准问+相似问 → 单条问法失败「相似问冲突」(仅移除冲突的相似问)
// 3. 反例 - 对比当前QA下所有标准问+相似问 → 单条问法失败「反例冲突」(仅移除冲突的反例)
func (s *knowledgeService) validateEntriesForReplaceModeWithProgress(ctx context.Context,
entries []types.FAQEntryPayload, progress *types.FAQImportProgress,
) []int {
totalEntries := len(entries)
// ==================== 第一次迭代:基本格式验证 + 标准问冲突检测 ====================
// 标准问冲突会导致整条QA失败
batchStandardQuestions := make(map[string]int) // value 为首次出现的索引
validIndicesAfterStdQ := make([]int, 0, totalEntries)
for i, entry := range entries {
// 验证条目基本格式
if err := validateFAQEntryPayloadBasic(&entry); err != nil {
progress.FailedCount++
progress.FailedEntries = append(progress.FailedEntries, buildFAQFailedEntry(i, err.Error(), &entry))
continue
}
standardQ := strings.TrimSpace(entry.StandardQuestion)
// 标准问校验:对比所有标准问 → 整条QA失败「标准问冲突」
if firstIdx, exists := batchStandardQuestions[standardQ]; exists {
progress.FailedCount++
progress.FailedEntries = append(progress.FailedEntries, buildFAQFailedEntry(i, fmt.Sprintf("标准问冲突:与批次内第 %d 条标准问重复", firstIdx+1), &entry))
continue
}
// 记录标准问
batchStandardQuestions[standardQ] = i
validIndicesAfterStdQ = append(validIndicesAfterStdQ, i)
// 定期更新进度消息
if (i+1)%100 == 0 {
progress.Message = fmt.Sprintf("正在验证标准问 %d/%d...", i+1, totalEntries)
progress.UpdatedAt = time.Now().Unix()
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to update FAQ dry run progress: %v", err)
}
}
}
// ==================== 第二次迭代:相似问冲突检测 ====================
// 只处理通过第一次校验的条目,相似问冲突只移除冲突的相似问
// 构建所有标准问+相似问的集合(只包含通过第一次校验的条目)
batchAllQuestions := make(map[string]int) // value 为首次出现的索引
for _, i := range validIndicesAfterStdQ {
standardQ := strings.TrimSpace(entries[i].StandardQuestion)
batchAllQuestions[standardQ] = i
for _, q := range entries[i].SimilarQuestions {
q = strings.TrimSpace(q)
if q != "" {
// 只记录第一次出现的位置
if _, exists := batchAllQuestions[q]; !exists {
batchAllQuestions[q] = i
}
}
}
}
// 用于收集每个条目被移除的相似问和反例
removedSimilarQuestionsMap := make(map[int][]string) // key 为条目索引
removedNegativeQuestionsMap := make(map[int][]string) // key 为条目索引
// 对每个通过第一次校验的条目,过滤冲突的相似问
for idx, i := range validIndicesAfterStdQ {
entry := &entries[i]
standardQ := strings.TrimSpace(entry.StandardQuestion)
validSimilarQuestions := make([]string, 0, len(entry.SimilarQuestions))
var removedSimilarQuestions []string
for _, q := range entry.SimilarQuestions {
q = strings.TrimSpace(q)
if q == "" {
continue
}
// 相似问校验:对比所有标准问+相似问 → 单条问法失败「相似问冲突」
// 如果该相似问与其他条目的标准问或相似问冲突(且不是自己的标准问),则移除
if firstIdx, exists := batchAllQuestions[q]; exists && firstIdx != i {
logger.Infof(ctx, "FAQ entry %d: similar question '%s' conflicts with entry %d, removing", i, q, firstIdx+1)
removedSimilarQuestions = append(removedSimilarQuestions, fmt.Sprintf(`「相似问冲突」:"%s"与第 %d 行"标准问/相似问"冲突`, q, firstIdx+1))
continue
}
// 相似问不能与自己的标准问相同
if q == standardQ {
logger.Infof(ctx, "FAQ entry %d: similar question '%s' same as standard question, removing", i, q)
removedSimilarQuestions = append(removedSimilarQuestions, fmt.Sprintf(`「相似问冲突」:"%s"与本条"标准问"冲突`, q))
continue
}
validSimilarQuestions = append(validSimilarQuestions, q)
}
entries[i].SimilarQuestions = validSimilarQuestions
// 记录被移除的相似问
if len(removedSimilarQuestions) > 0 {
removedSimilarQuestionsMap[i] = removedSimilarQuestions
}
// 定期更新进度消息
if (idx+1)%100 == 0 {
progress.Message = fmt.Sprintf("正在验证相似问 %d/%d...", idx+1, len(validIndicesAfterStdQ))
progress.UpdatedAt = time.Now().Unix()
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to update FAQ dry run progress: %v", err)
}
}
}
// ==================== 第三次迭代:反例冲突检测 ====================
// 只处理通过前两次校验的条目,反例冲突只移除冲突的反例
for idx, i := range validIndicesAfterStdQ {
entry := &entries[i]
standardQ := strings.TrimSpace(entry.StandardQuestion)
// 构建当前QA的所有问题集合(标准问+通过校验的相似问)
currentQAQuestions := make(map[string]bool)
currentQAQuestions[standardQ] = true
for _, q := range entry.SimilarQuestions {
currentQAQuestions[q] = true
}
validNegativeQuestions := make([]string, 0, len(entry.NegativeQuestions))
var removedNegativeQuestions []string
for _, q := range entry.NegativeQuestions {
q = strings.TrimSpace(q)
if q == "" {
continue
}
// 反例校验:对比当前QA下所有标准问+相似问 → 单条问法失败「反例冲突」
if currentQAQuestions[q] {
logger.Infof(ctx, "FAQ entry %d: negative question '%s' conflicts with current QA's questions, removing", i, q)
removedNegativeQuestions = append(removedNegativeQuestions, fmt.Sprintf(`「反例冲突」:"%s"与本条"标准问/相似问"冲突`, q))
continue
}
validNegativeQuestions = append(validNegativeQuestions, q)
}
entries[i].NegativeQuestions = validNegativeQuestions
// 记录被移除的反例
if len(removedNegativeQuestions) > 0 {
removedNegativeQuestionsMap[i] = removedNegativeQuestions
}
// 定期更新进度消息
if (idx+1)%100 == 0 {
progress.Message = fmt.Sprintf("正在验证反例 %d/%d...", idx+1, len(validIndicesAfterStdQ))
progress.UpdatedAt = time.Now().Unix()
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to update FAQ dry run progress: %v", err)
}
}
}
// 将部分失败信息添加到 FailedEntries
for _, i := range validIndicesAfterStdQ {
removedSimilar := removedSimilarQuestionsMap[i]
removedNegative := removedNegativeQuestionsMap[i]
if len(removedSimilar) > 0 || len(removedNegative) > 0 {
pf := buildFAQPartialFailedEntry(i, &entries[i], removedSimilar, removedNegative)
progress.FailedEntries = append(progress.FailedEntries, pf)
progress.PartialFailedCount++
}
}
return validIndicesAfterStdQ
}
// unionStrings 合并两个字符串切片并去重(完全匹配)
func unionStrings(a, b []string) []string {
seen := make(map[string]bool, len(a)+len(b))
result := make([]string, 0, len(a)+len(b))
for _, s := range a {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
seen[s] = true
result = append(result, s)
}
}
for _, s := range b {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
seen[s] = true
result = append(result, s)
}
}
return result
}
// validateFAQEntryPayloadBasic 验证 FAQ 条目的基本格式
func validateFAQEntryPayloadBasic(entry *types.FAQEntryPayload) error {
if entry == nil {
return fmt.Errorf("条目不能为空")
}
standardQ := strings.TrimSpace(entry.StandardQuestion)
if standardQ == "" {
return fmt.Errorf("标准问不能为空")
}
if len(entry.Answers) == 0 {
return fmt.Errorf("答案不能为空")
}
hasValidAnswer := false
for _, a := range entry.Answers {
if strings.TrimSpace(a) != "" {
hasValidAnswer = true
break
}
}
if !hasValidAnswer {
return fmt.Errorf("答案不能全为空")
}
return nil
}
type faqMergeOperation struct {
Entry types.FAQEntryPayload
ExistingChunk *types.Chunk
OldMeta *types.FAQChunkMetadata
MergedMeta *types.FAQChunkMetadata
Detail types.FAQMergeDetail
}
// calculateAppendOperations 计算 Append 模式下的操作(支持智能合并)。
// 如果 entry 的标准问在 KB 中已存在,则视为合并操作(相似问 / 反例并集,
// 答案 / 策略以新条目为准);否则视为新建。无变化(hash 一致且操作位
// 也未变)的合并目标会被 skip 掉,避免无效写库 / 重建索引。
//
// 内部 master 行为,对应开源版仅 "重复 = 失败" 的简化逻辑。FAQ 导入的
// 常见使用方式是"导出修改后重新 append",需要这种合并语义才能正确叠加
// 新的相似问而不丢历史数据。
func (s *knowledgeService) calculateAppendOperations(ctx context.Context,
tenantID uint64, kbID string, entries []types.FAQEntryPayload,
) (newEntries []types.FAQEntryPayload, mergeOps []faqMergeOperation, skippedCount int, err error) {
if len(entries) == 0 {
return nil, nil, 0, nil
}
existingChunks, err := s.chunkRepo.ListAllFAQChunksWithMetadataByKnowledgeBaseID(ctx, tenantID, kbID)
if err != nil {
return nil, nil, 0, fmt.Errorf("failed to list existing FAQ chunks: %w", err)
}
existingStdQToChunk := make(map[string]*types.Chunk)
existingQuestions := make(map[string]bool)
for _, chunk := range existingChunks {
meta, cErr := chunk.FAQMetadata()
if cErr != nil || meta == nil {
continue
}
if meta.StandardQuestion != "" {
existingStdQToChunk[meta.StandardQuestion] = chunk
existingQuestions[meta.StandardQuestion] = true
}
for _, q := range meta.SimilarQuestions {
if q != "" {
existingQuestions[q] = true
}
}
}
batchQuestions := make(map[string]bool)
newEntries = make([]types.FAQEntryPayload, 0, len(entries))
mergeOps = make([]faqMergeOperation, 0)
for entryIdx, entry := range entries {
meta, sErr := sanitizeFAQEntryPayload(&entry)
if sErr != nil {
skippedCount++
logger.Warnf(ctx, "Skipping invalid FAQ entry: %v", sErr)
continue
}
// 检查标准问是否在 KB 中作为标准问存在 → 合并候选
existingChunk, isMergeCandidate := existingStdQToChunk[meta.StandardQuestion]
if isMergeCandidate {
existingMeta, mErr := existingChunk.FAQMetadata()
if mErr != nil || existingMeta == nil {
isMergeCandidate = false
} else {
mergedSimilar := unionStrings(existingMeta.SimilarQuestions, meta.SimilarQuestions)
mergedNegative := unionStrings(existingMeta.NegativeQuestions, meta.NegativeQuestions)
mergedMeta := &types.FAQChunkMetadata{
StandardQuestion: existingMeta.StandardQuestion,
SimilarQuestions: mergedSimilar,
NegativeQuestions: mergedNegative,
Answers: meta.Answers,
AnswerStrategy: meta.AnswerStrategy,
Version: existingMeta.Version + 1,
Source: existingMeta.Source,
}
newHash := types.CalculateFAQContentHash(mergedMeta)
enabledChanged := entry.IsEnabled != nil && *entry.IsEnabled != existingChunk.IsEnabled
recommendedChanged := entry.IsRecommended != nil &&
*entry.IsRecommended != existingChunk.Flags.HasFlag(types.ChunkFlagRecommended)
answerStrategyChanged := meta.AnswerStrategy != existingMeta.AnswerStrategy
if existingChunk.ContentHash == newHash && !enabledChanged && !recommendedChanged && !answerStrategyChanged {
skippedCount++
logger.Infof(ctx, "Skipping merge for unchanged FAQ entry: %s", meta.StandardQuestion)
continue
}
oldAnswerStr := strings.Join(existingMeta.Answers, "##")
newAnswerStr := strings.Join(meta.Answers, "##")
oldSimilarSet := make(map[string]bool, len(existingMeta.SimilarQuestions))
for _, q := range existingMeta.SimilarQuestions {
oldSimilarSet[q] = true
}
oldNegativeSet := make(map[string]bool, len(existingMeta.NegativeQuestions))
for _, q := range existingMeta.NegativeQuestions {
oldNegativeSet[q] = true
}
newSimilarCount := 0
for _, q := range mergedSimilar {
if !oldSimilarSet[q] {
newSimilarCount++
}
}
newNegativeCount := 0
for _, q := range mergedNegative {
if !oldNegativeSet[q] {
newNegativeCount++
}
}
mergeOps = append(mergeOps, faqMergeOperation{
Entry: entry,
ExistingChunk: existingChunk,
OldMeta: existingMeta,
MergedMeta: mergedMeta,
Detail: types.FAQMergeDetail{
Index: entryIdx,
StandardQuestion: meta.StandardQuestion,
AnswerChanged: oldAnswerStr != newAnswerStr,
NewSimilarCount: newSimilarCount,
NewNegativeCount: newNegativeCount,
},
})
continue
}
}
// 走到这里说明既不是合并候选,又跟现有 KB / 当前批次的标准问 / 相似问冲突。
// 正常情况下这些冲突应在 executeFAQDryRunValidation 阶段就被拦截,这里
// 仅作为防御性兜底;用 Warn 级别打点便于排查验证层漏检。
if existingQuestions[meta.StandardQuestion] || batchQuestions[meta.StandardQuestion] {
skippedCount++
logger.Warnf(ctx,
"calculateAppendOperations: dropping FAQ entry with duplicate standard question %q "+
"(should have been filtered at validation); entry_idx=%d",
meta.StandardQuestion, entryIdx)
continue
}
batchQuestions[meta.StandardQuestion] = true
for _, q := range meta.SimilarQuestions {
batchQuestions[q] = true
}
newEntries = append(newEntries, entry)
}
return newEntries, mergeOps, skippedCount, nil
}
// calculateReplaceOperations 计算Replace模式下需要删除、创建、更新的条目
// 同时过滤掉同批次内标准问或相似问重复的条目
func (s *knowledgeService) calculateReplaceOperations(ctx context.Context,
tenantID uint64, knowledgeID string, newEntries []types.FAQEntryPayload,
) ([]types.FAQEntryPayload, []*types.Chunk, int, error) {
// 获取 kbID 用于解析 tag
var kbID string
if len(newEntries) > 0 {
// 从 knowledgeID 获取 kbID
knowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, knowledgeID)
if err != nil {
return nil, nil, 0, fmt.Errorf("failed to get knowledge: %w", err)
}
if knowledge != nil {
kbID = knowledge.KnowledgeBaseID
}
}
// 计算所有新条目的 content hash,并同时构建 hash 到 entry 的映射
type entryWithHash struct {
entry types.FAQEntryPayload
hash string
meta *types.FAQChunkMetadata
}
entriesWithHash := make([]entryWithHash, 0, len(newEntries))
newHashSet := make(map[string]bool)
// 用于批次内标准问和相似问去重
batchQuestions := make(map[string]bool)
batchSkippedCount := 0
for _, entry := range newEntries {
meta, err := sanitizeFAQEntryPayload(&entry)
if err != nil {
batchSkippedCount++
logger.Warnf(ctx, "Skipping invalid FAQ entry in replace mode: %v", err)
continue
}
// 检查标准问是否在同批次中重复
if batchQuestions[meta.StandardQuestion] {
batchSkippedCount++
logger.Infof(ctx, "Skipping FAQ entry with duplicate standard question in batch: %s", meta.StandardQuestion)
continue
}
// 检查相似问是否在同批次中重复
hasDuplicateSimilar := false
for _, q := range meta.SimilarQuestions {
if batchQuestions[q] {
hasDuplicateSimilar = true
logger.Infof(ctx, "Skipping FAQ entry with duplicate similar question in batch: %s (standard: %s)", q, meta.StandardQuestion)
break
}
}
if hasDuplicateSimilar {
batchSkippedCount++
continue
}
// 将当前条目的标准问和相似问加入批次集合
batchQuestions[meta.StandardQuestion] = true
for _, q := range meta.SimilarQuestions {
batchQuestions[q] = true
}
hash := types.CalculateFAQContentHash(meta)
if hash != "" {
entriesWithHash = append(entriesWithHash, entryWithHash{entry: entry, hash: hash, meta: meta})
newHashSet[hash] = true
}
}
// 查询所有已存在的chunks
allExistingChunks, err := s.chunkRepo.ListAllFAQChunksByKnowledgeID(ctx, tenantID, knowledgeID)
if err != nil {
return nil, nil, 0, fmt.Errorf("failed to list existing chunks: %w", err)
}
// 在内存中过滤出匹配新条目hash的chunks,并构建map
existingHashMap := make(map[string]*types.Chunk)
for _, chunk := range allExistingChunks {
if chunk.ContentHash != "" && newHashSet[chunk.ContentHash] {
existingHashMap[chunk.ContentHash] = chunk
}
}
// 计算需要删除的chunks(数据库中有但新批次中没有的,或hash不匹配的)
chunksToDelete := make([]*types.Chunk, 0)
for _, chunk := range allExistingChunks {
if chunk.ContentHash == "" {
// 如果没有hash,需要删除(可能是旧数据)
chunksToDelete = append(chunksToDelete, chunk)
} else if !newHashSet[chunk.ContentHash] {
// hash不在新条目中,需要删除
chunksToDelete = append(chunksToDelete, chunk)
}
}
// 批量预加载 tag 信息,避免循环内逐条查询数据库
// 收集所有需要查询的 tag_id 和 tag_name
tagSeqIDSet := make(map[int64]bool)
tagNameSet := make(map[string]bool)
for _, ewh := range entriesWithHash {
if ewh.entry.TagID != 0 {
tagSeqIDSet[ewh.entry.TagID] = true
} else if ewh.entry.TagName != "" {
tagNameSet[ewh.entry.TagName] = true
} else {
tagNameSet[types.UntaggedTagName] = true
}
}
// 批量查询 tag by seq_id
tagSeqIDToUUID := make(map[int64]string)
if len(tagSeqIDSet) > 0 {
seqIDs := make([]int64, 0, len(tagSeqIDSet))
for seqID := range tagSeqIDSet {
seqIDs = append(seqIDs, seqID)
}
tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, seqIDs)
if err != nil {
logger.Warnf(ctx, "Failed to batch load tags by seq_ids: %v", err)
} else {
for _, tag := range tags {
tagSeqIDToUUID[tag.SeqID] = tag.ID
}
}
}
// 批量查询 tag by name
tagNameToUUID := make(map[string]string)
if len(tagNameSet) > 0 && kbID != "" {
for name := range tagNameSet {
if tag, err := s.tagRepo.GetByName(ctx, tenantID, kbID, name); err == nil && tag != nil {
tagNameToUUID[name] = tag.ID
}
}
}
logger.Infof(ctx, "Preloaded %d tags by seq_id, %d tags by name for %d entries",
len(tagSeqIDToUUID), len(tagNameToUUID), len(entriesWithHash))
// resolveTagIDFromCache 从缓存中解析 tag ID,缓存未命中时回退到数据库查询
resolveTagIDFromCache := func(entry *types.FAQEntryPayload) (string, error) {
if entry.TagID != 0 {
if uuid, ok := tagSeqIDToUUID[entry.TagID]; ok {
return uuid, nil
}
// 缓存未命中,回退到数据库查询(可能需要创建)
return s.resolveTagID(ctx, kbID, entry)
}
tagName := entry.TagName
if tagName == "" {
tagName = types.UntaggedTagName
}
if uuid, ok := tagNameToUUID[tagName]; ok {
return uuid, nil
}
// 缓存未命中,回退到数据库查询(可能需要创建)
return s.resolveTagID(ctx, kbID, entry)
}
// 计算需要创建的条目(利用已经计算好的hash,避免重复计算)
entriesToProcess := make([]types.FAQEntryPayload, 0, len(entriesWithHash))
skippedCount := batchSkippedCount
for idx, ewh := range entriesWithHash {
// 每处理 1000 条打印一次进度日志
if idx > 0 && idx%1000 == 0 {
logger.Infof(ctx, "calculateReplaceOperations progress: %d/%d entries processed", idx, len(entriesWithHash))
}
existingChunk := existingHashMap[ewh.hash]
if existingChunk != nil {
// hash 匹配,检查 tag 是否变化
newTagID, err := resolveTagIDFromCache(&ewh.entry)
if err != nil {
logger.Warnf(ctx, "Failed to resolve tag for entry, treating as new: %v", err)
entriesToProcess = append(entriesToProcess, ewh.entry)
continue
}
enabledChanged := ewh.entry.IsEnabled != nil && *ewh.entry.IsEnabled != existingChunk.IsEnabled
recommendedChanged := ewh.entry.IsRecommended != nil &&
*ewh.entry.IsRecommended != existingChunk.Flags.HasFlag(types.ChunkFlagRecommended)
answerStrategyChanged := false
if existingMeta, metaErr := existingChunk.FAQMetadata(); metaErr == nil && existingMeta != nil {
answerStrategyChanged = ewh.meta.AnswerStrategy != existingMeta.AnswerStrategy
}
if existingChunk.TagID != newTagID || enabledChanged || recommendedChanged || answerStrategyChanged {
if existingChunk.TagID != newTagID {
logger.Infof(ctx, "FAQ entry tag changed from %s to %s, will update", existingChunk.TagID, newTagID)
}
if enabledChanged || recommendedChanged || answerStrategyChanged {
logger.Infof(ctx, "FAQ entry operational fields changed (enabled: %v->%v, recommended: %v->%v, answerStrategy changed: %v), will update",
existingChunk.IsEnabled, ewh.entry.IsEnabled,
existingChunk.Flags.HasFlag(types.ChunkFlagRecommended), ewh.entry.IsRecommended,
answerStrategyChanged)
}
chunksToDelete = append(chunksToDelete, existingChunk)
entriesToProcess = append(entriesToProcess, ewh.entry)
} else {
// hash、tag、运营状态都相同,跳过
skippedCount++
}
continue
}
// hash 不匹配或不存在,需要创建
entriesToProcess = append(entriesToProcess, ewh.entry)
}
return entriesToProcess, chunksToDelete, skippedCount, nil
}
// executeFAQImport 执行实际的FAQ导入逻辑
func (s *knowledgeService) executeFAQImport(ctx context.Context, taskID string, kbID string,
payload *types.FAQBatchUpsertPayload, tenantID uint64, processedCount int,
progress *types.FAQImportProgress,
) (err error) {
// 保存知识库和embedding模型信息,用于清理索引
var kb *types.KnowledgeBase
var embeddingModel embedding.Embedder
totalEntries := len(payload.Entries) + processedCount
// Recovery机制:如果发生任何错误或panic,回滚所有已创建的chunks和索引数据
defer func() {
// 捕获panic
if r := recover(); r != nil {
buf := make([]byte, 8192)
n := runtime.Stack(buf, false)
stack := string(buf[:n])
logger.Errorf(ctx, "FAQ import task %s panicked: %v\n%s", taskID, r, stack)
err = fmt.Errorf("panic during FAQ import: %v", r)
}
}()
kb, err = s.validateFAQKnowledgeBase(ctx, kbID)
if err != nil {
return err
}
kb.EnsureDefaults()
// 获取embedding模型,用于后续清理索引
embeddingModel, err = s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
if err != nil {
return fmt.Errorf("failed to get embedding model: %w", err)
}
faqKnowledge, err := s.ensureFAQKnowledge(ctx, tenantID, kb)
if err != nil {
return err
}
// 获取索引模式
indexMode := types.FAQIndexModeQuestionOnly
if kb.FAQConfig != nil && kb.FAQConfig.IndexMode != "" {
indexMode = kb.FAQConfig.IndexMode
}
// 增量更新逻辑:计算需要处理的条目
var entriesToProcess []types.FAQEntryPayload
var chunksToDelete []*types.Chunk
var skippedCount int
if payload.Mode == types.FAQBatchModeReplace {
// Replace模式:计算需要删除、创建、更新的条目
entriesToProcess, chunksToDelete, skippedCount, err = s.calculateReplaceOperations(
ctx,
tenantID,
faqKnowledge.ID,
payload.Entries,
)
if err != nil {
return fmt.Errorf("failed to calculate replace operations: %w", err)
}
// 删除需要删除的chunks(包括需要更新的旧chunks)
if len(chunksToDelete) > 0 {
chunkIDsToDelete := make([]string, 0, len(chunksToDelete))
for _, chunk := range chunksToDelete {
chunkIDsToDelete = append(chunkIDsToDelete, chunk.ID)
}
if err := s.chunkRepo.DeleteChunks(ctx, tenantID, chunkIDsToDelete); err != nil {
return fmt.Errorf("failed to delete chunks: %w", err)
}
// 删除索引
if err := s.deleteFAQChunkVectors(ctx, kb, faqKnowledge, chunksToDelete); err != nil {
return fmt.Errorf("failed to delete chunk vectors: %w", err)
}
logger.Infof(ctx, "FAQ import task %s: deleted %d chunks (including updates)", taskID, len(chunksToDelete))
}
} else {
// Append 模式(智能合并):标准问已存在的条目走 merge ops,其余作为新建。
var mergeOps []faqMergeOperation
entriesToProcess, mergeOps, skippedCount, err = s.calculateAppendOperations(ctx, tenantID, kb.ID, payload.Entries)
if err != nil {
return fmt.Errorf("failed to calculate append operations: %w", err)
}
if len(mergeOps) > 0 {
mergedCount, mergeErr := s.executeFAQMergeOperations(ctx, taskID, kb, faqKnowledge, embeddingModel, indexMode, mergeOps, progress)
if mergeErr != nil {
return fmt.Errorf("failed to execute merge operations: %w", mergeErr)
}
logger.Infof(ctx, "FAQ import task %s: merged %d entries", taskID, mergedCount)
progress.MergedCount = mergedCount
for _, op := range mergeOps {
progress.MergeDetails = append(progress.MergeDetails, op.Detail)
}
}
}
logger.Infof(
ctx,
"FAQ import task %s: total entries: %d, new to create: %d, skipped: %d, merged: %d",
taskID,
len(payload.Entries),
len(entriesToProcess),
skippedCount,
progress.MergedCount,
)
// 如果没有需要处理的条目,直接返回
if len(entriesToProcess) == 0 {
logger.Infof(ctx, "FAQ import task %s: no new entries to create", taskID)
return nil
}
// 分批处理需要创建的条目
remainingEntries := len(entriesToProcess)
totalStartTime := time.Now()
actualProcessed := skippedCount + processedCount + progress.MergedCount
logger.Infof(
ctx,
"FAQ import task %s: starting batch processing, remaining entries: %d, total entries: %d, batch size: %d",
taskID,
remainingEntries,
totalEntries,
faqImportBatchSize,
)
for i := 0; i < remainingEntries; i += faqImportBatchSize {
batchStartTime := time.Now()
end := i + faqImportBatchSize
if end > remainingEntries {
end = remainingEntries
}
batch := entriesToProcess[i:end]
logger.Infof(ctx, "FAQ import task %s: processing batch %d-%d (%d entries)", taskID, i+1, end, len(batch))
// 构建chunks
buildStartTime := time.Now()
chunks := make([]*types.Chunk, 0, len(batch))
chunkIds := make([]string, 0, len(batch))
for idx, entry := range batch {
meta, err := sanitizeFAQEntryPayload(&entry)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"entry": entry,
"task_id": taskID,
})
return fmt.Errorf("failed to sanitize entry at index %d: %w", i+idx, err)
}
// 解析 TagID
tagID, err := s.resolveTagID(ctx, kbID, &entry)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"entry": entry,
"task_id": taskID,
})
return fmt.Errorf("failed to resolve tag for entry at index %d: %w", i+idx, err)
}
isEnabled := true
if entry.IsEnabled != nil {
isEnabled = *entry.IsEnabled
}
// ChunkIndex计算:startChunkIndex + (i+idx) + initialProcessed
chunk := &types.Chunk{
ID: uuid.New().String(),
TenantID: tenantID,
KnowledgeID: faqKnowledge.ID,
KnowledgeBaseID: kb.ID,
Content: buildFAQChunkContent(meta, indexMode),
// ChunkIndex: 0,
IsEnabled: isEnabled,
ChunkType: types.ChunkTypeFAQ,
TagID: tagID, // 使用解析后的 TagID
Status: int(types.ChunkStatusStored), // store but not indexed
}
// 如果指定了 ID(用于数据迁移),设置 SeqID
if entry.ID != nil && *entry.ID > 0 {
chunk.SeqID = *entry.ID
}
if err := chunk.SetFAQMetadata(meta); err != nil {
return fmt.Errorf("failed to set FAQ metadata: %w", err)
}
chunks = append(chunks, chunk)
chunkIds = append(chunkIds, chunk.ID)
}
buildDuration := time.Since(buildStartTime)
logger.Debugf(ctx, "FAQ import task %s: batch %d-%d built %d chunks in %v, chunk IDs: %v",
taskID, i+1, end, len(chunks), buildDuration, chunkIds)
// 创建chunks
createStartTime := time.Now()
if err := s.chunkService.CreateChunks(ctx, chunks); err != nil {
return fmt.Errorf("failed to create chunks: %w", err)
}
createDuration := time.Since(createStartTime)
logger.Infof(
ctx,
"FAQ import task %s: batch %d-%d created %d chunks in %v",
taskID,
i+1,
end,
len(chunks),
createDuration,
)
// 索引chunks
indexStartTime := time.Now()
// 注意:如果索引失败,defer中的recovery机制会自动回滚已创建的chunks和索引数据
if err := s.indexFAQChunks(ctx, kb, faqKnowledge, chunks, embeddingModel, true, false); err != nil {
return fmt.Errorf("failed to index chunks: %w", err)
}
indexDuration := time.Since(indexStartTime)
logger.Infof(
ctx,
"FAQ import task %s: batch %d-%d indexed %d chunks in %v",
taskID,
i+1,
end,
len(chunks),
indexDuration,
)
// 更新chunks的Status为已索引
chunksToUpdate := make([]*types.Chunk, 0, len(chunks))
for _, chunk := range chunks {
chunk.Status = int(types.ChunkStatusIndexed) // indexed
chunksToUpdate = append(chunksToUpdate, chunk)
}
if err := s.chunkService.UpdateChunks(ctx, chunksToUpdate); err != nil {
return fmt.Errorf("failed to update chunks status: %w", err)
}
// 收集成功条目信息
for idx, chunk := range chunks {
entryIdx := i + idx + processedCount // 原始条目索引
meta, _ := chunk.FAQMetadata()
standardQ := ""
if meta != nil {
standardQ = meta.StandardQuestion
}
// 获取 tag info
var tagID int64
tagName := ""
if chunk.TagID != "" {
if tag, err := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID); err == nil && tag != nil {
tagID = tag.SeqID
tagName = tag.Name
}
}
progress.SuccessEntries = append(progress.SuccessEntries, types.FAQSuccessEntry{
Index: entryIdx,
SeqID: chunk.SeqID,
TagID: tagID,
TagName: tagName,
StandardQuestion: standardQ,
})
}
actualProcessed += len(batch)
// 更新任务进度
progress := int(float64(actualProcessed) / float64(totalEntries) * 100)
if err := s.updateFAQImportProgressStatus(ctx, taskID, "", 0, types.FAQImportStatusProcessing, progress, totalEntries, actualProcessed, fmt.Sprintf("正在处理第 %d/%d 条", actualProcessed, totalEntries), ""); err != nil {
logger.Errorf(ctx, "Failed to update task progress: %v", err)
}
batchDuration := time.Since(batchStartTime)
logger.Infof(
ctx,
"FAQ import task %s: batch %d-%d completed in %v (build: %v, create: %v, index: %v), total progress: %d/%d (%d%%)",
taskID,
i+1,
end,
batchDuration,
buildDuration,
createDuration,
indexDuration,
actualProcessed,
totalEntries,
progress,
)
}
totalDuration := time.Since(totalStartTime)
var avgPerEntry time.Duration
if actualProcessed > 0 {
avgPerEntry = totalDuration / time.Duration(actualProcessed)
}
logger.Infof(
ctx,
"FAQ import task %s: all batches completed, processed: %d entries (skipped: %d) in %v, avg: %v per entry",
taskID,
actualProcessed,
skippedCount,
totalDuration,
avgPerEntry,
)
return nil
}
// updateFAQImportProgressStatus updates the FAQ import progress in Redis
func (s *knowledgeService) updateFAQImportProgressStatus(
ctx context.Context,
taskID string,
instanceID string,
enqueuedAt int64,
status types.FAQImportTaskStatus,
progress, total, processed int,
message, errorMsg string,
) error {
// Get existing progress from Redis
existingProgress, err := s.GetFAQImportProgress(ctx, taskID)
if err != nil {
// If not found, create a new progress entry
existingProgress = &types.FAQImportProgress{
TaskID: taskID,
CreatedAt: time.Now().Unix(),
}
}
// Update progress fields
existingProgress.Status = status
existingProgress.Progress = progress
existingProgress.Total = total
existingProgress.Processed = processed
if message != "" {
existingProgress.Message = message
}
existingProgress.Error = errorMsg
if status == types.FAQImportStatusCompleted {
existingProgress.Error = ""
}
// 任务完成或失败时,清除 running key
if status == types.FAQImportStatusCompleted || status == types.FAQImportStatusFailed {
if existingProgress.KBID != "" {
if clearErr := s.clearRunningFAQImportInfoIfMatches(ctx, existingProgress.KBID, taskID, instanceID, enqueuedAt); clearErr != nil {
logger.Errorf(ctx, "Failed to clear running FAQ import task ID: %v", clearErr)
}
}
}
return s.saveFAQImportProgress(ctx, existingProgress)
}
// cleanupFAQEntriesFileOnFinalFailure 在任务最终失败时清理对象存储中的 entries 文件
// 只有当 retryCount >= maxRetry 时才执行清理,否则重试时还需要使用这个文件
func (s *knowledgeService) cleanupFAQEntriesFileOnFinalFailure(ctx context.Context, entriesURL string, retryCount, maxRetry int) {
if entriesURL == "" || retryCount < maxRetry {
return
}
if err := s.fileSvc.DeleteFile(ctx, entriesURL); err != nil {
logger.Warnf(ctx, "Failed to delete FAQ entries file from object storage on final failure: %v", err)
} else {
logger.Infof(ctx, "Deleted FAQ entries file from object storage on final failure: %s", entriesURL)
}
}
// runningFAQImportInfo stores the task ID and enqueued timestamp for uniquely identifying a task instance
type runningFAQImportInfo struct {
TaskID string `json:"task_id"`
EnqueuedAt int64 `json:"enqueued_at"`
InstanceID string `json:"instance_id,omitempty"`
}
// getRunningFAQImportInfo checks if there's a running FAQ import task for the given KB
// Returns the task info if found, nil otherwise
func (s *knowledgeService) getRunningFAQImportInfo(ctx context.Context, kbID string) (*runningFAQImportInfo, error) {
if s.redisClient == nil {
if v, ok := s.memFAQRunningImport.Load(kbID); ok {
return v.(*runningFAQImportInfo), nil
}
return nil, nil
}
key := getFAQImportRunningKey(kbID)
data, err := s.redisClient.Get(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, nil
}
return nil, fmt.Errorf("failed to get running FAQ import task: %w", err)
}
// Try to parse as JSON first (new format)
var info runningFAQImportInfo
if err := json.Unmarshal([]byte(data), &info); err != nil {
// Fallback: old format was just taskID string
return &runningFAQImportInfo{TaskID: data, EnqueuedAt: 0}, nil
}
return &info, nil
}
// getRunningFAQImportTaskID checks if there's a running FAQ import task for the given KB
// Returns the task ID if found, empty string otherwise (for backward compatibility)
func (s *knowledgeService) getRunningFAQImportTaskID(ctx context.Context, kbID string) (string, error) {
info, err := s.getRunningFAQImportInfo(ctx, kbID)
if err != nil {
return "", err
}
if info == nil {
return "", nil
}
return info.TaskID, nil
}
// setRunningFAQImportInfo sets the running task info for a KB
func (s *knowledgeService) setRunningFAQImportInfo(ctx context.Context, kbID string, info *runningFAQImportInfo) error {
if s.redisClient == nil {
s.memFAQRunningImport.Store(kbID, info)
return nil
}
key := getFAQImportRunningKey(kbID)
data, err := json.Marshal(info)
if err != nil {
return fmt.Errorf("failed to marshal running info: %w", err)
}
return s.redisClient.Set(ctx, key, data, faqImportProgressTTL).Err()
}
// clearRunningFAQImportTaskID clears the running task ID for a KB
func (s *knowledgeService) clearRunningFAQImportTaskID(ctx context.Context, kbID string) error {
if s.redisClient == nil {
s.memFAQRunningImport.Delete(kbID)
return nil
}
key := getFAQImportRunningKey(kbID)
return s.redisClient.Del(ctx, key).Err()
}
func (s *knowledgeService) clearRunningFAQImportInfoIfMatches(ctx context.Context, kbID, taskID, instanceID string, enqueuedAt int64) error {
if s.redisClient == nil {
if v, ok := s.memFAQRunningImport.Load(kbID); ok {
info, _ := v.(*runningFAQImportInfo)
if runningFAQImportInfoMatches(info, taskID, instanceID, enqueuedAt) {
s.memFAQRunningImport.Delete(kbID)
}
}
return nil
}
info, err := s.getRunningFAQImportInfo(ctx, kbID)
if err != nil {
return err
}
if !runningFAQImportInfoMatches(info, taskID, instanceID, enqueuedAt) {
return nil
}
key := getFAQImportRunningKey(kbID)
return s.redisClient.Del(ctx, key).Err()
}
func runningFAQImportInfoMatches(info *runningFAQImportInfo, taskID, instanceID string, enqueuedAt int64) bool {
if info == nil || info.TaskID != taskID {
return false
}
if info.InstanceID != "" && instanceID != "" {
return info.InstanceID == instanceID
}
return enqueuedAt == 0 || info.EnqueuedAt == 0 || info.EnqueuedAt == enqueuedAt
}
// incrementalIndexFAQEntry 增量更新FAQ条目的索引
// 只对内容变化的部分进行embedding计算和索引更新,跳过未变化的部分
func (s *knowledgeService) incrementalIndexFAQEntry(
ctx context.Context,
kb *types.KnowledgeBase,
knowledge *types.Knowledge,
chunk *types.Chunk,
embeddingModel embedding.Embedder,
oldStandardQuestion string,
oldSimilarQuestions []string,
oldAnswers []string,
newMeta *types.FAQChunkMetadata,
) error {
indexStartTime := time.Now()
logger.Debugf(ctx, "incrementalIndexFAQEntry: starting for chunk=%s, oldSimilarQuestions=%d, newSimilarQuestions=%d",
chunk.ID, len(oldSimilarQuestions), len(newMeta.SimilarQuestions))
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
ctx, s.retrieveEngine, s.ownership, types.MustTenantIDFromContext(ctx), kb.VectorStoreID)
if err != nil {
return err
}
indexMode := types.FAQIndexModeQuestionAnswer
if kb.FAQConfig != nil && kb.FAQConfig.IndexMode != "" {
indexMode = kb.FAQConfig.IndexMode
}
// 对新旧数据进行归一化处理,确保与 buildFAQIndexInfoList 的行为一致
// 旧数据归一化
oldStandardQuestion = types.NormalizeQuestion(oldStandardQuestion)
normalizedOldSimilarQuestions := make([]string, 0, len(oldSimilarQuestions))
for _, q := range oldSimilarQuestions {
if nq := types.NormalizeQuestion(q); nq != "" {
normalizedOldSimilarQuestions = append(normalizedOldSimilarQuestions, nq)
}
}
oldSimilarQuestions = normalizedOldSimilarQuestions
oldAnswers = types.SanitizeStrings(oldAnswers)
// 新数据归一化
normalizedNewMeta := newMeta.Normalize()
// 构建索引内容
buildContent := func(question string, answers []string) string {
if indexMode == types.FAQIndexModeQuestionAnswer && len(answers) > 0 {
var builder strings.Builder
builder.WriteString(question)
for _, ans := range answers {
builder.WriteString("\n")
builder.WriteString(ans)
}
return builder.String()
}
return question
}
// 检查答案是否变化(仅在 QuestionAnswer 模式下才影响索引)
answersChanged := indexMode == types.FAQIndexModeQuestionAnswer && !slices.Equal(oldAnswers, normalizedNewMeta.Answers)
logger.Debugf(ctx, "incrementalIndexFAQEntry: answersChanged=%v (indexMode=%s), oldAnswers=%d, newAnswers=%d",
answersChanged, indexMode, len(oldAnswers), len(normalizedNewMeta.Answers))
// 收集需要更新的索引项
var indexInfoToUpdate []*types.IndexInfo
// 1. 检查标准问是否需要更新
oldStdContent := buildContent(oldStandardQuestion, oldAnswers)
newStdContent := buildContent(normalizedNewMeta.StandardQuestion, normalizedNewMeta.Answers)
stdQuestionChanged := oldStdContent != newStdContent
if stdQuestionChanged {
logger.Debugf(ctx, "incrementalIndexFAQEntry: standard question changed, sourceID=%s", chunk.ID)
indexInfoToUpdate = append(indexInfoToUpdate, &types.IndexInfo{
Content: newStdContent,
SourceID: chunk.ID,
SourceType: types.ChunkSourceType,
ChunkID: chunk.ID,
KnowledgeID: chunk.KnowledgeID,
KnowledgeBaseID: chunk.KnowledgeBaseID,
KnowledgeType: types.KnowledgeTypeFAQ,
TagID: chunk.TagID,
IsEnabled: chunk.IsEnabled,
IsRecommended: chunk.Flags.HasFlag(types.ChunkFlagRecommended),
})
}
// 2. 基于内容哈希处理相似问的增删改
// 构建旧问题集合 (问题 -> 是否存在)
oldQuestionsSet := make(map[string]struct{}, len(oldSimilarQuestions))
for _, q := range oldSimilarQuestions {
oldQuestionsSet[q] = struct{}{}
}
// 构建新问题集合
newQuestionsSet := make(map[string]struct{}, len(normalizedNewMeta.SimilarQuestions))
for _, q := range normalizedNewMeta.SimilarQuestions {
newQuestionsSet[q] = struct{}{}
}
// 找出需要删除的问题(在旧集合中但不在新集合中)
var sourceIDsToDelete []string
var deletedQuestions []string
for oldQ := range oldQuestionsSet {
if _, exists := newQuestionsSet[oldQ]; !exists {
sourceID := fmt.Sprintf("%s-%s", chunk.ID, hashQuestion(oldQ))
sourceIDsToDelete = append(sourceIDsToDelete, sourceID)
deletedQuestions = append(deletedQuestions, oldQ)
}
}
// 找出需要新增或更新的问题
var addedQuestions, updatedQuestions []string
for newQ := range newQuestionsSet {
_, existedBefore := oldQuestionsSet[newQ]
// 需要更新的条件:
// 1. 新问题(之前不存在)
// 2. 答案变化(需要重新embedding)
if !existedBefore || answersChanged {
sourceID := fmt.Sprintf("%s-%s", chunk.ID, hashQuestion(newQ))
indexInfoToUpdate = append(indexInfoToUpdate, &types.IndexInfo{
Content: buildContent(newQ, normalizedNewMeta.Answers),
SourceID: sourceID,
SourceType: types.ChunkSourceType,
ChunkID: chunk.ID,
KnowledgeID: chunk.KnowledgeID,
KnowledgeBaseID: chunk.KnowledgeBaseID,
KnowledgeType: types.KnowledgeTypeFAQ,
TagID: chunk.TagID,
IsEnabled: chunk.IsEnabled,
IsRecommended: chunk.Flags.HasFlag(types.ChunkFlagRecommended),
})
if !existedBefore {
addedQuestions = append(addedQuestions, newQ)
} else {
updatedQuestions = append(updatedQuestions, newQ)
}
}
}
// 输出详细的变化日志
if len(deletedQuestions) > 0 {
logger.Debugf(ctx, "incrementalIndexFAQEntry: deleted similar questions: %v", deletedQuestions)
}
if len(addedQuestions) > 0 {
logger.Debugf(ctx, "incrementalIndexFAQEntry: added similar questions: %v", addedQuestions)
}
if len(updatedQuestions) > 0 {
logger.Debugf(ctx, "incrementalIndexFAQEntry: updated similar questions (answers changed): %v", updatedQuestions)
}
// 3. 删除不再存在的相似问索引
if len(sourceIDsToDelete) > 0 {
logger.Debugf(ctx, "incrementalIndexFAQEntry: deleting %d obsolete sourceIDs: %v", len(sourceIDsToDelete), sourceIDsToDelete)
if delErr := retrieveEngine.DeleteBySourceIDList(ctx, sourceIDsToDelete, embeddingModel.GetDimensions(), types.KnowledgeTypeFAQ); delErr != nil {
logger.Warnf(ctx, "incrementalIndexFAQEntry: failed to delete obsolete source IDs: %v", delErr)
}
}
// 4. 批量索引需要更新的内容
newCount := len(normalizedNewMeta.SimilarQuestions)
if len(indexInfoToUpdate) > 0 {
logger.Debugf(ctx, "incrementalIndexFAQEntry: updating %d index entries (skipped %d unchanged)",
len(indexInfoToUpdate), 1+newCount-len(indexInfoToUpdate))
if err := retrieveEngine.BatchIndex(ctx, embeddingModel, indexInfoToUpdate); err != nil {
return err
}
} else {
logger.Debugf(ctx, "incrementalIndexFAQEntry: all %d entries unchanged, skipping index update", 1+newCount)
}
// 5. 更新 knowledge 记录
now := time.Now()
knowledge.UpdatedAt = now
knowledge.ProcessedAt = &now
if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil {
return err
}
totalDuration := time.Since(indexStartTime)
logger.Debugf(ctx, "incrementalIndexFAQEntry: completed in %v, updated %d/%d entries",
totalDuration, len(indexInfoToUpdate), 1+newCount)
return nil
}
func (s *knowledgeService) indexFAQChunks(ctx context.Context,
kb *types.KnowledgeBase, knowledge *types.Knowledge,
chunks []*types.Chunk, embeddingModel embedding.Embedder,
adjustStorage bool, needDelete bool,
) error {
if len(chunks) == 0 {
return nil
}
indexStartTime := time.Now()
logger.Debugf(ctx, "indexFAQChunks: starting to index %d chunks", len(chunks))
tenantInfo := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
ctx, s.retrieveEngine, s.ownership, tenantInfo.ID, kb.VectorStoreID)
if err != nil {
return err
}
// 构建索引信息
buildIndexInfoStartTime := time.Now()
indexInfo := make([]*types.IndexInfo, 0)
chunkIDs := make([]string, 0, len(chunks))
for _, chunk := range chunks {
infoList, err := s.buildFAQIndexInfoList(ctx, kb, chunk)
if err != nil {
return err
}
indexInfo = append(indexInfo, infoList...)
chunkIDs = append(chunkIDs, chunk.ID)
}
buildIndexInfoDuration := time.Since(buildIndexInfoStartTime)
logger.Debugf(
ctx,
"indexFAQChunks: built %d index info entries for %d chunks in %v",
len(indexInfo),
len(chunks),
buildIndexInfoDuration,
)
var size int64
if adjustStorage {
estimateStartTime := time.Now()
size = retrieveEngine.EstimateStorageSize(ctx, embeddingModel, indexInfo)
estimateDuration := time.Since(estimateStartTime)
logger.Debugf(ctx, "indexFAQChunks: estimated storage size %d bytes in %v", size, estimateDuration)
if tenantInfo.StorageQuota > 0 && tenantInfo.StorageUsed+size > tenantInfo.StorageQuota {
return types.NewStorageQuotaExceededError()
}
}
// 删除旧向量
var deleteDuration time.Duration
if needDelete {
deleteStartTime := time.Now()
if err := retrieveEngine.DeleteByChunkIDList(ctx, chunkIDs, embeddingModel.GetDimensions(), types.KnowledgeTypeFAQ); err != nil {
logger.Warnf(ctx, "Delete FAQ vectors failed: %v", err)
}
deleteDuration = time.Since(deleteStartTime)
if deleteDuration > 100*time.Millisecond {
logger.Debugf(ctx, "indexFAQChunks: deleted old vectors for %d chunks in %v", len(chunkIDs), deleteDuration)
}
}
// 批量索引(这里可能是性能瓶颈)
batchIndexStartTime := time.Now()
if err := retrieveEngine.BatchIndex(ctx, embeddingModel, indexInfo); err != nil {
return err
}
batchIndexDuration := time.Since(batchIndexStartTime)
var avgPerEntry time.Duration
if len(indexInfo) > 0 {
avgPerEntry = batchIndexDuration / time.Duration(len(indexInfo))
}
logger.Debugf(ctx, "indexFAQChunks: batch indexed %d index info entries in %v (avg: %v per entry)",
len(indexInfo), batchIndexDuration, avgPerEntry)
if adjustStorage && size > 0 {
adjustStartTime := time.Now()
if err := s.tenantRepo.AdjustStorageUsed(ctx, tenantInfo.ID, size); err == nil {
tenantInfo.StorageUsed += size
}
knowledge.StorageSize += size
adjustDuration := time.Since(adjustStartTime)
if adjustDuration > 50*time.Millisecond {
logger.Debugf(ctx, "indexFAQChunks: adjusted storage in %v", adjustDuration)
}
}
updateStartTime := time.Now()
now := time.Now()
knowledge.UpdatedAt = now
knowledge.ProcessedAt = &now
err = s.repo.UpdateKnowledge(ctx, knowledge)
updateDuration := time.Since(updateStartTime)
if updateDuration > 50*time.Millisecond {
logger.Debugf(ctx, "indexFAQChunks: updated knowledge in %v", updateDuration)
}
totalDuration := time.Since(indexStartTime)
logger.Debugf(
ctx,
"indexFAQChunks: completed indexing %d chunks in %v (build: %v, delete: %v, batchIndex: %v, update: %v)",
len(chunks),
totalDuration,
buildIndexInfoDuration,
deleteDuration,
batchIndexDuration,
updateDuration,
)
return err
}
func (s *knowledgeService) deleteFAQChunkVectors(ctx context.Context,
kb *types.KnowledgeBase, knowledge *types.Knowledge, chunks []*types.Chunk,
) error {
if len(chunks) == 0 {
return nil
}
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
if err != nil {
return err
}
tenantInfo := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
ctx, s.retrieveEngine, s.ownership, tenantInfo.ID, kb.VectorStoreID)
if err != nil {
return err
}
indexInfo := make([]*types.IndexInfo, 0)
chunkIDs := make([]string, 0, len(chunks))
for _, chunk := range chunks {
infoList, err := s.buildFAQIndexInfoList(ctx, kb, chunk)
if err != nil {
return err
}
indexInfo = append(indexInfo, infoList...)
chunkIDs = append(chunkIDs, chunk.ID)
}
size := retrieveEngine.EstimateStorageSize(ctx, embeddingModel, indexInfo)
if err := retrieveEngine.DeleteByChunkIDList(ctx, chunkIDs, embeddingModel.GetDimensions(), types.KnowledgeTypeFAQ); err != nil {
return err
}
if size > 0 {
if err := s.tenantRepo.AdjustStorageUsed(ctx, tenantInfo.ID, -size); err == nil {
tenantInfo.StorageUsed -= size
if tenantInfo.StorageUsed < 0 {
tenantInfo.StorageUsed = 0
}
}
if knowledge.StorageSize >= size {
knowledge.StorageSize -= size
} else {
knowledge.StorageSize = 0
}
}
knowledge.UpdatedAt = time.Now()
return s.repo.UpdateKnowledge(ctx, knowledge)
}
func faqImportCompletedOutcome(successCount, failedCount, skippedCount int) types.AuditOutcome {
if successCount > 0 && (failedCount > 0 || skippedCount > 0) {
return types.AuditOutcomePartial
}
if successCount > 0 {
return types.AuditOutcomeSuccess
}
if failedCount > 0 && skippedCount > 0 {
return types.AuditOutcomePartial
}
if failedCount > 0 || skippedCount > 0 {
return types.AuditOutcomeFailed
}
return types.AuditOutcomeSuccess
}
func faqImportActivityDetails(payload *types.FAQImportPayload, progress *types.FAQImportProgress, totalEntries int) map[string]any {
details := map[string]any{"mode": payload.Mode}
if progress == nil {
return details
}
total := totalEntries
if total <= 0 {
total = progress.Total
}
if total > 0 {
details["total"] = total
}
details["count"] = progress.SuccessCount
if progress.FailedCount > 0 {
details["failed"] = progress.FailedCount
}
skipped := progress.SkippedCount
if skipped <= 0 && total > 0 {
skipped = total - progress.SuccessCount - progress.FailedCount
if skipped < 0 {
skipped = 0
}
}
if skipped > 0 {
details["skipped"] = skipped
}
return details
}
func (s *knowledgeService) recordFAQImportKBActivity(
ctx context.Context,
payload *types.FAQImportPayload,
progress *types.FAQImportProgress,
totalEntries int,
action types.AuditAction,
outcome types.AuditOutcome,
) {
if s == nil || payload == nil || payload.DryRun || payload.KBID == "" {
return
}
recordKBActivity(ctx, s.audit, payload.TenantID, payload.KBID, action,
"faq_entry", payload.KnowledgeID, outcome, faqImportActivityDetails(payload, progress, totalEntries))
}
// ProcessFAQImport handles Asynq FAQ import tasks (including dry run mode)
func (s *knowledgeService) ProcessFAQImport(ctx context.Context, t *asynq.Task) error {
var payload types.FAQImportPayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
logger.Errorf(ctx, "failed to unmarshal FAQ import task payload: %v", err)
return fmt.Errorf("failed to unmarshal task payload: %w", err)
}
ctx = payload.Initiator.Apply(ctx)
ctx = withKBActivityTask(ctx, payload.TaskID, kbActivityTrigger(ctx))
ctx = logger.WithRequestID(ctx, uuid.New().String())
ctx = logger.WithField(ctx, "faq_import", payload.TaskID)
ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID)
// 获取任务重试信息,用于判断是否是最后一次重试
retryCount, _ := asynq.GetRetryCount(ctx)
maxRetry, _ := asynq.GetMaxRetry(ctx)
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)
// 如果 entries 存储在对象存储中,先下载
if payload.EntriesURL != "" && len(payload.Entries) == 0 {
logger.Infof(ctx, "Downloading FAQ entries from object storage: %s", payload.EntriesURL)
reader, err := s.fileSvc.GetFile(ctx, payload.EntriesURL)
if err != nil {
logger.Errorf(ctx, "Failed to download FAQ entries from object storage: %v", err)
return fmt.Errorf("failed to download entries: %w", err)
}
defer reader.Close()
entriesData, err := io.ReadAll(reader)
if err != nil {
logger.Errorf(ctx, "Failed to read FAQ entries data: %v", err)
return fmt.Errorf("failed to read entries data: %w", err)
}
var entries []types.FAQEntryPayload
if err := json.Unmarshal(entriesData, &entries); err != nil {
logger.Errorf(ctx, "Failed to unmarshal FAQ entries: %v", err)
return fmt.Errorf("failed to unmarshal entries: %w", err)
}
payload.Entries = entries
logger.Infof(ctx, "Downloaded %d FAQ entries from object storage", len(entries))
}
logger.Infof(ctx, "Processing FAQ import task: task_id=%s, kb_id=%s, total_entries=%d, dry_run=%v, retry=%d/%d",
payload.TaskID, payload.KBID, len(payload.Entries), payload.DryRun, retryCount, maxRetry)
// 保存原始总数量
originalTotalEntries := len(payload.Entries)
// 初始化进度
// 检查是否已有验证结果(用于重试时跳过验证)
// 注意:必须在保存新 progress 之前查询,否则会被覆盖
existingProgress, _ := s.GetFAQImportProgress(ctx, payload.TaskID)
progress := &types.FAQImportProgress{
TaskID: payload.TaskID,
KBID: payload.KBID,
KnowledgeID: payload.KnowledgeID,
Status: types.FAQImportStatusProcessing,
Progress: 0,
Total: originalTotalEntries,
Processed: 0,
SuccessCount: 0,
FailedCount: 0,
FailedEntries: make([]types.FAQFailedEntry, 0),
SuccessEntries: make([]types.FAQSuccessEntry, 0),
Message: "正在验证条目...",
CreatedAt: time.Now().Unix(),
UpdatedAt: time.Now().Unix(),
DryRun: payload.DryRun,
}
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to save initial FAQ import progress: %v", err)
}
var validEntryIndices []int
if existingProgress != nil && len(existingProgress.ValidEntryIndices) > 0 {
// 重试时直接使用之前的验证结果
validEntryIndices = existingProgress.ValidEntryIndices
progress.FailedCount = existingProgress.FailedCount
progress.FailedEntries = existingProgress.FailedEntries
logger.Infof(ctx, "Reusing previous validation result: valid=%d, failed=%d",
len(validEntryIndices), progress.FailedCount)
} else {
// 第一步:执行验证(无论是 dry run 还是 import 模式都需要验证)
validEntryIndices = s.executeFAQDryRunValidation(ctx, &payload, progress)
// 保存验证通过的索引,用于重试时跳过验证
progress.ValidEntryIndices = validEntryIndices
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to save validation result: %v", err)
}
logger.Infof(ctx, "FAQ validation completed: total=%d, valid=%d, failed=%d",
originalTotalEntries, len(validEntryIndices), progress.FailedCount)
}
// Dry run 模式:验证完成后直接返回结果
if payload.DryRun {
return s.finalizeFAQValidation(ctx, &payload, progress, originalTotalEntries)
}
// Import 模式:检查是否有有效条目需要导入
if len(validEntryIndices) == 0 {
// 没有有效条目,直接完成
return s.finalizeFAQValidation(ctx, &payload, progress, originalTotalEntries)
}
// 提取有效的条目
validEntries := make([]types.FAQEntryPayload, 0, len(validEntryIndices))
for _, idx := range validEntryIndices {
validEntries = append(validEntries, payload.Entries[idx])
}
// 更新进度消息
progress.Message = fmt.Sprintf("验证完成,开始导入 %d 条有效数据...", len(validEntries))
progress.UpdatedAt = time.Now().Unix()
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to update FAQ import progress: %v", err)
}
// 幂等性检查:获取knowledge记录(FAQ任务使用knowledge ID作为taskID)
knowledge, err := s.repo.GetKnowledgeByID(ctx, payload.TenantID, payload.KnowledgeID)
if err != nil {
logger.Errorf(ctx, "failed to get FAQ knowledge: %v", err)
return nil
}
if knowledge == nil {
return nil
}
kb, err := s.kbService.GetKnowledgeBaseByID(ctx, payload.KBID)
if err != nil {
logger.Errorf(ctx, "Failed to get knowledge base: %v", err)
// 如果是最后一次重试,更新状态为失败
if isLastRetry {
if updateErr := s.updateFAQImportProgressStatus(ctx, payload.TaskID, payload.InstanceID, payload.EnqueuedAt, types.FAQImportStatusFailed, 0, originalTotalEntries, 0, "获取知识库失败", err.Error()); updateErr != nil {
logger.Errorf(ctx, "Failed to update task status to failed: %v", updateErr)
}
s.recordFAQImportKBActivity(ctx, &payload, progress, originalTotalEntries, types.AuditActionFAQImportFailed, types.AuditOutcomeFailed)
}
s.cleanupFAQEntriesFileOnFinalFailure(ctx, payload.EntriesURL, retryCount, maxRetry)
return fmt.Errorf("failed to get knowledge base: %w", err)
}
// 检查任务状态 - 幂等性处理(复用之前获取的 existingProgress)
var processedCount int
if existingProgress != nil {
if existingProgress.Status == types.FAQImportStatusCompleted {
logger.Infof(ctx, "FAQ import already completed, skipping: %s", payload.TaskID)
if clearErr := s.clearRunningFAQImportInfoIfMatches(ctx, payload.KBID, payload.TaskID, payload.InstanceID, payload.EnqueuedAt); clearErr != nil {
logger.Warnf(ctx, "Failed to clear running FAQ import info for completed task: %v", clearErr)
}
return nil // 幂等:已完成的任务直接返回
}
// 获取已处理的数量(注意:这是相对于 validEntries 的索引)
processedCount = existingProgress.Processed - progress.FailedCount // 已处理数 - 验证失败数 = 已导入的有效条目数
if processedCount < 0 {
processedCount = 0
}
logger.Infof(ctx, "Resuming FAQ import from progress: %d/%d (valid entries)", processedCount, len(validEntries))
}
// 幂等性处理:清理可能已部分处理的chunks和索引数据
chunksDeleted, err := s.chunkRepo.DeleteUnindexedChunks(ctx, payload.TenantID, payload.KnowledgeID)
if err != nil {
logger.Errorf(ctx, "Failed to delete unindexed chunks: %v", err)
// 如果是最后一次重试,更新状态为失败
if isLastRetry {
if updateErr := s.updateFAQImportProgressStatus(ctx, payload.TaskID, payload.InstanceID, payload.EnqueuedAt, types.FAQImportStatusFailed, 0, originalTotalEntries, 0, "清理未索引数据失败", err.Error()); updateErr != nil {
logger.Errorf(ctx, "Failed to update task status to failed: %v", updateErr)
}
s.recordFAQImportKBActivity(ctx, &payload, progress, originalTotalEntries, types.AuditActionFAQImportFailed, types.AuditOutcomeFailed)
}
s.cleanupFAQEntriesFileOnFinalFailure(ctx, payload.EntriesURL, retryCount, maxRetry)
return fmt.Errorf("failed to delete unindexed chunks: %w", err)
}
if len(chunksDeleted) > 0 {
logger.Infof(ctx, "Deleted unindexed chunks: %d", len(chunksDeleted))
// 删除索引数据
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
if err == nil {
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
ctx, s.retrieveEngine, s.ownership, tenantInfo.ID, kb.VectorStoreID)
if err == nil {
chunkIDs := make([]string, 0, len(chunksDeleted))
for _, chunk := range chunksDeleted {
chunkIDs = append(chunkIDs, chunk.ID)
}
if err := retrieveEngine.DeleteByChunkIDList(ctx, chunkIDs, embeddingModel.GetDimensions(), types.KnowledgeTypeFAQ); err != nil {
logger.Warnf(ctx, "Failed to delete index data for chunks (may not exist): %v", err)
} else {
logger.Infof(ctx, "Successfully deleted index data for %d chunks", len(chunksDeleted))
}
}
}
}
// 如果已经处理了一部分有效条目,从该位置继续
entriesToImport := validEntries
importMode := payload.Mode
if processedCount > 0 && processedCount < len(validEntries) {
entriesToImport = validEntries[processedCount:]
// 重试场景下,如果之前已经处理了一部分数据,需要切换到 Append 模式
// 因为 Replace 模式的删除操作在第一次运行时已经执行过了
// 如果继续使用 Replace 模式,calculateReplaceOperations 会将之前成功导入的数据标记为删除
// 导致数据丢失
if payload.Mode == types.FAQBatchModeReplace {
importMode = types.FAQBatchModeAppend
logger.Infof(ctx, "Switching to Append mode for retry, original mode was Replace")
}
logger.Infof(ctx, "Continuing FAQ import from entry %d, remaining: %d entries", processedCount, len(entriesToImport))
}
// 构建FAQBatchUpsertPayload(使用验证通过的有效条目)
faqPayload := &types.FAQBatchUpsertPayload{
Entries: entriesToImport,
Mode: importMode,
}
// 执行FAQ导入(传入已处理的偏移量,用于进度计算)
if err := s.executeFAQImport(ctx, payload.TaskID, payload.KBID, faqPayload, payload.TenantID, progress.FailedCount+processedCount, progress); err != nil {
logger.Errorf(ctx, "FAQ import task failed: %s, error: %v", payload.TaskID, err)
// 如果是最后一次重试,更新状态为失败
if isLastRetry {
if updateErr := s.updateFAQImportProgressStatus(ctx, payload.TaskID, payload.InstanceID, payload.EnqueuedAt, types.FAQImportStatusFailed, 0, originalTotalEntries, len(validEntries), "导入失败", err.Error()); updateErr != nil {
logger.Errorf(ctx, "Failed to update task status to failed: %v", updateErr)
}
s.recordFAQImportKBActivity(ctx, &payload, progress, originalTotalEntries, types.AuditActionFAQImportFailed, types.AuditOutcomeFailed)
}
s.cleanupFAQEntriesFileOnFinalFailure(ctx, payload.EntriesURL, retryCount, maxRetry)
return fmt.Errorf("FAQ import failed: %w", err)
}
// 任务成功完成
logger.Infof(ctx, "FAQ import task completed: %s, imported: %d, failed: %d",
payload.TaskID, len(progress.SuccessEntries), progress.FailedCount)
// 最终完成处理(生成失败条目 CSV 等)
return s.finalizeFAQValidation(ctx, &payload, progress, originalTotalEntries)
}
// finalizeFAQValidation 完成 FAQ 验证/导入任务,生成失败条目 CSV(如果有)
func (s *knowledgeService) finalizeFAQValidation(ctx context.Context, payload *types.FAQImportPayload,
progress *types.FAQImportProgress, originalTotalEntries int,
) error {
// 清理对象存储中的 entries 文件(如果有)
if payload.EntriesURL != "" {
if err := s.fileSvc.DeleteFile(ctx, payload.EntriesURL); err != nil {
logger.Warnf(ctx, "Failed to delete FAQ entries file from object storage: %v", err)
} else {
logger.Infof(ctx, "Deleted FAQ entries file from object storage: %s", payload.EntriesURL)
}
}
progress.UpdatedAt = time.Now().Unix()
// 如果有失败条目,生成 CSV 文件
if len(progress.FailedEntries) > 0 {
csvURL, err := s.generateFailedEntriesCSV(ctx, payload.TenantID, payload.TaskID, progress.FailedEntries)
if err != nil {
logger.Warnf(ctx, "Failed to generate failed entries CSV: %v", err)
} else {
progress.FailedEntriesURL = csvURL
progress.FailedEntries = nil // 清空内联数据,使用 URL
progress.Message += " (失败记录已导出为CSV)"
}
}
// 必须在 saveFAQImportResultToDatabase 之前计算最终统计。
progress.Status = types.FAQImportStatusCompleted
progress.Progress = 100
progress.Processed = originalTotalEntries
if len(progress.ValidEntryIndices) > 0 {
progress.SuccessCount = len(progress.ValidEntryIndices) - progress.PartialFailedCount
} else if len(progress.SuccessEntries) > 0 {
progress.SuccessCount = len(progress.SuccessEntries) - progress.PartialFailedCount
} else {
progress.SuccessCount = originalTotalEntries - progress.FailedCount - progress.PartialFailedCount
}
if progress.SuccessCount < 0 {
progress.SuccessCount = 0
}
if progress.AddedCount == 0 && progress.MergedCount > 0 {
progress.AddedCount = progress.SuccessCount - progress.MergedCount
if progress.AddedCount < 0 {
progress.AddedCount = 0
}
} else if progress.AddedCount == 0 {
progress.AddedCount = progress.SuccessCount
}
skippedCount := originalTotalEntries - progress.SuccessCount - progress.PartialFailedCount - progress.FailedCount
if skippedCount < 0 {
skippedCount = 0
}
progress.SkippedCount = skippedCount
if payload.DryRun {
progress.Message = s.buildFAQImportResultMessage("验证完成", progress)
} else {
progress.Message = s.buildFAQImportResultMessage("导入完成", progress)
}
// 如果不是 dry run 模式,保存导入结果统计到数据库
if !payload.DryRun {
if err := s.saveFAQImportResultToDatabase(ctx, payload, progress, originalTotalEntries); err != nil {
logger.Warnf(ctx, "Failed to save FAQ import result to database: %v", err)
}
// 只有 replace 模式才清理未使用的 Tag
// append 模式不应删除用户预先创建的空标签
if payload.Mode == types.FAQBatchModeReplace {
deletedTags, err := s.tagRepo.DeleteUnusedTags(ctx, payload.TenantID, payload.KBID)
if err != nil {
logger.Warnf(ctx, "FAQ import task %s: failed to cleanup unused tags: %v", payload.TaskID, err)
} else if deletedTags > 0 {
logger.Infof(ctx, "FAQ import task %s: cleaned up %d unused tags after replace import", payload.TaskID, deletedTags)
}
}
}
// 使用 updateFAQImportProgressStatus 来确保正确清理 running key
// 但是需要先保存其他字段,因为 updateFAQImportProgressStatus 不会保存所有字段
if err := s.saveFAQImportProgress(ctx, progress); err != nil {
logger.Warnf(ctx, "Failed to save final FAQ import progress: %v", err)
}
// 然后调用状态更新来清理 running key
if err := s.updateFAQImportProgressStatus(ctx, payload.TaskID, payload.InstanceID, payload.EnqueuedAt, types.FAQImportStatusCompleted,
100, originalTotalEntries, originalTotalEntries, progress.Message, ""); err != nil {
logger.Warnf(ctx, "Failed to update final FAQ import status: %v", err)
}
logger.Infof(ctx, "FAQ task completed: %s, dry_run=%v, success: %d, added: %d, merged: %d, failed: %d, partial_failed: %d",
payload.TaskID, payload.DryRun, progress.SuccessCount, progress.AddedCount, progress.MergedCount, progress.FailedCount, progress.PartialFailedCount)
if !payload.DryRun {
outcome := faqImportCompletedOutcome(progress.SuccessCount, progress.FailedCount, progress.SkippedCount)
s.recordFAQImportKBActivity(ctx, payload, progress, originalTotalEntries,
types.AuditActionFAQImportCompleted, outcome)
}
return nil
}
// executeFAQMergeOperations 批量执行 append-mode 合并操作:更新已有 chunk
// 的 metadata / content / 索引。使用 ListChunksByID 批量加载 + SaveChunks 事
// 务批量保存,减少 DB 往返。任一批次失败立即返回,由 executeFAQImport
// 的 defer recovery 决定是否回滚整次导入。
//
// 必要时这里逐条 fan-out 索引(EFPutDocument),而不是合并 chunks 批量
// 重建:因为索引底层是按 SourceID 覆盖写入,按合并后的最终内容直接 put
// 即可。
func (s *knowledgeService) executeFAQMergeOperations(
ctx context.Context,
taskID string,
kb *types.KnowledgeBase,
faqKnowledge *types.Knowledge,
embeddingModel embedding.Embedder,
indexMode types.FAQIndexMode,
mergeOps []faqMergeOperation,
progress *types.FAQImportProgress,
) (int, error) {
if len(mergeOps) == 0 {
return 0, nil
}
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
mergedCount := 0
for batchStart := 0; batchStart < len(mergeOps); batchStart += faqImportBatchSize {
batchEnd := batchStart + faqImportBatchSize
if batchEnd > len(mergeOps) {
batchEnd = len(mergeOps)
}
batch := mergeOps[batchStart:batchEnd]
// 1. 批量加载完整 chunk(calculateAppendOperations 中只加载了部分字段,
// 缺少 status/is_enabled/flags/seq_id 等,直接更新会将这些字段覆盖为零值)
chunkIDs := make([]string, len(batch))
for i, op := range batch {
chunkIDs[i] = op.ExistingChunk.ID
}
fullChunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, chunkIDs)
if err != nil {
logger.Errorf(ctx, "FAQ import task %s: failed to batch reload chunks for merge: %v", taskID, err)
return mergedCount, fmt.Errorf("failed to batch reload chunks for merge: %w", err)
}
chunkMap := make(map[string]*types.Chunk, len(fullChunks))
for _, c := range fullChunks {
chunkMap[c.ID] = c
}
// 2. 逐条应用合并数据
mergedChunks := make([]*types.Chunk, 0, len(batch))
for _, op := range batch {
fullChunk, ok := chunkMap[op.ExistingChunk.ID]
if !ok {
logger.Errorf(ctx, "FAQ import task %s: chunk %s not found during batch reload", taskID, op.ExistingChunk.ID)
return mergedCount, fmt.Errorf("chunk %s not found during batch reload", op.ExistingChunk.ID)
}
if err := fullChunk.SetFAQMetadata(op.MergedMeta); err != nil {
logger.Errorf(ctx, "FAQ import task %s: failed to set merged metadata for chunk %s: %v", taskID, fullChunk.ID, err)
return mergedCount, fmt.Errorf("failed to set merged FAQ metadata: %w", err)
}
fullChunk.Content = buildFAQChunkContent(op.MergedMeta, indexMode)
fullChunk.ContentHash = types.CalculateFAQContentHash(op.MergedMeta)
fullChunk.UpdatedAt = time.Now()
// 用新值覆盖运营状态
if op.Entry.IsEnabled != nil {
fullChunk.IsEnabled = *op.Entry.IsEnabled
}
if op.Entry.IsRecommended != nil {
if *op.Entry.IsRecommended {
fullChunk.Flags = fullChunk.Flags.SetFlag(types.ChunkFlagRecommended)
} else {
fullChunk.Flags = fullChunk.Flags.ClearFlag(types.ChunkFlagRecommended)
}
}
mergedChunks = append(mergedChunks, fullChunk)
}
// 3. 事务批量保存(GORM Save 全字段更新,确保 metadata/content_hash 持久化)
if err := s.chunkRepo.SaveChunks(ctx, mergedChunks); err != nil {
logger.Errorf(ctx, "FAQ import task %s: failed to batch save merged chunks: %v", taskID, err)
return mergedCount, fmt.Errorf("failed to batch save merged chunks: %w", err)
}
// 4. 重建索引(EFPutDocument 会自动覆盖相同 SourceID)
if err := s.indexFAQChunks(ctx, kb, faqKnowledge, mergedChunks, embeddingModel, false, false); err != nil {
return mergedCount, fmt.Errorf("failed to re-index merged chunks: %w", err)
}
// 5. 收集成功条目信息
for i, op := range batch {
chunk := mergedChunks[i]
meta := op.MergedMeta
var tagID int64
tagName := ""
if chunk.TagID != "" {
if tag, tErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID); tErr == nil && tag != nil {
tagID = tag.SeqID
tagName = tag.Name
}
}
progress.SuccessEntries = append(progress.SuccessEntries, types.FAQSuccessEntry{
Index: op.Detail.Index,
SeqID: chunk.SeqID,
TagID: tagID,
TagName: tagName,
StandardQuestion: meta.StandardQuestion,
})
}
mergedCount += len(batch)
logger.Infof(ctx, "FAQ import task %s: merged batch %d-%d (%d chunks)", taskID, batchStart+1, batchEnd, len(mergedChunks))
}
return mergedCount, nil
}
// buildFAQImportResultMessage 构建 FAQ 导入 / 验证最终结果的人类可读消息。
// 前端会把它直接展示在 toast / 任务列表里,所以保持简单清晰:
// - 默认形态:"导入完成 / 上传 N 条 / 成功 X 条 [/ 失败 Y 条] [/ 部分失败 Z 条]"
// - 当 MergedCount > 0 时改用拆分形态:"/ 新增 X 条 / 合并更新 Y 条",
// 让用户在 append 模式下看到合并了多少条历史 FAQ 而不是只看到总成功数。
//
// 内部 master 原始实现;HEAD 版本之前没有,所有完成消息只有 "正在处理第 N/M 条"。
func (s *knowledgeService) buildFAQImportResultMessage(prefix string, progress *types.FAQImportProgress) string {
parts := []string{prefix}
parts = append(parts, fmt.Sprintf("上传 %d 条", progress.Total))
if progress.MergedCount > 0 {
parts = append(parts, fmt.Sprintf("新增 %d 条", progress.AddedCount))
parts = append(parts, fmt.Sprintf("合并更新 %d 条", progress.MergedCount))
} else {
parts = append(parts, fmt.Sprintf("成功 %d 条", progress.SuccessCount))
}
if progress.FailedCount > 0 {
parts = append(parts, fmt.Sprintf("失败 %d 条", progress.FailedCount))
}
if progress.PartialFailedCount > 0 {
parts = append(parts, fmt.Sprintf("部分失败 %d 条", progress.PartialFailedCount))
}
return strings.Join(parts, " / ")
}
const (
faqImportProgressKeyPrefix = "faq_import_progress:"
faqImportRunningKeyPrefix = "faq_import_running:"
faqImportProgressTTL = 3 * time.Hour
)
// getFAQImportProgressKey returns the Redis key for storing FAQ import progress
func getFAQImportProgressKey(taskID string) string {
return faqImportProgressKeyPrefix + taskID
}
// getFAQImportRunningKey returns the Redis key for storing running task ID by KB ID
func getFAQImportRunningKey(kbID string) string {
return faqImportRunningKeyPrefix + kbID
}
// saveFAQImportProgress saves the FAQ import progress to Redis
func (s *knowledgeService) saveFAQImportProgress(ctx context.Context, progress *types.FAQImportProgress) error {
if s.redisClient == nil {
progress.UpdatedAt = time.Now().Unix()
s.memFAQProgress.Store(progress.TaskID, progress)
return nil
}
key := getFAQImportProgressKey(progress.TaskID)
progress.UpdatedAt = time.Now().Unix()
data, err := json.Marshal(progress)
if err != nil {
return fmt.Errorf("failed to marshal FAQ import progress: %w", err)
}
return s.redisClient.Set(ctx, key, data, faqImportProgressTTL).Err()
}
// GetFAQImportProgress retrieves the progress of an FAQ import task
func (s *knowledgeService) GetFAQImportProgress(ctx context.Context, taskID string) (*types.FAQImportProgress, error) {
if s.redisClient == nil {
if v, ok := s.memFAQProgress.Load(taskID); ok {
return v.(*types.FAQImportProgress), nil
}
return nil, werrors.NewNotFoundError("FAQ import task not found")
}
key := getFAQImportProgressKey(taskID)
data, err := s.redisClient.Get(ctx, key).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, werrors.NewNotFoundError("FAQ import task not found")
}
return nil, fmt.Errorf("failed to get FAQ import progress from Redis: %w", err)
}
var progress types.FAQImportProgress
if err := json.Unmarshal(data, &progress); err != nil {
return nil, fmt.Errorf("failed to unmarshal FAQ import progress: %w", err)
}
// If task is completed, enrich with persisted result fields from database
if progress.Status == types.FAQImportStatusCompleted && progress.KnowledgeID != "" {
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
knowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, progress.KnowledgeID)
if err == nil && knowledge != nil {
if result, err := knowledge.GetLastFAQImportResult(); err == nil && result != nil {
progress.SuccessCount = result.SuccessCount
progress.FailedCount = result.FailedCount
progress.PartialFailedCount = result.PartialFailedCount
progress.SkippedCount = result.SkippedCount
progress.MergedCount = result.MergedCount
progress.AddedCount = result.AddedCount
progress.ImportMode = result.ImportMode
progress.ImportedAt = result.ImportedAt
progress.DisplayStatus = result.DisplayStatus
progress.ProcessingTime = result.ProcessingTime
if result.FailedEntriesURL != "" {
progress.FailedEntriesURL = result.FailedEntriesURL
}
}
}
}
return &progress, nil
}
// UpdateLastFAQImportResultDisplayStatus updates the display status of FAQ import result
func (s *knowledgeService) UpdateLastFAQImportResultDisplayStatus(ctx context.Context, kbID string, displayStatus string) error {
// 验证displayStatus参数
if displayStatus != "open" && displayStatus != "close" {
return werrors.NewBadRequestError("invalid display status, must be 'open' or 'close'")
}
// 获取当前空间ID
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
// 查找FAQ类型的knowledge
knowledgeList, err := s.repo.ListKnowledgeByKnowledgeBaseID(ctx, tenantID, kbID)
if err != nil {
return fmt.Errorf("failed to list knowledge: %w", err)
}
// 查找FAQ类型的knowledge
var faqKnowledge *types.Knowledge
for _, k := range knowledgeList {
if k.Type == types.KnowledgeTypeFAQ {
faqKnowledge = k
break
}
}
if faqKnowledge == nil {
return werrors.NewNotFoundError("FAQ knowledge not found in this knowledge base")
}
// 解析当前的导入结果
result, err := faqKnowledge.GetLastFAQImportResult()
if err != nil {
return fmt.Errorf("failed to parse FAQ import result: %w", err)
}
if result == nil {
return werrors.NewNotFoundError("no FAQ import result found")
}
// 更新显示状态
result.DisplayStatus = displayStatus
// 保存更新后的结果
if err := faqKnowledge.SetLastFAQImportResult(result); err != nil {
return fmt.Errorf("failed to set FAQ import result: %w", err)
}
// 更新数据库
if err := s.repo.UpdateKnowledge(ctx, faqKnowledge); err != nil {
return fmt.Errorf("failed to update knowledge: %w", err)
}
return nil
}