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
|
|||
|
|
}
|