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
2859 lines
104 KiB
Go
2859 lines
104 KiB
Go
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
|
||
}
|