Files
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

2859 lines
104 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"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
}