package tech.easyflow.ai.documentimport.task;

import cn.dev33.satoken.stp.StpUtil;
import com.alibaba.fastjson2.JSON;
import com.easyagents.core.document.Document;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.model.embedding.EmbeddingOptions;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.easyagents.rag.core.RagChunk;
import com.easyagents.rag.core.RagDefaults;
import com.easyagents.rag.core.RagStrategyCodes;
import com.easyagents.rag.core.RagChunkTypes;
import com.easyagents.rag.ingestion.RagIngestionService;
import com.easyagents.rag.ingestion.model.AnalysisResult;
import com.easyagents.rag.ingestion.model.StrategyConfig;
import com.easyagents.search.engine.service.DocumentSearcher;
import com.easyagents.search.engine.service.KeywordSearchMetadataKeys;
import com.easyagents.document.core.entity.DocumentBlock;
import com.easyagents.document.core.entity.DocumentImage;
import com.easyagents.document.core.entity.DocumentTable;
import com.mybatisflex.core.keygen.impl.FlexIDKeyGenerator;
import com.mybatisflex.core.query.QueryWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.config.SearcherFactory;
import tech.easyflow.ai.document.model.DocumentParseArtifacts;
import tech.easyflow.ai.document.model.DocumentParseScenario;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.documentimport.DocumentImportPreviewService;
import tech.easyflow.ai.easyagents.CustomMultipartFile;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.DocumentImportTask;
import tech.easyflow.ai.enums.DocumentImportTaskPhase;
import tech.easyflow.ai.enums.DocumentImportTaskStatus;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.mapper.DocumentChunkMapper;
import tech.easyflow.ai.mapper.DocumentImportTaskMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.DocumentImportTaskService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.util.FileUtil;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;

import javax.annotation.Resource;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URLConnection;
import java.util.Base64;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 知识库文档任务化导入应用服务。
 *
 * @author Codex
 * @since 2026-04-14
 */
@Service
public class KnowledgeDocumentImportTaskAppService {

    private static final Logger LOG = LoggerFactory.getLogger(KnowledgeDocumentImportTaskAppService.class);
    private static final int INDEX_BATCH_SIZE = 20;
    private static final String SOURCE_RANGES_KEY = "sourceRanges";
    private static final String KNOWLEDGE_PARSE_IMAGE_CATEGORY = "knowledge-parse";
    private static final String OFFICE_PPTX_PAGE_STRATEGY = "OFFICE_PPTX_PAGE";
    private static final String OFFICE_XLSX_ROW_WINDOW_STRATEGY = "OFFICE_XLSX_ROW_WINDOW";
    private static final String SEARCH_RENDER_MARKDOWN_METADATA_KEY = "renderMarkdown";
    private static final Pattern MARKDOWN_IMAGE_PATTERN = Pattern.compile("!\\[(?:[^\\]]*)\\]\\(([^)]+)\\)");

    @Resource
    private DocumentMapper documentMapper;

    @Resource
    private DocumentChunkMapper documentChunkMapper;

    @Resource
    private DocumentImportTaskMapper documentImportTaskMapper;

    @Resource
    private DocumentCollectionService knowledgeService;

    @Resource
    private ModelService modelService;

    @Resource
    private DocumentChunkService documentChunkService;

    @Resource
    private DocumentImportTaskService documentImportTaskService;

    @Resource
    private DocumentImportParseMonitorProperties parseMonitorProperties;

    @Resource
    private DocumentImportPreviewService documentImportPreviewService;

    @Resource
    private RagIngestionService ragIngestionService;

    @Resource
    private DocumentParseBridgeService documentParseBridgeService;

    @Resource
    private DocumentImportParseTaskProducer parseTaskProducer;

    @Resource
    private DocumentImportIndexTaskProducer indexTaskProducer;

    @Resource(name = "default")
    private FileStorageService storageService;

    @Resource
    private SearcherFactory searcherFactory;

    @Resource(name = "documentImportTaskExecutor")
    private ThreadPoolTaskExecutor documentImportTaskExecutor;

    @Resource
    private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;

    @Autowired
    @Lazy
    private KnowledgeDocumentImportTaskAppService selfProxy;

    /**
     * 上传后创建解析任务。
     *
     * @param request 创建请求
     * @return 任务结果
     */
    @Transactional
    public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(DocumentImportDtos.TaskCreateRequest request) {
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        if (!StringUtil.hasText(request.getFilePath()) || !StringUtil.hasText(request.getFileName())) {
            throw new BusinessException("文件信息不完整");
        }
        String fileExt = normalizeFileExtension(request.getFileName(), request.getFilePath());
        assertSupportedImportFile(fileExt);

        Date now = new Date();
        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setId(generateId(document));
        document.setCollectionId(knowledge.getId());
        document.setDocumentPath(request.getFilePath());
        document.setTitle(request.getFileName());
        document.setDocumentType(fileExt);
        document.setCreated(now);
        document.setModified(now);
        document.setCreatedBy(resolveOperatorId());
        document.setModifiedBy(resolveOperatorId());
        document.setProcessStatus(DocumentProcessStatus.PARSING.name());
        document.setTotalChunks(0);
        document.setCompletedChunks(0);
        document.setFailedChunks(0);
        document.setProgressPercent(0);
        document.setTaskModifiedAt(now);
        document.setOptions(buildInitialOptions(fileExt));
        documentMapper.insert(document);

        DocumentImportTask task = createTask(document, DocumentImportTaskPhase.PARSE, buildFilePayload(request));
        LOG.info("文档导入任务已创建: knowledgeId={}, documentId={}, taskId={}, fileName={}, fileExt={}, parseMode={}",
            knowledge.getId(),
            document.getId(),
            task.getId(),
            document.getTitle(),
            fileExt,
            shouldUseDocumentParseBridge(fileExt) ? "async-bridge" : "sync-default");
        documentImportTaskStatusStreamService.publishAfterCommit(document.getId());
        if (shouldUseDocumentParseBridge(fileExt)) {
            LOG.info("文档解析任务准备通过 MQ 异步投递: knowledgeId={}, documentId={}, taskId={}, fileExt={}",
                knowledge.getId(), document.getId(), task.getId(), fileExt);
            dispatchParseTaskAfterCommit(task.getId());
            scheduleParseTaskFallback(task.getId());
        } else {
            LOG.info("文档解析任务准备同步执行: knowledgeId={}, documentId={}, taskId={}, fileExt={}",
                knowledge.getId(), document.getId(), task.getId(), fileExt);
            selfProxy.handleParseTask(task.getId());
            document = requireDocument(document.getId());
        }

        DocumentImportDtos.TaskCreateResponse response = new DocumentImportDtos.TaskCreateResponse();
        response.setDocumentId(document.getId());
        response.setTaskId(task.getId());
        response.setProcessStatus(document.getProcessStatus());
        return Result.ok(response);
    }

    /**
     * 查询任务详情。
     *
     * @param taskId 任务 ID
     * @return 任务详情
     */
    public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        tech.easyflow.ai.entity.Document document = requireDocument(task.getDocumentId());
        DocumentImportDtos.TaskDetailResponse response = new DocumentImportDtos.TaskDetailResponse();
        response.setTaskId(task.getId());
        response.setDocumentId(task.getDocumentId());
        response.setKnowledgeId(task.getKnowledgeId());
        response.setPhase(task.getPhase());
        response.setStatus(task.getStatus());
        response.setProcessStatus(document.getProcessStatus());
        response.setProgressPercent(defaultInt(document.getProgressPercent()));
        response.setTotalChunks(defaultInt(document.getTotalChunks()));
        response.setCompletedChunks(defaultInt(document.getCompletedChunks()));
        response.setFailedChunks(defaultInt(document.getFailedChunks()));
        response.setProviderTaskId(task.getProviderTaskId());
        response.setErrorSummary(StringUtil.hasText(document.getLastTaskError()) ? document.getLastTaskError() : task.getErrorSummary());
        response.setStartedAt(task.getStartedAt());
        response.setFinishedAt(task.getFinishedAt());
        return Result.ok(response);
    }

    /**
     * 生成单文档分块预览。
     *
     * @param request 预览请求
     * @return 预览结果
     */
    @Transactional
    public Result<DocumentImportDtos.PreviewResponse> previewImportTask(DocumentImportDtos.PreviewRequest request) {
        if (request.getDocumentId() == null) {
            throw new BusinessException("文档ID不能为空");
        }
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        tech.easyflow.ai.entity.Document document = requireDocumentForKnowledge(request.getDocumentId(), knowledge.getId());
        ensurePreviewable(document);

        StrategyConfig requestedStrategy = null;
        if (request.getFiles() != null && !request.getFiles().isEmpty()) {
            requestedStrategy = request.getFiles().get(0).getStrategyConfig();
        }

        DocumentImportDtos.PreviewSession session = buildPreviewSessionForDocument(knowledge, document, requestedStrategy);
        String sessionId = documentImportPreviewService.put(session);

        DocumentImportDtos.PreviewFileResult item = buildPreviewFileResult(document, session, sessionId);

        DocumentImportDtos.PreviewResponse response = new DocumentImportDtos.PreviewResponse();
        response.setItems(List.of(item));
        response.setTotalFiles(1);
        response.setTotalChunks(session.getDocumentChunks().size());
        return Result.ok(response);
    }

    /**
     * 启动向量化任务。
     *
     * @param request 启动请求
     * @return 启动结果
     */
    @Transactional
    public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(DocumentImportDtos.TaskStartIndexRequest request) {
        if (request.getDocumentId() == null) {
            throw new BusinessException("文档ID不能为空");
        }
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        tech.easyflow.ai.entity.Document document = requireDocumentForKnowledge(request.getDocumentId(), knowledge.getId());
        if (!allowIndexStart(document)) {
            throw new BusinessException("当前文档状态不允许开始向量化");
        }

        DocumentImportDtos.PreviewSession session = resolveIndexPreviewSession(knowledge, document, request.getPreviewSessionId());
        int totalChunks = session.getDocumentChunks().size();
        if (totalChunks <= 0) {
            throw new BusinessException("未生成有效分块，无法开始向量化");
        }

        mergeDocumentPreviewOptions(document, session);
        updateDocumentIndexing(document, totalChunks);
        DocumentImportTask task = createTask(document, DocumentImportTaskPhase.INDEX, Map.of(
            "previewSessionId", session.getSessionId(),
            "totalChunks", totalChunks
        ));
        LOG.info("文档向量化任务已创建: knowledgeId={}, documentId={}, taskId={}, previewSessionId={}, totalChunks={}",
            knowledge.getId(), document.getId(), task.getId(), session.getSessionId(), totalChunks);
        dispatchIndexTaskAfterCommit(task.getId());
        scheduleIndexTaskFallback(task.getId());

        DocumentImportDtos.TaskStartIndexResponse response = new DocumentImportDtos.TaskStartIndexResponse();
        response.setTaskId(task.getId());
        response.setProcessStatus(DocumentProcessStatus.INDEXING.name());
        return Result.ok(response);
    }

    /**
     * 重试解析任务。
     *
     * @param request 重试请求
     * @return 任务结果
     */
    @Transactional
    public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(DocumentImportDtos.TaskRetryRequest request) {
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        tech.easyflow.ai.entity.Document document = requireDocumentForKnowledge(request.getDocumentId(), knowledge.getId());
        if (!DocumentProcessStatus.PARSE_FAILED.name().equals(document.getProcessStatus())) {
            throw new BusinessException("当前文档不支持重试解析");
        }
        String fileExt = normalizeFileExtension(document.getTitle(), document.getDocumentPath());
        resetDocumentForParseRetry(document);
        DocumentImportTask task = createTask(document, DocumentImportTaskPhase.PARSE, buildDocumentPayload(document));
        if (shouldUseDocumentParseBridge(fileExt)) {
            dispatchParseTaskAfterCommit(task.getId());
            scheduleParseTaskFallback(task.getId());
            return Result.ok(buildTaskStartResponse(task, DocumentProcessStatus.PARSING));
        }
        selfProxy.handleParseTask(task.getId());
        tech.easyflow.ai.entity.Document current = requireDocument(document.getId());
        return Result.ok(buildTaskStartResponse(task, DocumentProcessStatus.valueOf(current.getProcessStatus())));
    }

    /**
     * 重试向量化任务。
     *
     * @param request 重试请求
     * @return 任务结果
     */
    @Transactional
    public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(DocumentImportDtos.TaskRetryRequest request) {
        DocumentImportDtos.TaskStartIndexRequest startRequest = new DocumentImportDtos.TaskStartIndexRequest();
        startRequest.setKnowledgeId(request.getKnowledgeId());
        startRequest.setDocumentId(request.getDocumentId());
        return startIndexTask(startRequest);
    }

    /**
     * 处理解析任务消息。
     *
     * @param taskId 任务 ID
     */
    @Transactional
    public void handleParseTask(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        if (!DocumentImportTaskPhase.PARSE.name().equals(task.getPhase())) {
            LOG.warn("忽略非解析阶段任务: taskId={}, phase={}", taskId, task.getPhase());
            return;
        }
        if (DocumentImportTaskStatus.COMPLETED.name().equals(task.getStatus())
            || DocumentImportTaskStatus.FAILED.name().equals(task.getStatus())) {
            LOG.info("解析任务已结束，跳过重复处理: taskId={}, status={}", taskId, task.getStatus());
            return;
        }
        if (!tryMarkTaskRunning(taskId)) {
            LOG.info("解析任务未抢占成功，跳过本次执行: taskId={}", taskId);
            return;
        }
        task = requireTask(taskId);

        tech.easyflow.ai.entity.Document document = requireDocument(task.getDocumentId());
        LOG.info("开始执行文档解析任务: taskId={}, documentId={}, knowledgeId={}, currentStatus={}",
            taskId, document.getId(), task.getKnowledgeId(), document.getProcessStatus());

        try {
            String fileExt = normalizeFileExtension(document.getTitle(), document.getDocumentPath());
            if (shouldUseDocumentParseBridge(fileExt)) {
                handleBridgeParse(task, document, fileExt);
            } else {
                handleDefaultParse(task, document, fileExt);
            }
        } catch (Exception e) {
            LOG.error("文档解析任务失败: taskId={}, documentId={}", taskId, document.getId(), e);
            markParseFailed(task, document, truncateError(e.getMessage()));
        }
    }

    /**
     * 收敛运行中的桥接解析任务。
     */
    public void monitorRunningParseTasks() {
        QueryWrapper queryWrapper = QueryWrapper.create()
            .eq(DocumentImportTask::getPhase, DocumentImportTaskPhase.PARSE.name())
            .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
            .orderBy(DocumentImportTask::getModified, true)
            .limit(parseMonitorProperties.getBatchSize());
        List<DocumentImportTask> runningTasks = documentImportTaskService.list(queryWrapper);
        if (runningTasks == null || runningTasks.isEmpty()) {
            return;
        }
        for (DocumentImportTask task : runningTasks) {
            if (task == null || task.getId() == null || !StringUtil.hasText(task.getProviderTaskId())) {
                continue;
            }
            try {
                selfProxy.handleRunningParseTask(task.getId());
            } catch (Exception e) {
                LOG.error("收敛运行中的文档解析任务失败: taskId={}", task.getId(), e);
            }
        }
    }

    /**
     * 单次收敛指定解析任务的桥接状态。
     *
     * @param taskId 任务 ID
     */
    @Transactional
    public void handleRunningParseTask(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        if (!DocumentImportTaskPhase.PARSE.name().equals(task.getPhase())) {
            return;
        }
        if (!DocumentImportTaskStatus.RUNNING.name().equals(task.getStatus())
            || !StringUtil.hasText(task.getProviderTaskId())) {
            return;
        }
        tech.easyflow.ai.entity.Document document = requireDocument(task.getDocumentId());
        if (!DocumentProcessStatus.PARSING.name().equals(document.getProcessStatus())) {
            return;
        }
        String fileExt = normalizeFileExtension(document.getTitle(), document.getDocumentPath());
        if (!shouldUseDocumentParseBridge(fileExt)) {
            return;
        }
        try {
            syncBridgeParseTask(task, document, fileExt);
        } catch (Exception e) {
            LOG.error("文档解析任务收敛失败: taskId={}, documentId={}", taskId, document.getId(), e);
            markParseFailed(task, document, truncateError(e.getMessage()));
        }
    }

    /**
     * 处理向量化任务消息。
     *
     * @param taskId 任务 ID
     */
    @Transactional
    public void handleIndexTask(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        if (!DocumentImportTaskPhase.INDEX.name().equals(task.getPhase())) {
            LOG.warn("忽略非向量化阶段任务: taskId={}, phase={}", taskId, task.getPhase());
            return;
        }
        if (DocumentImportTaskStatus.COMPLETED.name().equals(task.getStatus())
            || DocumentImportTaskStatus.FAILED.name().equals(task.getStatus())) {
            LOG.info("向量化任务已结束，跳过重复处理: taskId={}, status={}", taskId, task.getStatus());
            return;
        }
        if (!tryMarkTaskRunning(taskId)) {
            LOG.info("向量化任务未抢占成功，跳过本次执行: taskId={}", taskId);
            return;
        }
        task = requireTask(taskId);

        tech.easyflow.ai.entity.Document document = requireDocument(task.getDocumentId());
        LOG.info("开始执行文档向量化任务: taskId={}, documentId={}, knowledgeId={}, currentStatus={}",
            taskId, document.getId(), task.getKnowledgeId(), document.getProcessStatus());
        StoreExecutionContext storeContext = null;
        List<DocumentChunk> storedChunks = new ArrayList<DocumentChunk>();
        try {
            DocumentCollection knowledge = assertDocumentCollection(task.getKnowledgeId());
            DocumentImportDtos.PreviewSession session = resolveIndexPreviewSession(
                knowledge,
                document,
                asString(task.getPayloadJson().get("previewSessionId"))
            );
            List<DocumentChunk> chunks = session.getDocumentChunks();
            if (chunks == null || chunks.isEmpty()) {
                throw new BusinessException("预览会话无有效分块");
            }

            clearPersistedChunks(document.getId());
            storeContext = prepareStoreContext(document);
            int totalChunks = chunks.size();
            int completedChunks = 0;
            for (int start = 0; start < chunks.size(); start += INDEX_BATCH_SIZE) {
                int end = Math.min(start + INDEX_BATCH_SIZE, chunks.size());
                List<DocumentChunk> batch = new ArrayList<DocumentChunk>(chunks.subList(start, end));
                LOG.info("文档向量化任务开始处理批次: taskId={}, documentId={}, batchStart={}, batchEnd={}, batchSize={}, totalChunks={}",
                    taskId, document.getId(), start, end, batch.size(), totalChunks);
                storeDocumentChunks(storeContext, batch);
                storedChunks.addAll(batch);
                persistChunkBatch(document, batch);
                completedChunks += batch.size();
                updateDocumentIndexProgress(document.getId(), totalChunks, completedChunks);
            }
            updateKnowledgeAfterStore(storeContext);
            markIndexCompleted(task, document, totalChunks);
            documentImportPreviewService.remove(session.getSessionId());
        } catch (Exception e) {
            LOG.error("文档向量化任务失败: taskId={}, documentId={}", taskId, document.getId(), e);
            clearPersistedChunks(document.getId());
            if (storeContext != null && !storedChunks.isEmpty()) {
                rollbackStoredChunks(taskId, document.getId(), storeContext, storedChunks);
            }
            markIndexFailed(task, document, truncateError(e.getMessage()));
        } finally {
            closeStoreContext(storeContext);
        }
    }

    /**
     * 通过统一文档解析桥接处理异步解析任务。
     *
     * @param task 任务实体
     * @param document 文档实体
     * @param fileExt 文件后缀
     */
    private void handleBridgeParse(DocumentImportTask task,
                                   tech.easyflow.ai.entity.Document document,
                                   String fileExt) {
        if (!StringUtil.hasText(task.getProviderTaskId())) {
            submitBridgeParseTask(task, document, fileExt);
            return;
        }
        syncBridgeParseTask(task, document, fileExt);
    }

    private void handleDefaultParse(DocumentImportTask task,
                                    tech.easyflow.ai.entity.Document document,
                                    String fileExt) {
        LOG.info("开始同步解析文档: taskId={}, documentId={}, fileName={}, fileExt={}",
            task.getId(), document.getId(), document.getTitle(), fileExt);
        String normalizedContent = readFileContent(document.getDocumentPath(), document.getTitle());
        DocumentParsedResult parsedResult = new DocumentParsedResult();
        parsedResult.setFileName(document.getTitle());
        parsedResult.setPreferredText(normalizedContent);
        parsedResult.setPlainText(normalizedContent);
        parsedResult.setMetadata(Map.of("sourceFormat", fileExt));
        LOG.info("同步解析文档完成: taskId={}, documentId={}, fileName={}, fileExt={}, contentLength={}",
            task.getId(), document.getId(), document.getTitle(), fileExt, normalizedContent == null ? 0 : normalizedContent.length());
        markParseSuccess(task, document, parsedResult, fileExt, null);
    }

    private void markParseSuccess(DocumentImportTask task,
                                  tech.easyflow.ai.entity.Document document,
                                  DocumentParsedResult parsedResult,
                                  String sourceFormat,
                                  String providerTaskId) {
        parsedResult = normalizeParsedImagesForKnowledgeImport(document, parsedResult);
        ParsedKnowledgeContent parsedKnowledgeContent = buildParsedKnowledgeContent(document, parsedResult, sourceFormat);
        if (!StringUtil.hasText(parsedKnowledgeContent.documentLlmContent)) {
            throw new BusinessException("文档解析结果为空");
        }

        Map<String, Object> options = copyOptions(document.getOptions());
        options.put(DocumentImportKeys.KEY_DOCUMENT_SOURCE_FILE_EXT, sourceFormat);
        if (StringUtil.hasText(providerTaskId)) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PROVIDER_TASK_ID, providerTaskId);
        }
        if (parsedResult.getMetadata() != null && !parsedResult.getMetadata().isEmpty()) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_METADATA, new LinkedHashMap<String, Object>(parsedResult.getMetadata()));
        }
        if (parsedResult.getWarnings() != null && !parsedResult.getWarnings().isEmpty()) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_WARNINGS, new ArrayList<String>(parsedResult.getWarnings()));
        }
        if (parsedResult.getMetadata() != null) {
            Object imageUrls = parsedResult.getMetadata().get(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_URLS);
            if (imageUrls instanceof List<?>) {
                options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_URLS, new ArrayList<Object>((List<?>) imageUrls));
            }
            Object imageCount = parsedResult.getMetadata().get(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_COUNT);
            if (imageCount != null) {
                options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_COUNT, imageCount);
            }
            Object imageStoragePrefix = parsedResult.getMetadata().get(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_STORAGE_PREFIX);
            if (StringUtil.hasText(asString(imageStoragePrefix))) {
                options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_STORAGE_PREFIX, imageStoragePrefix);
            }
        }
        options.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, parsedKnowledgeContent.documentRenderMarkdown);
        options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY, parsedKnowledgeContent.parseArtifactSummary);
        clearDocumentParseProgress(options);

        Date now = new Date();
        document.setContent(parsedKnowledgeContent.documentLlmContent);
        document.setDocumentType(sourceFormat);
        document.setOptions(options);
        document.setProcessStatus(DocumentProcessStatus.READY_FOR_SEGMENT.name());
        document.setProgressPercent(0);
        document.setTotalChunks(0);
        document.setCompletedChunks(0);
        document.setFailedChunks(0);
        document.setLastTaskError(null);
        persistDocumentTaskState(document, now);
        LOG.info("文档解析任务完成: taskId={}, documentId={}, processStatus={}, providerTaskId={}, contentLength={}",
            task.getId(), document.getId(), DocumentProcessStatus.READY_FOR_SEGMENT.name(), providerTaskId,
            parsedKnowledgeContent.documentLlmContent.length());

        finishTask(task, now, DocumentImportTaskStatus.COMPLETED, null);
    }

    /**
     * 将解析结果中的图片上传到对象存储，并把 Markdown/结构化工件里的相对路径回写为可访问 URL。
     *
     * <p>该逻辑仅服务于知识库导入链路，避免把对象存储耦合下沉到通用解析 provider 或 MinerU 自身。</p>
     *
     * @param document 文档实体
     * @param parsedResult 解析结果
     * @return 已完成图片标准化的解析结果
     */
    private DocumentParsedResult normalizeParsedImagesForKnowledgeImport(tech.easyflow.ai.entity.Document document,
                                                                         DocumentParsedResult parsedResult) {
        if (document == null || parsedResult == null || parsedResult.getImages() == null || parsedResult.getImages().isEmpty()) {
            return parsedResult;
        }
        Map<String, String> storedImageUrls = uploadParsedImages(document, parsedResult.getImages());
        if (storedImageUrls.isEmpty()) {
            return parsedResult;
        }
        rewriteParsedResultImageReferences(parsedResult, storedImageUrls);
        parsedResult.getMetadata().put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_URLS, new ArrayList<String>(new LinkedHashSet<String>(storedImageUrls.values())));
        parsedResult.getMetadata().put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_COUNT, parsedResult.getImages().size());
        parsedResult.getMetadata().put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_STORAGE_PREFIX, buildKnowledgeParseImagePrePath(document));
        return parsedResult;
    }

    /**
     * 上传解析结果中的图片，并建立“原始相对路径/文件名 -> 最终 URL”的映射。
     *
     * @param document 文档实体
     * @param images 解析出的图片集合
     * @return 图片引用映射
     */
    private Map<String, String> uploadParsedImages(tech.easyflow.ai.entity.Document document,
                                                   List<DocumentImage> images) {
        Map<String, String> storedImageUrls = new LinkedHashMap<String, String>();
        if (document == null || images == null || images.isEmpty()) {
            return storedImageUrls;
        }
        Map<String, Integer> baseNameCounts = countImageBaseNames(images);
        String imagePrePath = buildKnowledgeParseImagePrePath(document);
        int imageIndex = 0;
        for (DocumentImage image : images) {
            if (image == null) {
                imageIndex++;
                continue;
            }
            byte[] imageBytes = resolveParsedImageContent(image);
            if (imageBytes.length == 0) {
                imageIndex++;
                continue;
            }
            String fileName = resolveParsedImageFileName(image, imageIndex);
            String mimeType = resolveParsedImageMimeType(image, fileName);
            CustomMultipartFile multipartFile = new CustomMultipartFile(imageBytes, fileName, fileName, mimeType);
            String storedUrl = storageService.save(multipartFile, imagePrePath);
            registerStoredImageUrl(storedImageUrls, image.getSourcePath(), storedUrl, baseNameCounts);
            registerStoredImageUrl(storedImageUrls, image.getName(), storedUrl, baseNameCounts);
            image.setSourcePath(storedUrl);
            image.setDataUrl(null);
            image.setContent(null);
            if (!StringUtil.hasText(image.getMimeType())) {
                image.setMimeType(mimeType);
            }
            imageIndex++;
        }
        return storedImageUrls;
    }

    /**
     * 重写解析结果中的 Markdown、图片块与结构化工件引用。
     *
     * @param parsedResult 解析结果
     * @param storedImageUrls 原始路径到最终 URL 的映射
     */
    private void rewriteParsedResultImageReferences(DocumentParsedResult parsedResult,
                                                    Map<String, String> storedImageUrls) {
        if (parsedResult == null || storedImageUrls == null || storedImageUrls.isEmpty()) {
            return;
        }
        String originalMarkdown = parsedResult.getMarkdown();
        String rewrittenMarkdown = rewriteMarkdownImageReferences(originalMarkdown, storedImageUrls);
        if (rewrittenMarkdown != null) {
            parsedResult.setMarkdown(rewrittenMarkdown);
            if (!StringUtil.hasText(parsedResult.getPreferredText()) || Objects.equals(parsedResult.getPreferredText(), originalMarkdown)) {
                parsedResult.setPreferredText(rewrittenMarkdown);
            }
            if (!StringUtil.hasText(parsedResult.getPlainText()) || Objects.equals(parsedResult.getPlainText(), originalMarkdown)) {
                parsedResult.setPlainText(rewrittenMarkdown);
            }
        }
        rewriteBlockImageReferences(parsedResult.getBlocks(), storedImageUrls);
        rewriteTableImageReferences(parsedResult.getTables(), storedImageUrls);
        rewriteDocumentImageReferences(parsedResult.getImages(), storedImageUrls);
        rewriteArtifactImageReferences(parsedResult.getArtifacts(), storedImageUrls);
    }

    private void markParseFailed(DocumentImportTask task,
                                 tech.easyflow.ai.entity.Document document,
                                 String errorMessage) {
        Date now = new Date();
        document.setProcessStatus(DocumentProcessStatus.PARSE_FAILED.name());
        document.setLastTaskError(errorMessage);
        persistDocumentTaskState(document, now);
        LOG.warn("文档解析任务失败: taskId={}, documentId={}, processStatus={}, error={}",
            task.getId(), document.getId(), DocumentProcessStatus.PARSE_FAILED.name(), errorMessage);

        finishTask(task, now, DocumentImportTaskStatus.FAILED, errorMessage);
    }

    /**
     * 统计图片文件名，便于仅在“文件名唯一”时使用 basename 兜底匹配。
     *
     * @param images 图片集合
     * @return 文件名计数
     */
    private Map<String, Integer> countImageBaseNames(List<DocumentImage> images) {
        Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
        if (images == null || images.isEmpty()) {
            return counts;
        }
        for (DocumentImage image : images) {
            if (image == null) {
                continue;
            }
            String baseName = resolveImageBaseName(image.getSourcePath(), image.getName());
            if (!StringUtil.hasText(baseName)) {
                continue;
            }
            counts.put(baseName, counts.getOrDefault(baseName, 0) + 1);
        }
        return counts;
    }

    /**
     * 构建知识库导入图片存储前缀。
     *
     * @param document 文档实体
     * @return 存储前缀
     */
    private String buildKnowledgeParseImagePrePath(tech.easyflow.ai.entity.Document document) {
        String documentSegment = buildKnowledgeParseDocumentSegment(document);
        return KNOWLEDGE_PARSE_IMAGE_CATEGORY + "/" + documentSegment + "/images";
    }

    /**
     * 构建对象存储中的文档级目录名。
     *
     * @param document 文档实体
     * @return 文档目录名
     */
    private String buildKnowledgeParseDocumentSegment(tech.easyflow.ai.entity.Document document) {
        String documentId = document == null || document.getId() == null ? "unknown" : document.getId().toString();
        String safeFileName = sanitizeStorageSegment(removeFileExtension(document == null ? null : document.getTitle()));
        return StringUtil.hasText(safeFileName) ? documentId + "_" + safeFileName : documentId;
    }

    /**
     * 去除文件扩展名，只保留主文件名。
     *
     * @param fileName 原始文件名
     * @return 主文件名
     */
    private String removeFileExtension(String fileName) {
        String pureFileName = fileName == null ? null : tech.easyflow.common.filestorage.utils.PathGeneratorUtil.getPureFileName(fileName);
        if (!StringUtil.hasText(pureFileName)) {
            return null;
        }
        int dotIndex = pureFileName.lastIndexOf('.');
        if (dotIndex <= 0) {
            return pureFileName;
        }
        return pureFileName.substring(0, dotIndex);
    }

    /**
     * 将目录片段标准化为对象存储友好的名称。
     *
     * @param rawSegment 原始片段
     * @return 规范化后的片段
     */
    private String sanitizeStorageSegment(String rawSegment) {
        if (!StringUtil.hasText(rawSegment)) {
            return null;
        }
        String normalized = rawSegment.trim()
            .replaceAll("[\\\\/]+", "_")
            .replaceAll("[^\\p{IsAlphabetic}\\p{IsDigit}_-]+", "_")
            .replaceAll("_+", "_")
            .replaceAll("^_+|_+$", "");
        if (!StringUtil.hasText(normalized)) {
            return "document";
        }
        return normalized.length() > 80 ? normalized.substring(0, 80) : normalized;
    }

    /**
     * 解析 data URL，提取二进制图片内容。
     *
     * @param dataUrl data URL
     * @return 图片字节数组
     */
    private byte[] decodeDataUrl(String dataUrl) {
        if (!StringUtil.hasText(dataUrl)) {
            return new byte[0];
        }
        int commaIndex = dataUrl.indexOf(',');
        if (commaIndex < 0 || commaIndex >= dataUrl.length() - 1) {
            return new byte[0];
        }
        try {
            return Base64.getDecoder().decode(dataUrl.substring(commaIndex + 1));
        } catch (IllegalArgumentException exception) {
            throw new BusinessException("解析文档图片数据失败");
        }
    }

    /**
     * 解析图片 MIME 类型。
     *
     * @param image 图片实体
     * @param fileName 文件名
     * @return MIME 类型
     */
    private String resolveParsedImageMimeType(DocumentImage image, String fileName) {
        if (image != null && StringUtil.hasText(image.getMimeType())) {
            return image.getMimeType();
        }
        String mimeType = URLConnection.guessContentTypeFromName(fileName);
        return StringUtil.hasText(mimeType) ? mimeType : "image/png";
    }

    /**
     * 为上传对象存储生成稳定文件名。
     *
     * @param image 图片实体
     * @param imageIndex 图片序号
     * @return 文件名
     */
    private String resolveParsedImageFileName(DocumentImage image, int imageIndex) {
        String candidate = resolveImageBaseName(image == null ? null : image.getSourcePath(), image == null ? null : image.getName());
        if (!StringUtil.hasText(candidate)) {
            candidate = "image_" + (imageIndex + 1) + ".png";
        }
        String pureFileName = tech.easyflow.common.filestorage.utils.PathGeneratorUtil.getPureFileName(candidate);
        String normalized = pureFileName.replaceAll("[^\\p{IsAlphabetic}\\p{IsDigit}._-]+", "_");
        if (!StringUtil.hasText(normalized)) {
            normalized = "image_" + (imageIndex + 1) + ".png";
        }
        if (!normalized.contains(".")) {
            normalized = normalized + ".png";
        }
        return normalized;
    }

    /**
     * 注册图片路径映射。
     *
     * @param storedImageUrls 映射表
     * @param originalPath 原始路径
     * @param storedUrl 最终 URL
     * @param baseNameCounts 文件名计数
     */
    private void registerStoredImageUrl(Map<String, String> storedImageUrls,
                                        String originalPath,
                                        String storedUrl,
                                        Map<String, Integer> baseNameCounts) {
        if (!StringUtil.hasText(originalPath) || !StringUtil.hasText(storedUrl)) {
            return;
        }
        storedImageUrls.putIfAbsent(originalPath, storedUrl);
        storedImageUrls.putIfAbsent(trimLeadingCurrentPath(originalPath), storedUrl);
        String baseName = resolveImageBaseName(originalPath, null);
        if (StringUtil.hasText(baseName) && baseNameCounts.getOrDefault(baseName, 0) == 1) {
            storedImageUrls.putIfAbsent(baseName, storedUrl);
        }
    }

    /**
     * 重写 Markdown 中的图片引用。
     *
     * @param markdown Markdown 内容
     * @param storedImageUrls 图片映射
     * @return 回写后的 Markdown
     */
    private String rewriteMarkdownImageReferences(String markdown, Map<String, String> storedImageUrls) {
        if (!StringUtil.hasText(markdown) || storedImageUrls == null || storedImageUrls.isEmpty()) {
            return markdown;
        }
        Matcher matcher = MARKDOWN_IMAGE_PATTERN.matcher(markdown);
        StringBuffer output = new StringBuffer();
        boolean replaced = false;
        while (matcher.find()) {
            String originalPath = matcher.group(1);
            String resolvedUrl = resolveStoredImageUrl(originalPath, storedImageUrls);
            if (!StringUtil.hasText(resolvedUrl)) {
                matcher.appendReplacement(output, Matcher.quoteReplacement(matcher.group(0)));
                continue;
            }
            String replacedSegment = matcher.group(0).replace("(" + originalPath + ")", "(" + resolvedUrl + ")");
            matcher.appendReplacement(output, Matcher.quoteReplacement(replacedSegment));
            replaced = true;
        }
        if (!replaced) {
            return markdown;
        }
        matcher.appendTail(output);
        return output.toString();
    }

    /**
     * 回写块级图片引用。
     *
     * @param blocks 内容块集合
     * @param storedImageUrls 图片映射
     */
    private void rewriteBlockImageReferences(List<DocumentBlock> blocks, Map<String, String> storedImageUrls) {
        if (blocks == null || blocks.isEmpty()) {
            return;
        }
        for (DocumentBlock block : blocks) {
            if (block == null || !StringUtil.hasText(block.getImagePath())) {
                continue;
            }
            String resolvedUrl = resolveStoredImageUrl(block.getImagePath(), storedImageUrls);
            if (StringUtil.hasText(resolvedUrl)) {
                block.setImagePath(resolvedUrl);
            }
        }
    }

    /**
     * 回写表格图片引用。
     *
     * @param tables 表格集合
     * @param storedImageUrls 图片映射
     */
    private void rewriteTableImageReferences(List<DocumentTable> tables, Map<String, String> storedImageUrls) {
        if (tables == null || tables.isEmpty()) {
            return;
        }
        for (DocumentTable table : tables) {
            if (table == null || !StringUtil.hasText(table.getImagePath())) {
                continue;
            }
            String resolvedUrl = resolveStoredImageUrl(table.getImagePath(), storedImageUrls);
            if (StringUtil.hasText(resolvedUrl)) {
                table.setImagePath(resolvedUrl);
            }
        }
    }

    /**
     * 回写图片实体引用。
     *
     * @param images 图片集合
     * @param storedImageUrls 图片映射
     */
    private void rewriteDocumentImageReferences(List<DocumentImage> images, Map<String, String> storedImageUrls) {
        if (images == null || images.isEmpty()) {
            return;
        }
        for (DocumentImage image : images) {
            if (image == null || !StringUtil.hasText(image.getSourcePath())) {
                continue;
            }
            String resolvedUrl = resolveStoredImageUrl(image.getSourcePath(), storedImageUrls);
            if (StringUtil.hasText(resolvedUrl)) {
                image.setSourcePath(resolvedUrl);
            }
        }
    }

    /**
     * 回写结构化工件中的图片路径。
     *
     * @param artifacts 解析工件
     * @param storedImageUrls 图片映射
     */
    private void rewriteArtifactImageReferences(DocumentParseArtifacts artifacts,
                                                Map<String, String> storedImageUrls) {
        if (artifacts == null) {
            return;
        }
        rewriteImageReferencesInNode(artifacts.getMiddleJson(), storedImageUrls);
        rewriteImageReferencesInNode(artifacts.getContentList(), storedImageUrls);
        rewriteImageReferencesInNode(artifacts.getModelOutput(), storedImageUrls);
        if (artifacts.getExtraJsonArtifacts() != null && !artifacts.getExtraJsonArtifacts().isEmpty()) {
            for (Map.Entry<String, Object> entry : artifacts.getExtraJsonArtifacts().entrySet()) {
                Object normalizedNode = normalizeArtifactNode(entry.getValue());
                rewriteImageReferencesInNode(normalizedNode, storedImageUrls);
                entry.setValue(normalizedNode);
            }
        }
    }

    private Object normalizeArtifactNode(Object node) {
        if (node == null || node instanceof Map<?, ?> || node instanceof List<?>) {
            return node;
        }
        return JSON.parse(JSON.toJSONString(node));
    }

    private byte[] resolveParsedImageContent(DocumentImage image) {
        if (image == null) {
            return new byte[0];
        }
        if (image.getContent() != null && image.getContent().length > 0) {
            return image.getContent();
        }
        return decodeDataUrl(image.getDataUrl());
    }

    /**
     * 递归回写 JSON/Object 树里的图片路径字段。
     *
     * @param node 当前节点
     * @param storedImageUrls 图片映射
     */
    @SuppressWarnings("unchecked")
    private void rewriteImageReferencesInNode(Object node, Map<String, String> storedImageUrls) {
        if (node instanceof Map<?, ?> mapNode) {
            for (Map.Entry<?, ?> entry : ((Map<?, ?>) mapNode).entrySet()) {
                Object value = entry.getValue();
                if (entry.getKey() instanceof String key) {
                    if (value instanceof String stringValue && isImageReferenceKey(key)) {
                        String resolvedUrl = resolveStoredImageUrl(stringValue, storedImageUrls);
                        if (StringUtil.hasText(resolvedUrl)) {
                            ((Map<Object, Object>) mapNode).put(entry.getKey(), resolvedUrl);
                        }
                        continue;
                    }
                    if (value instanceof List<?> listValue && isImageReferenceArrayKey(key)) {
                        rewriteImageReferenceList(listValue, storedImageUrls);
                        continue;
                    }
                }
                rewriteImageReferencesInNode(value, storedImageUrls);
            }
            return;
        }
        if (node instanceof List<?> listNode) {
            for (Object item : listNode) {
                rewriteImageReferencesInNode(item, storedImageUrls);
            }
        }
    }

    /**
     * 判断字段是否为图片路径字段。
     *
     * @param key 字段名
     * @return 是否是图片路径字段
     */
    private boolean isImageReferenceKey(String key) {
        return "img_path".equals(key)
            || "image_path".equals(key)
            || "imagePath".equals(key)
            || "sourcePath".equals(key);
    }

    private boolean isImageReferenceArrayKey(String key) {
        return "sourcePaths".equals(key)
            || DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS.equals(key);
    }

    @SuppressWarnings("unchecked")
    private void rewriteImageReferenceList(List<?> imageReferences, Map<String, String> storedImageUrls) {
        for (int index = 0; index < imageReferences.size(); index++) {
            Object item = imageReferences.get(index);
            if (item instanceof String imagePath) {
                String resolvedUrl = resolveStoredImageUrl(imagePath, storedImageUrls);
                if (StringUtil.hasText(resolvedUrl)) {
                    ((List<Object>) imageReferences).set(index, resolvedUrl);
                }
                continue;
            }
            rewriteImageReferencesInNode(item, storedImageUrls);
        }
    }

    /**
     * 根据原始路径查找最终图片 URL。
     *
     * @param originalPath 原始路径
     * @param storedImageUrls 图片映射
     * @return 最终 URL
     */
    private String resolveStoredImageUrl(String originalPath, Map<String, String> storedImageUrls) {
        if (!StringUtil.hasText(originalPath) || storedImageUrls == null || storedImageUrls.isEmpty()) {
            return null;
        }
        String exact = storedImageUrls.get(originalPath);
        if (StringUtil.hasText(exact)) {
            return exact;
        }
        String trimmedPath = trimLeadingCurrentPath(originalPath);
        exact = storedImageUrls.get(trimmedPath);
        if (StringUtil.hasText(exact)) {
            return exact;
        }
        String baseName = resolveImageBaseName(originalPath, null);
        if (StringUtil.hasText(baseName)) {
            return storedImageUrls.get(baseName);
        }
        return null;
    }

    /**
     * 获取图片基础文件名。
     *
     * @param sourcePath 原始路径
     * @param fallbackName 兜底名称
     * @return 基础文件名
     */
    private String resolveImageBaseName(String sourcePath, String fallbackName) {
        String candidate = StringUtil.hasText(sourcePath) ? sourcePath : fallbackName;
        if (!StringUtil.hasText(candidate)) {
            return null;
        }
        return tech.easyflow.common.filestorage.utils.PathGeneratorUtil.getPureFileName(trimLeadingCurrentPath(candidate));
    }

    /**
     * 去掉 Markdown 相对路径里的 "./" 前缀，便于统一匹配。
     *
     * @param path 原始路径
     * @return 规范化后的路径
     */
    private String trimLeadingCurrentPath(String path) {
        if (!StringUtil.hasText(path)) {
            return path;
        }
        return path.replaceFirst("^\\./+", "");
    }

    private void markIndexCompleted(DocumentImportTask task,
                                    tech.easyflow.ai.entity.Document document,
                                    int totalChunks) {
        Date now = new Date();
        document.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
        document.setTotalChunks(totalChunks);
        document.setCompletedChunks(totalChunks);
        document.setFailedChunks(0);
        document.setProgressPercent(100);
        document.setLastTaskError(null);
        persistDocumentTaskState(document, now);
        LOG.info("文档向量化任务完成: taskId={}, documentId={}, processStatus={}, totalChunks={}",
            task.getId(), document.getId(), DocumentProcessStatus.COMPLETED.name(), totalChunks);

        finishTask(task, now, DocumentImportTaskStatus.COMPLETED, null);
    }

    private void markIndexFailed(DocumentImportTask task,
                                 tech.easyflow.ai.entity.Document document,
                                 String errorMessage) {
        tech.easyflow.ai.entity.Document current = requireDocument(document.getId());
        Date now = new Date();
        current.setProcessStatus(DocumentProcessStatus.INDEX_FAILED.name());
        current.setTotalChunks(defaultInt(current.getTotalChunks()));
        current.setCompletedChunks(0);
        current.setFailedChunks(defaultInt(current.getTotalChunks()));
        current.setProgressPercent(0);
        current.setLastTaskError(errorMessage);
        persistDocumentTaskState(current, now);
        LOG.warn("文档向量化任务失败: taskId={}, documentId={}, processStatus={}, completedChunks={}, totalChunks={}, error={}",
            task.getId(),
            document.getId(),
            DocumentProcessStatus.INDEX_FAILED.name(),
            defaultInt(current.getCompletedChunks()),
            defaultInt(current.getTotalChunks()),
            errorMessage);

        finishTask(task, now, DocumentImportTaskStatus.FAILED, errorMessage);
    }

    /**
     * 尝试将待处理任务抢占为运行中。
     *
     * @param taskId 任务 ID
     * @return 是否抢占成功
     */
    private boolean tryMarkTaskRunning(BigInteger taskId) {
        Date now = new Date();
        DocumentImportTask update = new DocumentImportTask();
        update.setStatus(DocumentImportTaskStatus.RUNNING.name());
        update.setStartedAt(now);
        update.setModified(now);
        update.setModifiedBy(resolveOperatorId());

        QueryWrapper queryWrapper = QueryWrapper.create()
            .eq(DocumentImportTask::getId, taskId)
            .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.PENDING.name());
        return documentImportTaskMapper.updateByQuery(update, queryWrapper) > 0;
    }

    private void updateTaskProvider(DocumentImportTask task, String providerTaskId) {
        task.setProviderTaskId(providerTaskId);
        task.setModified(new Date());
        Map<String, Object> payload = new LinkedHashMap<String, Object>(task.getPayloadJson());
        payload.put("providerTaskId", providerTaskId);
        task.setPayloadJson(payload);
        documentImportTaskService.updateById(task);
    }

    private void updateDocumentIndexing(tech.easyflow.ai.entity.Document document, int totalChunks) {
        Date now = new Date();
        document.setProcessStatus(DocumentProcessStatus.INDEXING.name());
        document.setTotalChunks(totalChunks);
        document.setCompletedChunks(0);
        document.setFailedChunks(0);
        document.setProgressPercent(0);
        document.setLastTaskError(null);
        persistDocumentTaskState(document, now);
    }

    private void updateDocumentIndexProgress(BigInteger documentId, int totalChunks, int completedChunks) {
        int progressPercent = Math.min(100, totalChunks <= 0 ? 0 : (completedChunks * 100) / totalChunks);
        tech.easyflow.ai.entity.Document document = requireDocument(documentId);
        Date now = new Date();
        document.setCompletedChunks(completedChunks);
        document.setFailedChunks(0);
        document.setProgressPercent(progressPercent);
        persistDocumentTaskState(document, now);
        LOG.info("文档向量化进度更新: documentId={}, completedChunks={}, totalChunks={}, progressPercent={}",
            documentId, completedChunks, totalChunks, progressPercent);
    }

    private void resetDocumentForParseRetry(tech.easyflow.ai.entity.Document document) {
        Date now = new Date();
        document.setProcessStatus(DocumentProcessStatus.PARSING.name());
        document.setLastTaskError(null);
        document.setProgressPercent(0);
        Map<String, Object> options = copyOptions(document.getOptions());
        clearDocumentParseProgress(options);
        document.setOptions(options);
        persistDocumentTaskState(document, now);
    }

    private void updateDocumentParseProgress(tech.easyflow.ai.entity.Document document,
                                             DocumentParseTaskInfo taskInfo) {
        if (document == null || taskInfo == null) {
            return;
        }
        tech.easyflow.ai.entity.Document current = requireDocument(document.getId());
        Map<String, Object> options = copyOptions(current.getOptions());
        options.put(DocumentImportKeys.KEY_DOCUMENT_PROVIDER_TASK_ID, taskInfo.getTaskId());
        if (taskInfo.getProgressPercent() != null) {
            current.setProgressPercent(taskInfo.getProgressPercent());
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_PROGRESS_PERCENT, taskInfo.getProgressPercent());
        }
        if (StringUtil.hasText(taskInfo.getCurrentStage())) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_CURRENT_STAGE, taskInfo.getCurrentStage());
        }
        if (taskInfo.getProcessedItems() != null) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_PROCESSED_ITEMS, taskInfo.getProcessedItems());
        }
        if (taskInfo.getTotalItems() != null) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_TOTAL_ITEMS, taskInfo.getTotalItems());
        }
        if (StringUtil.hasText(taskInfo.getStatusMessage())) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_STATUS_MESSAGE, taskInfo.getStatusMessage());
        }
        current.setOptions(options);
        persistDocumentTaskState(current, new Date());
    }

    /**
     * 持久化文档任务状态并推送局部状态刷新事件。
     *
     * @param document 文档实体
     * @param now 当前时间
     */
    private void persistDocumentTaskState(tech.easyflow.ai.entity.Document document, Date now) {
        document.setTaskModifiedAt(now);
        document.setModified(now);
        document.setModifiedBy(resolveOperatorId());
        documentMapper.update(document, false);
        documentImportTaskStatusStreamService.publishAfterCommit(document.getId());
    }

    /**
     * 收口任务终态更新，避免不同阶段遗漏错误信息或完成时间。
     *
     * @param task 任务实体
     * @param now 当前时间
     * @param status 终态
     * @param errorSummary 错误摘要
     */
    private void finishTask(DocumentImportTask task,
                            Date now,
                            DocumentImportTaskStatus status,
                            String errorSummary) {
        task.setStatus(status.name());
        task.setErrorSummary(errorSummary);
        task.setFinishedAt(now);
        task.setModified(now);
        documentImportTaskService.updateById(task, false);
    }

    private DocumentImportDtos.PreviewSession buildPreviewSessionForDocument(DocumentCollection knowledge,
                                                                            tech.easyflow.ai.entity.Document document,
                                                                            StrategyConfig requestedStrategy) {
        if (!StringUtil.hasText(document.getContent())) {
            throw new BusinessException("文档尚未完成解析");
        }
        String sourceFormat = normalizeSourceFormat(document);
        if (isOfficeDocument(sourceFormat)) {
            return buildOfficePreviewSession(knowledge, document, requestedStrategy, sourceFormat);
        }
        AnalysisResult analysis = ragIngestionService.analyze(document.getContent(), normalizeSourceFormat(document));
        StrategyConfig strategyConfig = resolveStrategyConfig(knowledge, requestedStrategy, analysis);
        List<RagChunk> previewChunks = ragIngestionService.split(analysis, strategyConfig);
        if (previewChunks == null || previewChunks.isEmpty()) {
            throw new BusinessException("未生成有效分块，请调整策略后重试");
        }
        validatePreviewChunkRanges(previewChunks, analysis.getNormalizedContent());
        List<DocumentChunk> documentChunks = buildDocumentChunks(document, previewChunks);
        DocumentImportDtos.PreviewSession session = new DocumentImportDtos.PreviewSession();
        session.setKnowledgeId(knowledge.getId());
        session.setDocumentId(document.getId());
        session.setFilePath(document.getDocumentPath());
        session.setFileName(document.getTitle());
        session.setSourceFormat(normalizeSourceFormat(document));
        session.setStrategyConfig(strategyConfig);
        session.setAnalysis(analysis);
        session.setDocument(document);
        session.setDocumentChunks(documentChunks);
        session.setPreviewChunks(previewChunks);
        session.setCreatedAt(new Date());
        return session;
    }

    private DocumentImportDtos.PreviewSession buildOfficePreviewSession(DocumentCollection knowledge,
                                                                       tech.easyflow.ai.entity.Document document,
                                                                       StrategyConfig requestedStrategy,
                                                                       String sourceFormat) {
        StrategyConfig strategyConfig = resolveOfficeStrategyConfig(document, requestedStrategy, sourceFormat);
        List<DocumentChunk> documentChunks = buildOfficeDocumentChunks(document, sourceFormat, strategyConfig);
        if (documentChunks.isEmpty()) {
            throw new BusinessException("当前 Office 文档未生成有效分块");
        }
        AnalysisResult analysis = buildOfficeAnalysis(document, sourceFormat, strategyConfig, documentChunks.size());

        DocumentImportDtos.PreviewSession session = new DocumentImportDtos.PreviewSession();
        session.setKnowledgeId(knowledge.getId());
        session.setDocumentId(document.getId());
        session.setFilePath(document.getDocumentPath());
        session.setFileName(document.getTitle());
        session.setSourceFormat(sourceFormat);
        session.setStrategyConfig(strategyConfig);
        session.setAnalysis(analysis);
        session.setDocument(document);
        session.setDocumentChunks(documentChunks);
        session.setPreviewChunks(new ArrayList<RagChunk>());
        session.setCreatedAt(new Date());
        return session;
    }

    private StrategyConfig resolveOfficeStrategyConfig(tech.easyflow.ai.entity.Document document,
                                                       StrategyConfig requestedStrategy,
                                                       String sourceFormat) {
        StrategyConfig config = StrategyConfig.defaults();
        if (requestedStrategy != null) {
            mergeStrategyConfig(config, requestedStrategy);
        }
        if ("pptx".equals(sourceFormat)) {
            config.setStrategyCode(OFFICE_PPTX_PAGE_STRATEGY);
            config.setRowsPerChunk(null);
            return config;
        }
        Integer rowsPerChunk = requestedStrategy == null ? null : requestedStrategy.getRowsPerChunk();
        if (rowsPerChunk == null || rowsPerChunk <= 0) {
            StrategyConfig storedStrategy = null;
            try {
                storedStrategy = readStoredStrategy(document);
            } catch (Exception ignored) {
                storedStrategy = null;
            }
            rowsPerChunk = storedStrategy == null ? null : storedStrategy.getRowsPerChunk();
        }
        if (rowsPerChunk == null || rowsPerChunk <= 0) {
            rowsPerChunk = 10;
        }
        config.setStrategyCode(OFFICE_XLSX_ROW_WINDOW_STRATEGY);
        config.setRowsPerChunk(rowsPerChunk);
        return config;
    }

    private AnalysisResult buildOfficeAnalysis(tech.easyflow.ai.entity.Document document,
                                               String sourceFormat,
                                               StrategyConfig strategyConfig,
                                               int totalChunks) {
        AnalysisResult analysis = new AnalysisResult();
        analysis.setSourceFormat(sourceFormat);
        analysis.setNormalizedContent(document.getContent());
        analysis.setRecommendedStrategyCode(strategyConfig.getStrategyCode());
        analysis.setRecommendedStrategyLabel(resolveStrategyLabel(sourceFormat, strategyConfig));
        analysis.setConfidence(1.0D);
        analysis.getFeatures().put("officeChunkCount", totalChunks);
        analysis.getFeatures().put("rowsPerChunk", strategyConfig.getRowsPerChunk());
        analysis.getFeatures().put("sourceFormat", sourceFormat);
        return analysis;
    }

    private ParsedKnowledgeContent buildParsedKnowledgeContent(tech.easyflow.ai.entity.Document document,
                                                               DocumentParsedResult parsedResult,
                                                               String sourceFormat) {
        String renderMarkdown = StringUtil.hasText(parsedResult.getMarkdown())
            ? parsedResult.getMarkdown()
            : resolvePreferredText(parsedResult);
        Map<String, Object> parseArtifactSummary = extractParseArtifactSummary(parsedResult, sourceFormat);
        if (!isOfficeDocument(sourceFormat)) {
            return new ParsedKnowledgeContent(resolvePreferredText(parsedResult), renderMarkdown, parseArtifactSummary);
        }
        List<DocumentChunk> documentChunks = buildOfficeDocumentChunks(document, sourceFormat,
            resolveOfficeStrategyConfig(document, null, sourceFormat), parseArtifactSummary);
        StringBuilder llmContent = new StringBuilder();
        for (DocumentChunk chunk : documentChunks) {
            if (!StringUtil.hasText(chunk.getContent())) {
                continue;
            }
            if (llmContent.length() > 0) {
                llmContent.append("\n\n");
            }
            llmContent.append(chunk.getContent().trim());
        }
        return new ParsedKnowledgeContent(llmContent.toString().trim(), renderMarkdown, parseArtifactSummary);
    }

    private Map<String, Object> extractParseArtifactSummary(DocumentParsedResult parsedResult,
                                                            String sourceFormat) {
        if (parsedResult == null || parsedResult.getArtifacts() == null
            || parsedResult.getArtifacts().getExtraJsonArtifacts() == null) {
            return new LinkedHashMap<String, Object>();
        }
        Object artifact = parsedResult.getArtifacts().getExtraJsonArtifacts().get(sourceFormat);
        Object normalizedArtifact = normalizeArtifactNode(artifact);
        if (normalizedArtifact instanceof Map<?, ?> map) {
            return new LinkedHashMap<String, Object>((Map<String, Object>) map);
        }
        return new LinkedHashMap<String, Object>();
    }

    private boolean isOfficeDocument(String sourceFormat) {
        return "pptx".equals(sourceFormat) || "xlsx".equals(sourceFormat);
    }

    private List<DocumentChunk> buildOfficeDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                          String sourceFormat,
                                                          StrategyConfig strategyConfig) {
        Object rawArtifactSummary = document.getOptions() == null
            ? null
            : document.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY);
        Map<String, Object> parseArtifactSummary = rawArtifactSummary instanceof Map<?, ?>
            ? new LinkedHashMap<String, Object>((Map<String, Object>) rawArtifactSummary)
            : new LinkedHashMap<String, Object>();
        return buildOfficeDocumentChunks(document, sourceFormat, strategyConfig, parseArtifactSummary);
    }

    private List<DocumentChunk> buildOfficeDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                          String sourceFormat,
                                                          StrategyConfig strategyConfig,
                                                          Map<String, Object> parseArtifactSummary) {
        if ("pptx".equals(sourceFormat)) {
            return buildPptxDocumentChunks(document, parseArtifactSummary);
        }
        if ("xlsx".equals(sourceFormat)) {
            int rowsPerChunk = strategyConfig == null || strategyConfig.getRowsPerChunk() == null
                ? 10
                : Math.max(1, strategyConfig.getRowsPerChunk());
            return buildXlsxDocumentChunks(document, parseArtifactSummary, rowsPerChunk);
        }
        return new ArrayList<DocumentChunk>();
    }

    @SuppressWarnings("unchecked")
    private List<DocumentChunk> buildPptxDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                        Map<String, Object> parseArtifactSummary) {
        List<DocumentChunk> chunks = new ArrayList<DocumentChunk>();
        List<Map<String, Object>> slides = readMapList(parseArtifactSummary.get("slides"));
        if (slides.isEmpty()) {
            String renderMarkdown = readDocumentRenderMarkdown(document);
            if (StringUtil.hasText(renderMarkdown)) {
                chunks.add(buildOfficeChunk(document, 1, stripMarkdownImageSyntax(renderMarkdown), renderMarkdown,
                    new LinkedHashMap<String, Object>() {{
                        put("chunkType", RagChunkTypes.SECTION);
                        put("sourceLabel", "Slide 1");
                        put("headingPath", Collections.singletonList("Slide 1"));
                    }}));
            }
            return chunks;
        }
        int sorting = 1;
        for (Map<String, Object> slide : slides) {
            int slideIndex = asInteger(slide.get("slideIndex"), sorting - 1);
            String sourceLabel = "Slide " + (slideIndex + 1);
            String title = asString(slide.get("title"));
            String ocrMarkdown = asString(slide.get("ocrMarkdown"));
            String imagePath = asString(slide.get("imagePath"));
            String imageName = asString(slide.get("imageName"));
            String renderMarkdown = buildPptxChunkRenderMarkdown(slideIndex, title, imageName, imagePath, ocrMarkdown);
            String llmContent = buildPptxChunkLlmContent(slideIndex, title, ocrMarkdown);
            Map<String, Object> options = new LinkedHashMap<String, Object>();
            options.put("chunkType", RagChunkTypes.SECTION);
            options.put("sourceLabel", sourceLabel);
            options.put("headingPath", buildHeadingPath(sourceLabel, title));
            options.put("charCount", llmContent.length());
            options.put("tokenEstimate", Math.max(1, llmContent.length() / 4));
            options.put("partNo", 1);
            options.put("partTotal", 1);
            options.put("warnings", readStringList(slide.get("warnings")));
            options.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, renderMarkdown);
            options.put(DocumentImportKeys.KEY_DOCUMENT_PAGE_INDEX, slideIndex + 1);
            options.put(DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS, imagePath == null
                ? new ArrayList<String>()
                : new ArrayList<String>(Collections.singletonList(imagePath)));
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY, new LinkedHashMap<String, Object>(slide));
            chunks.add(buildOfficeChunk(document, sorting++, llmContent, renderMarkdown, options));
        }
        return chunks;
    }

    private List<DocumentChunk> buildXlsxDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                        Map<String, Object> parseArtifactSummary,
                                                        int rowsPerChunk) {
        List<DocumentChunk> chunks = new ArrayList<DocumentChunk>();
        List<Map<String, Object>> sheets = readMapList(parseArtifactSummary.get("sheets"));
        List<Map<String, Object>> cellImages = readMapList(parseArtifactSummary.get("cellImages"));
        int sorting = 1;
        for (Map<String, Object> sheet : sheets) {
            String sheetName = asString(sheet.get("sheetName"));
            List<Map<String, Object>> sheetRows = readMapList(sheet.get("rows"));
            List<Map<String, Object>> sheetImages = filterSheetImages(cellImages, sheetName);
            if (sheetRows.isEmpty()) {
                if (sheetImages.isEmpty()) {
                    continue;
                }
                chunks.add(buildXlsxImageOnlyChunk(document, sorting++, sheetName, sheet, sheetImages));
                continue;
            }
            Map<String, Object> headerRow = sheetRows.get(0);
            List<Map<String, Object>> dataRows = sheetRows.size() > 1
                ? new ArrayList<Map<String, Object>>(sheetRows.subList(1, sheetRows.size()))
                : new ArrayList<Map<String, Object>>();
            if (dataRows.isEmpty()) {
                chunks.add(buildXlsxWindowChunk(document, sorting++, sheetName, sheet, headerRow,
                    new ArrayList<Map<String, Object>>(), sheetImages));
                continue;
            }
            for (int start = 0; start < dataRows.size(); start += rowsPerChunk) {
                int end = Math.min(start + rowsPerChunk, dataRows.size());
                List<Map<String, Object>> windowRows = new ArrayList<Map<String, Object>>(dataRows.subList(start, end));
                chunks.add(buildXlsxWindowChunk(document, sorting++, sheetName, sheet, headerRow, windowRows, sheetImages));
            }
        }
        if (chunks.isEmpty()) {
            String renderMarkdown = readDocumentRenderMarkdown(document);
            if (StringUtil.hasText(renderMarkdown)) {
                Map<String, Object> options = new LinkedHashMap<String, Object>();
                options.put("chunkType", RagChunkTypes.SECTION);
                options.put("sourceLabel", document.getTitle());
                options.put("headingPath", Collections.singletonList(document.getTitle()));
                chunks.add(buildOfficeChunk(document, 1, stripMarkdownImageSyntax(renderMarkdown), renderMarkdown, options));
            }
        }
        return chunks;
    }

    private DocumentChunk buildXlsxWindowChunk(tech.easyflow.ai.entity.Document document,
                                               int sorting,
                                               String sheetName,
                                               Map<String, Object> sheetSummary,
                                               Map<String, Object> headerRow,
                                               List<Map<String, Object>> windowRows,
                                               List<Map<String, Object>> sheetImages) {
        int headerRowIndex = asInteger(headerRow.get("rowIndex"), 0);
        int rowStart = windowRows.isEmpty() ? headerRowIndex + 1 : asInteger(windowRows.get(0).get("rowIndex"), headerRowIndex) + 1;
        int rowEnd = windowRows.isEmpty()
            ? rowStart
            : asInteger(windowRows.get(windowRows.size() - 1).get("rowIndex"), rowStart - 1) + 1;
        List<Map<String, Object>> selectedImages = selectWindowImages(sheetImages, rowStart, rowEnd, windowRows.isEmpty());
        String renderMarkdown = buildXlsxChunkRenderMarkdown(sheetName, headerRow, windowRows, selectedImages);
        String llmContent = buildXlsxChunkLlmContent(sheetName, headerRow, windowRows, selectedImages);
        Map<String, Object> options = new LinkedHashMap<String, Object>();
        options.put("chunkType", RagChunkTypes.SECTION);
        options.put("sourceLabel", sheetName + " · " + rowStart + "-" + rowEnd + " 行");
        options.put("headingPath", Collections.singletonList(sheetName));
        options.put("charCount", llmContent.length());
        options.put("tokenEstimate", Math.max(1, llmContent.length() / 4));
        options.put("partNo", 1);
        options.put("partTotal", 1);
        options.put("warnings", new ArrayList<String>());
        options.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, renderMarkdown);
        options.put(DocumentImportKeys.KEY_DOCUMENT_SHEET_NAME, sheetName);
        options.put(DocumentImportKeys.KEY_DOCUMENT_ROW_START, rowStart);
        options.put(DocumentImportKeys.KEY_DOCUMENT_ROW_END, rowEnd);
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS, collectImageRefs(selectedImages));
        options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY, buildXlsxChunkSummary(sheetSummary, rowStart, rowEnd, selectedImages));
        return buildOfficeChunk(document, sorting, llmContent, renderMarkdown, options);
    }

    private DocumentChunk buildXlsxImageOnlyChunk(tech.easyflow.ai.entity.Document document,
                                                  int sorting,
                                                  String sheetName,
                                                  Map<String, Object> sheetSummary,
                                                  List<Map<String, Object>> sheetImages) {
        String renderMarkdown = buildXlsxImageOnlyRenderMarkdown(sheetName, sheetImages);
        String llmContent = buildXlsxImageOnlyLlmContent(sheetName, sheetImages);
        Map<String, Object> options = new LinkedHashMap<String, Object>();
        options.put("chunkType", RagChunkTypes.SECTION);
        options.put("sourceLabel", sheetName);
        options.put("headingPath", Collections.singletonList(sheetName));
        options.put("charCount", llmContent.length());
        options.put("tokenEstimate", Math.max(1, llmContent.length() / 4));
        options.put("partNo", 1);
        options.put("partTotal", 1);
        options.put("warnings", new ArrayList<String>());
        options.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, renderMarkdown);
        options.put(DocumentImportKeys.KEY_DOCUMENT_SHEET_NAME, sheetName);
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS, collectImageRefs(sheetImages));
        options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY, buildXlsxChunkSummary(sheetSummary, null, null, sheetImages));
        return buildOfficeChunk(document, sorting, llmContent, renderMarkdown, options);
    }

    private DocumentChunk buildOfficeChunk(tech.easyflow.ai.entity.Document document,
                                           int sorting,
                                           String llmContent,
                                           String renderMarkdown,
                                           Map<String, Object> options) {
        DocumentChunk chunk = new DocumentChunk();
        chunk.setId(generateId(chunk));
        chunk.setDocumentId(document.getId());
        chunk.setDocumentCollectionId(document.getCollectionId());
        chunk.setSorting(sorting);
        chunk.setContent(llmContent);
        Map<String, Object> normalizedOptions = options == null
            ? new LinkedHashMap<String, Object>()
            : new LinkedHashMap<String, Object>(options);
        normalizedOptions.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, renderMarkdown);
        chunk.setOptions(normalizedOptions);
        return chunk;
    }

    private String buildPptxChunkRenderMarkdown(int slideIndex,
                                                String title,
                                                String imageName,
                                                String imagePath,
                                                String ocrMarkdown) {
        StringBuilder builder = new StringBuilder();
        builder.append("# Slide ").append(slideIndex + 1).append("\n\n");
        if (StringUtil.hasText(title)) {
            builder.append("## ").append(title.trim()).append("\n\n");
        }
        if (StringUtil.hasText(imagePath)) {
            builder.append("![")
                .append(StringUtil.hasText(imageName) ? imageName : "slide-" + (slideIndex + 1))
                .append("](")
                .append(imagePath)
                .append(")\n\n");
        }
        if (StringUtil.hasText(ocrMarkdown)) {
            builder.append(ocrMarkdown.trim());
        }
        return builder.toString().trim();
    }

    private String buildPptxChunkLlmContent(int slideIndex,
                                            String title,
                                            String ocrMarkdown) {
        StringBuilder builder = new StringBuilder();
        builder.append("Slide ").append(slideIndex + 1);
        if (StringUtil.hasText(title)) {
            builder.append("\n标题：").append(title.trim());
        }
        if (StringUtil.hasText(ocrMarkdown)) {
            builder.append("\n\n").append(stripMarkdownImageSyntax(ocrMarkdown));
        }
        return builder.toString().trim();
    }

    private String buildXlsxChunkRenderMarkdown(String sheetName,
                                                Map<String, Object> headerRow,
                                                List<Map<String, Object>> windowRows,
                                                List<Map<String, Object>> selectedImages) {
        StringBuilder builder = new StringBuilder();
        builder.append("# ").append(sheetName).append("\n\n");
        builder.append(buildMarkdownTable(headerRow, windowRows));
        appendXlsxImageAppendix(builder, sheetName, selectedImages);
        return builder.toString().trim();
    }

    private String buildXlsxChunkLlmContent(String sheetName,
                                            Map<String, Object> headerRow,
                                            List<Map<String, Object>> windowRows,
                                            List<Map<String, Object>> selectedImages) {
        StringBuilder builder = new StringBuilder();
        builder.append("# ").append(sheetName).append("\n\n");
        builder.append(buildMarkdownTable(headerRow, windowRows));
        if (!selectedImages.isEmpty()) {
            builder.append("\n\n图片 OCR：\n");
            for (Map<String, Object> image : selectedImages) {
                builder.append("- ")
                    .append(asString(image.get("anchorCell")))
                    .append("：")
                    .append(StringUtil.hasText(asString(image.get("ocrText"))) ? asString(image.get("ocrText")) : "无文本")
                    .append("\n");
            }
        }
        return stripMarkdownImageSyntax(builder.toString().trim());
    }

    private String buildXlsxImageOnlyRenderMarkdown(String sheetName,
                                                    List<Map<String, Object>> sheetImages) {
        StringBuilder builder = new StringBuilder();
        builder.append("# ").append(sheetName).append("\n\n");
        builder.append("## ").append(sheetName).append(" 图片内容\n\n");
        for (Map<String, Object> image : sheetImages) {
            builder.append("[IMG:").append(asString(image.get("referenceKey"))).append("]\n\n");
        }
        appendXlsxImageAppendix(builder, sheetName, sheetImages);
        return builder.toString().trim();
    }

    private String buildXlsxImageOnlyLlmContent(String sheetName,
                                                List<Map<String, Object>> sheetImages) {
        StringBuilder builder = new StringBuilder();
        builder.append("# ").append(sheetName).append("\n\n图片 OCR：\n");
        for (Map<String, Object> image : sheetImages) {
            builder.append("- ")
                .append(asString(image.get("anchorCell")))
                .append("：")
                .append(StringUtil.hasText(asString(image.get("ocrText"))) ? asString(image.get("ocrText")) : "无文本")
                .append("\n");
        }
        return builder.toString().trim();
    }

    private String buildMarkdownTable(Map<String, Object> headerRow,
                                      List<Map<String, Object>> windowRows) {
        List<Map<String, Object>> tableRows = new ArrayList<Map<String, Object>>();
        tableRows.add(headerRow);
        tableRows.addAll(windowRows);
        int maxCol = resolveMaxColumnCount(tableRows);
        if (maxCol <= 0) {
            return "_empty sheet_";
        }
        List<String> headerCells = resolveMarkdownRowValues(headerRow, maxCol, true);
        StringBuilder builder = new StringBuilder();
        builder.append("| ").append(joinMarkdownCells(headerCells)).append(" |\n");
        builder.append("|");
        for (int index = 0; index < maxCol; index++) {
            builder.append(" --- |");
        }
        builder.append("\n");
        for (Map<String, Object> row : windowRows) {
            builder.append("| ").append(joinMarkdownCells(resolveMarkdownRowValues(row, maxCol, false))).append(" |\n");
        }
        return builder.toString().trim();
    }

    private void appendXlsxImageAppendix(StringBuilder builder,
                                         String sheetName,
                                         List<Map<String, Object>> images) {
        if (images == null || images.isEmpty()) {
            return;
        }
        builder.append("\n\n## ").append(sheetName).append(" 图片说明\n\n");
        for (Map<String, Object> image : images) {
            String referenceKey = asString(image.get("referenceKey"));
            String sourcePath = asString(image.get("sourcePath"));
            builder.append("![")
                .append(referenceKey)
                .append("](")
                .append(sourcePath)
                .append(")\n\n");
            builder.append("- 占位符：[IMG:").append(referenceKey).append("]\n");
            builder.append("- 锚点：").append(asString(image.get("anchorCell"))).append("\n");
            builder.append("- OCR：")
                .append(StringUtil.hasText(asString(image.get("ocrText"))) ? asString(image.get("ocrText")) : "")
                .append("\n\n");
        }
    }

    private int resolveMaxColumnCount(List<Map<String, Object>> rows) {
        int maxCol = 0;
        for (Map<String, Object> row : rows) {
            List<Map<String, Object>> cells = readMapList(row == null ? null : row.get("cells"));
            for (Map<String, Object> cell : cells) {
                maxCol = Math.max(maxCol, asInteger(cell.get("columnIndex"), 0) + 1);
            }
        }
        return maxCol;
    }

    private List<String> resolveMarkdownRowValues(Map<String, Object> row,
                                                  int maxCol,
                                                  boolean headerRow) {
        List<String> values = new ArrayList<String>(Collections.nCopies(maxCol, ""));
        if (row == null) {
            return values;
        }
        for (Map<String, Object> cell : readMapList(row.get("cells"))) {
            int colIndex = asInteger(cell.get("columnIndex"), 0);
            if (colIndex < 0 || colIndex >= maxCol) {
                continue;
            }
            String baseText = asString(cell.get("text"));
            String mergedText = mergeCellTextWithImages(baseText, readStringList(cell.get("imageKeys")));
            if (headerRow && !StringUtil.hasText(mergedText)) {
                mergedText = "列" + (colIndex + 1);
            }
            values.set(colIndex, escapeMarkdownCell(mergedText));
        }
        return values;
    }

    private String mergeCellTextWithImages(String baseText, List<String> imageKeys) {
        StringBuilder builder = new StringBuilder();
        if (StringUtil.hasText(baseText)) {
            builder.append(baseText.trim());
        }
        for (String imageKey : imageKeys) {
            if (!StringUtil.hasText(imageKey)) {
                continue;
            }
            if (builder.length() > 0) {
                builder.append('\n');
            }
            builder.append("[IMG:").append(imageKey).append("]");
        }
        return builder.toString();
    }

    private String escapeMarkdownCell(String text) {
        if (!StringUtil.hasText(text)) {
            return "";
        }
        return text.replace("|", "\\|").replace("\r", " ").replace("\n", "<br/>");
    }

    private String joinMarkdownCells(List<String> cells) {
        StringBuilder builder = new StringBuilder();
        for (int index = 0; index < cells.size(); index++) {
            if (index > 0) {
                builder.append(" | ");
            }
            builder.append(cells.get(index));
        }
        return builder.toString();
    }

    private List<Map<String, Object>> filterSheetImages(List<Map<String, Object>> cellImages,
                                                        String sheetName) {
        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
        for (Map<String, Object> image : cellImages) {
            if (Objects.equals(sheetName, asString(image.get("sheetName")))) {
                result.add(new LinkedHashMap<String, Object>(image));
            }
        }
        return result;
    }

    private List<Map<String, Object>> selectWindowImages(List<Map<String, Object>> sheetImages,
                                                         int rowStart,
                                                         int rowEnd,
                                                         boolean noDataRows) {
        List<Map<String, Object>> selected = new ArrayList<Map<String, Object>>();
        for (Map<String, Object> image : sheetImages) {
            int imageRow = asInteger(image.get("fromRow"), 0) + 1;
            if (noDataRows || (imageRow >= rowStart && imageRow <= rowEnd) || (imageRow < rowStart && rowStart <= 2)) {
                selected.add(new LinkedHashMap<String, Object>(image));
            }
        }
        return selected;
    }

    private List<String> collectImageRefs(List<Map<String, Object>> images) {
        List<String> refs = new ArrayList<String>();
        for (Map<String, Object> image : images) {
            String sourcePath = asString(image.get("sourcePath"));
            if (StringUtil.hasText(sourcePath)) {
                refs.add(sourcePath);
            }
        }
        return refs;
    }

    private Map<String, Object> buildXlsxChunkSummary(Map<String, Object> sheetSummary,
                                                      Integer rowStart,
                                                      Integer rowEnd,
                                                      List<Map<String, Object>> images) {
        Map<String, Object> summary = new LinkedHashMap<String, Object>();
        summary.put("sheetName", asString(sheetSummary.get("sheetName")));
        summary.put("sheetIndex", sheetSummary.get("sheetIndex"));
        summary.put(DocumentImportKeys.KEY_DOCUMENT_ROW_START, rowStart);
        summary.put(DocumentImportKeys.KEY_DOCUMENT_ROW_END, rowEnd);
        summary.put("images", new ArrayList<Map<String, Object>>(images));
        return summary;
    }

    private List<Map<String, Object>> readMapList(Object value) {
        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
        if (!(value instanceof List<?> rawList)) {
            return result;
        }
        for (Object item : rawList) {
            if (item instanceof Map<?, ?> mapItem) {
                result.add(new LinkedHashMap<String, Object>((Map<String, Object>) mapItem));
            }
        }
        return result;
    }

    private List<String> readStringList(Object value) {
        List<String> result = new ArrayList<String>();
        if (!(value instanceof List<?> rawList)) {
            return result;
        }
        for (Object item : rawList) {
            if (StringUtil.hasText(asString(item))) {
                result.add(asString(item));
            }
        }
        return result;
    }

    private List<String> buildHeadingPath(String sourceLabel, String title) {
        List<String> headingPath = new ArrayList<String>();
        headingPath.add(sourceLabel);
        if (StringUtil.hasText(title)) {
            headingPath.add(title.trim());
        }
        return headingPath;
    }

    private String readDocumentRenderMarkdown(tech.easyflow.ai.entity.Document document) {
        return document == null || document.getOptions() == null
            ? null
            : asString(document.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN));
    }

    private String stripMarkdownImageSyntax(String markdown) {
        if (!StringUtil.hasText(markdown)) {
            return markdown;
        }
        return markdown.replaceAll("!\\[[^\\]]*\\]\\([^)]*\\)", "").trim();
    }

    private String resolveStrategyLabel(String sourceFormat, StrategyConfig strategyConfig) {
        String strategyCode = strategyConfig == null ? null : strategyConfig.getStrategyCode();
        if ("pptx".equals(sourceFormat) || OFFICE_PPTX_PAGE_STRATEGY.equals(strategyCode)) {
            return "按页分块";
        }
        if ("xlsx".equals(sourceFormat) || OFFICE_XLSX_ROW_WINDOW_STRATEGY.equals(strategyCode)) {
            return "按 Sheet / 行窗口";
        }
        return ragIngestionService.toStrategyLabel(strategyCode);
    }

    /**
     * 组装处理页预览响应。
     *
     * @param document 文档实体
     * @param session 预览会话
     * @param sessionId 预览会话 ID
     * @return 预览文件结果
     */
    private DocumentImportDtos.PreviewFileResult buildPreviewFileResult(tech.easyflow.ai.entity.Document document,
                                                                        DocumentImportDtos.PreviewSession session,
                                                                        String sessionId) {
        DocumentImportDtos.PreviewFileResult item = new DocumentImportDtos.PreviewFileResult();
        item.setPreviewSessionId(sessionId);
        item.setFilePath(document.getDocumentPath());
        item.setFileName(document.getTitle());
        item.setNormalizedContent(session.getAnalysis() == null ? null : session.getAnalysis().getNormalizedContent());
        item.setStrategyCode(session.getStrategyConfig().getStrategyCode());
        item.setStrategyLabel(resolveStrategyLabel(session.getSourceFormat(), session.getStrategyConfig()));
        item.setAnalysis(session.getAnalysis());
        item.setTotalChunks(session.getDocumentChunks().size());
        item.setTotalWarnings(countChunkWarnings(session.getDocumentChunks()));
        item.setChunks(toPreviewChunkResults(session.getDocumentChunks()));
        return item;
    }

    private void mergeDocumentPreviewOptions(tech.easyflow.ai.entity.Document document,
                                             DocumentImportDtos.PreviewSession session) {
        Map<String, Object> options = copyOptions(document.getOptions());
        options.put(DocumentImportKeys.KEY_DOCUMENT_STRATEGY_CODE, session.getStrategyConfig().getStrategyCode());
        options.put(DocumentImportKeys.KEY_DOCUMENT_STRATEGY_LABEL, resolveStrategyLabel(session.getSourceFormat(), session.getStrategyConfig()));
        options.put(DocumentImportKeys.KEY_DOCUMENT_STRATEGY_SNAPSHOT, strategyConfigToMap(session.getStrategyConfig()));
        options.put(DocumentImportKeys.KEY_DOCUMENT_ANALYSIS_SUMMARY,
            session.getAnalysis() == null ? new LinkedHashMap<String, Object>() : session.getAnalysis().getFeatures());
        options.put(DocumentImportKeys.KEY_DOCUMENT_SOURCE_FILE_EXT, session.getSourceFormat());
        options.put(DocumentImportKeys.KEY_DOCUMENT_PREVIEW_VERSION, "v2");

        document.setOptions(options);
    }

    private DocumentImportDtos.PreviewSession resolveIndexPreviewSession(DocumentCollection knowledge,
                                                                        tech.easyflow.ai.entity.Document document,
                                                                        String previewSessionId) {
        if (StringUtil.hasText(previewSessionId)) {
            DocumentImportDtos.PreviewSession session = documentImportPreviewService.getRequired(previewSessionId);
            if (!knowledge.getId().equals(session.getKnowledgeId()) || !document.getId().equals(session.getDocumentId())) {
                throw new BusinessException("预览会话与当前文档不匹配");
            }
            return session;
        }
        StrategyConfig storedStrategy = readStoredStrategy(document);
        DocumentImportDtos.PreviewSession rebuilt = buildPreviewSessionForDocument(knowledge, document, storedStrategy);
        String sessionId = documentImportPreviewService.put(rebuilt);
        rebuilt.setSessionId(sessionId);
        return rebuilt;
    }

    @SuppressWarnings("unchecked")
    private StrategyConfig readStoredStrategy(tech.easyflow.ai.entity.Document document) {
        Object snapshot = document.getOptions() == null ? null : document.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_STRATEGY_SNAPSHOT);
        if (!(snapshot instanceof Map)) {
            throw new BusinessException("当前文档缺少分块策略，请重新生成预览");
        }
        Map<String, Object> rawSnapshot = (Map<String, Object>) snapshot;
        StrategyConfig config = StrategyConfig.defaults();
        config.setStrategyCode(asString(rawSnapshot.get("strategyCode")));
        config.setChunkSize(asInteger(rawSnapshot.get("chunkSize"), RagDefaults.CHUNK_SIZE));
        config.setOverlapSize(asInteger(rawSnapshot.get("overlapSize"), RagDefaults.OVERLAP_SIZE));
        config.setRegex(asString(rawSnapshot.get("regex")));
        config.setRowsPerChunk(asInteger(rawSnapshot.get("rowsPerChunk"), config.getRowsPerChunk()));
        config.setMdSplitterLevel(asInteger(rawSnapshot.get("mdSplitterLevel"), RagDefaults.MD_SPLITTER_LEVEL));
        return config;
    }

    private List<DocumentChunk> buildDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                    List<RagChunk> previewChunks) {
        FlexIDKeyGenerator flexIDKeyGenerator = new FlexIDKeyGenerator();
        List<DocumentChunk> chunks = new ArrayList<DocumentChunk>();
        for (int i = 0; i < previewChunks.size(); i++) {
            RagChunk previewChunk = previewChunks.get(i);
            DocumentChunk chunk = new DocumentChunk();
            chunk.setId(new BigInteger(String.valueOf(flexIDKeyGenerator.generate(chunk, null))));
            chunk.setDocumentId(document.getId());
            chunk.setDocumentCollectionId(document.getCollectionId());
            chunk.setContent(previewChunk.getContent());
            chunk.setSorting(i + 1);

            Map<String, Object> options = new LinkedHashMap<String, Object>();
            if (previewChunk.getOptions() != null) {
                options.putAll(previewChunk.getOptions());
            }
            options.put("chunkType", previewChunk.getChunkType());
            options.put("sourceLabel", previewChunk.getSourceLabel());
            options.put("headingPath", previewChunk.getHeadingPath());
            options.put("charCount", previewChunk.getCharCount());
            options.put("tokenEstimate", previewChunk.getTokenEstimate());
            options.put("qaQuestion", previewChunk.getQuestion());
            options.put("qaAnswer", previewChunk.getAnswer());
            options.put("partNo", previewChunk.getPartNo());
            options.put("partTotal", previewChunk.getPartTotal());
            options.put("warnings", previewChunk.getWarnings());
            options.put("sourceRanges", copySourceRanges(previewChunk));
            chunk.setOptions(options);
            chunks.add(chunk);
        }
        return chunks;
    }

    private DocumentImportTask createTask(tech.easyflow.ai.entity.Document document,
                                          DocumentImportTaskPhase phase,
                                          Map<String, Object> payload) {
        Date now = new Date();
        DocumentImportTask task = new DocumentImportTask();
        task.setDocumentId(document.getId());
        task.setKnowledgeId(document.getCollectionId());
        task.setPhase(phase.name());
        task.setStatus(DocumentImportTaskStatus.PENDING.name());
        task.setPayloadJson(payload);
        task.setCreated(now);
        task.setModified(now);
        task.setCreatedBy(resolveOperatorId());
        task.setModifiedBy(resolveOperatorId());
        documentImportTaskService.save(task);
        return task;
    }

    private Map<String, Object> buildFilePayload(DocumentImportDtos.TaskCreateRequest request) {
        Map<String, Object> payload = new LinkedHashMap<String, Object>();
        payload.put("filePath", request.getFilePath());
        payload.put("fileName", request.getFileName());
        return payload;
    }

    private Map<String, Object> buildDocumentPayload(tech.easyflow.ai.entity.Document document) {
        Map<String, Object> payload = new LinkedHashMap<String, Object>();
        payload.put("filePath", document.getDocumentPath());
        payload.put("fileName", document.getTitle());
        return payload;
    }

    private Map<String, Object> buildInitialOptions(String fileExt) {
        Map<String, Object> options = new LinkedHashMap<String, Object>();
        options.put(DocumentImportKeys.KEY_DOCUMENT_SOURCE_FILE_EXT, fileExt);
        return options;
    }

    private StoreExecutionContext prepareStoreContext(tech.easyflow.ai.entity.Document document) {
        DocumentCollection knowledge = knowledgeService.getById(document.getCollectionId());
        if (knowledge == null) {
            throw new BusinessException("知识库不存在");
        }
        DocumentStore documentStore = knowledge.toDocumentStore();
        if (documentStore == null) {
            throw new BusinessException("向量数据库配置错误");
        }
        try {
            Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
            if (model == null) {
                throw new BusinessException("该知识库未配置向量模型");
            }
            EmbeddingModel embeddingModel = model.toEmbeddingModel();
            documentStore.setEmbeddingModel(embeddingModel);

            StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
            EmbeddingOptions embeddingOptions = new EmbeddingOptions();
            embeddingOptions.setModel(model.getModelName());
            embeddingOptions.setDimensions(knowledge.getDimensionOfVectorModel());
            options.setEmbeddingOptions(embeddingOptions);
            options.setIndexName(options.getCollectionName());
            return new StoreExecutionContext(
                knowledge,
                embeddingModel,
                documentStore,
                options,
                searcherFactory.getSearcher()
            );
        } catch (RuntimeException e) {
            DocumentStoreLifecycleSupport.closeQuietly(documentStore);
            throw e;
        }
    }

    private void storeDocumentChunks(StoreExecutionContext storeContext, List<DocumentChunk> documentChunks) {
        List<Document> documents = new ArrayList<Document>();
        for (DocumentChunk chunk : documentChunks) {
            Document storeDocument = new Document();
            storeDocument.setId(chunk.getId());
            storeDocument.setContent(chunk.getContent());
            storeDocument.addMetadata(KeywordSearchMetadataKeys.KNOWLEDGE_ID,
                storeContext.knowledge.getId() == null ? null : storeContext.knowledge.getId().toString());
            documents.add(storeDocument);
        }
        StoreResult result = storeContext.documentStore.store(documents, storeContext.options);
        if (result == null || !result.isSuccess()) {
            throw new BusinessException("向量化写入失败");
        }
        if (storeContext.searcher != null) {
            for (Document storeDocument : documents) {
                storeContext.searcher.addDocument(storeDocument);
            }
        }
    }

    private void persistChunkBatch(tech.easyflow.ai.entity.Document document, List<DocumentChunk> batch) {
        for (DocumentChunk chunk : batch) {
            chunk.setDocumentId(document.getId());
            chunk.setDocumentCollectionId(document.getCollectionId());
            documentChunkService.save(chunk);
        }
    }

    private void rollbackStoredChunks(BigInteger taskId,
                                     BigInteger documentId,
                                     StoreExecutionContext storeContext,
                                     List<DocumentChunk> documentChunks) {
        try {
            List<BigInteger> ids = new ArrayList<BigInteger>();
            for (DocumentChunk chunk : documentChunks) {
                ids.add(chunk.getId());
            }
            LOG.warn("开始回滚文档向量化外部索引: taskId={}, documentId={}, knowledgeId={}, chunkCount={}",
                taskId,
                documentId,
                storeContext == null || storeContext.knowledge == null ? null : storeContext.knowledge.getId(),
                ids.size());
            storeContext.documentStore.delete(ids, storeContext.options);
            if (storeContext.searcher != null) {
                for (BigInteger id : ids) {
                    storeContext.searcher.deleteDocument(id);
                }
            }
            LOG.warn("文档向量化外部索引回滚完成: taskId={}, documentId={}, knowledgeId={}, chunkCount={}",
                taskId,
                documentId,
                storeContext == null || storeContext.knowledge == null ? null : storeContext.knowledge.getId(),
                ids.size());
        } catch (Exception e) {
            LOG.error("回滚文档向量数据失败", e);
        }
    }

    private void updateKnowledgeAfterStore(StoreExecutionContext storeContext) {
        DocumentCollection update = new DocumentCollection();
        update.setId(storeContext.knowledge.getId());
        Map<String, Object> options = storeContext.knowledge.getOptions() == null
            ? new HashMap<String, Object>()
            : new HashMap<String, Object>(storeContext.knowledge.getOptions());
        options.put(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, false);
        update.setOptions(options);
        knowledgeService.updateById(update);
        if (storeContext.knowledge.getDimensionOfVectorModel() == null) {
            DocumentCollection dimensionUpdate = new DocumentCollection();
            dimensionUpdate.setId(storeContext.knowledge.getId());
            dimensionUpdate.setDimensionOfVectorModel(Model.getEmbeddingDimension(storeContext.embeddingModel));
            knowledgeService.updateById(dimensionUpdate);
        }
    }

    private void closeStoreContext(StoreExecutionContext storeContext) {
        if (storeContext == null) {
            return;
        }
        DocumentStoreLifecycleSupport.closeQuietly(storeContext.documentStore);
    }

    private void clearPersistedChunks(BigInteger documentId) {
        if (documentId == null) {
            return;
        }
        documentChunkMapper.deleteByQuery(QueryWrapper.create().eq(DocumentChunk::getDocumentId, documentId));
    }

    private void ensurePreviewable(tech.easyflow.ai.entity.Document document) {
        String status = document.getProcessStatus();
        if (DocumentProcessStatus.PARSING.name().equals(status)) {
            throw new BusinessException("文档仍在解析中");
        }
        if (DocumentProcessStatus.INDEXING.name().equals(status)) {
            throw new BusinessException("文档正在向量化中");
        }
        if (DocumentProcessStatus.PARSE_FAILED.name().equals(status)) {
            throw new BusinessException("文档解析失败，请先重试解析");
        }
    }

    private boolean allowIndexStart(tech.easyflow.ai.entity.Document document) {
        String status = document.getProcessStatus();
        return DocumentProcessStatus.READY_FOR_SEGMENT.name().equals(status)
            || DocumentProcessStatus.READY_FOR_INDEX.name().equals(status)
            || DocumentProcessStatus.INDEX_FAILED.name().equals(status);
    }

    private DocumentImportDtos.TaskStartIndexResponse buildTaskStartResponse(DocumentImportTask task,
                                                                            DocumentProcessStatus processStatus) {
        DocumentImportDtos.TaskStartIndexResponse response = new DocumentImportDtos.TaskStartIndexResponse();
        response.setTaskId(task.getId());
        response.setProcessStatus(processStatus.name());
        return response;
    }

    private tech.easyflow.ai.entity.Document requireDocument(BigInteger documentId) {
        tech.easyflow.ai.entity.Document document = documentMapper.selectOneById(documentId);
        if (document == null) {
            throw new BusinessException("文档不存在");
        }
        return document;
    }

    private tech.easyflow.ai.entity.Document requireDocumentForKnowledge(BigInteger documentId, BigInteger knowledgeId) {
        tech.easyflow.ai.entity.Document document = requireDocument(documentId);
        if (document.getCollectionId() == null || document.getCollectionId().compareTo(knowledgeId) != 0) {
            throw new BusinessException("文档不存在");
        }
        return document;
    }

    private DocumentImportTask requireTask(BigInteger taskId) {
        DocumentImportTask task = documentImportTaskService.getById(taskId);
        if (task == null) {
            throw new BusinessException("任务不存在");
        }
        return task;
    }

    private DocumentCollection assertDocumentCollection(BigInteger knowledgeId) {
        DocumentCollection knowledge = knowledgeService.getById(knowledgeId);
        if (knowledge == null) {
            throw new BusinessException("知识库不存在");
        }
        if (knowledge.isFaqCollection()) {
            throw new BusinessException("FAQ知识库不支持文档上传");
        }
        return knowledge;
    }

    private String normalizeSourceFormat(tech.easyflow.ai.entity.Document document) {
        String type = document.getDocumentType();
        if (StringUtil.hasText(type)) {
            return type.toLowerCase(Locale.ROOT);
        }
        return normalizeFileExtension(document.getTitle(), document.getDocumentPath());
    }

    private String normalizeFileExtension(String fileName, String filePath) {
        String target = StringUtil.hasText(fileName) ? fileName : filePath;
        String ext = FileUtil.getFileTypeByExtension(target);
        return ext == null ? "" : ext.toLowerCase(Locale.ROOT);
    }

    private void assertSupportedImportFile(String fileExt) {
        if (!Arrays.asList("pdf", "docx", "txt", "md", "pptx", "xlsx").contains(fileExt)) {
            throw new BusinessException("当前仅支持 pdf/docx/txt/md/pptx/xlsx 文档导入");
        }
    }

    /**
     * 判断当前文件是否应走统一文档解析桥接。
     *
     * @param fileExt 文件后缀
     * @return 是否走桥接解析
     */
    private boolean shouldUseDocumentParseBridge(String fileExt) {
        return "pdf".equals(fileExt) || "docx".equals(fileExt) || "pptx".equals(fileExt) || "xlsx".equals(fileExt);
    }

    /**
     * 提交桥接解析任务。
     *
     * @param task 任务实体
     * @param document 文档实体
     * @param fileExt 文件后缀
     */
    private void submitBridgeParseTask(DocumentImportTask task,
                                       tech.easyflow.ai.entity.Document document,
                                       String fileExt) {
        DocumentSourceRef sourceRef = new DocumentSourceRef();
        sourceRef.setFileName(document.getTitle());
        sourceRef.setFilePath(document.getDocumentPath());
        sourceRef.setContentType(resolveBridgeContentType(fileExt));
        try {
            LOG.info("文档解析桥接任务开始提交: taskId={}, documentId={}, fileName={}, fileExt={}",
                task.getId(), document.getId(), document.getTitle(), fileExt);
            String providerTaskId = documentParseBridgeService.submit(sourceRef, DocumentParseScenario.KNOWLEDGE_IMPORT).getTaskId();
            if (!StringUtil.hasText(providerTaskId)) {
                throw new BusinessException("文档解析服务未返回任务ID");
            }
            updateTaskProvider(task, providerTaskId);
            LOG.info("文档解析桥接任务提交完成: taskId={}, documentId={}, providerTaskId={}",
                task.getId(), document.getId(), providerTaskId);
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            throw new BusinessException("文档解析失败：" + e.getMessage());
        }
    }

    /**
     * 单次查询桥接解析任务状态并收敛结果。
     *
     * @param task 任务实体
     * @param document 文档实体
     * @param fileExt 文件后缀
     */
    private void syncBridgeParseTask(DocumentImportTask task,
                                     tech.easyflow.ai.entity.Document document,
                                     String fileExt) {
        try {
            DocumentParseTaskInfo taskInfo = documentParseBridgeService.queryTaskInfo(task.getProviderTaskId());
            String providerStatus = taskInfo == null ? null : taskInfo.getStatus();
            LOG.info("文档解析桥接任务单次收敛: taskId={}, documentId={}, providerTaskId={}, providerStatus={}, hasResult={}, error={}",
                task.getId(),
                document.getId(),
                task.getProviderTaskId(),
                providerStatus,
                taskInfo != null && taskInfo.getResult() != null,
                taskInfo == null ? null : taskInfo.getError());
            if (isTaskSuccess(providerStatus)) {
                DocumentParsedResult result = taskInfo.getResult() == null
                    ? documentParseBridgeService.queryResult(task.getProviderTaskId())
                    : taskInfo.getResult();
                markParseSuccess(task, document, result, fileExt, task.getProviderTaskId());
                return;
            }
            if (isTaskFailed(providerStatus)) {
                throw new BusinessException(taskInfo == null ? "文档解析失败" : taskInfo.getError());
            }
            updateDocumentParseProgress(document, taskInfo);
            touchRunningTask(task);
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            throw new BusinessException("文档解析失败：" + e.getMessage());
        }
    }

    /**
     * 在事务提交后投递解析任务消息。
     *
     * @param taskId 任务 ID
     */
    private void dispatchParseTaskAfterCommit(BigInteger taskId) {
        runAfterCommit(() -> parseTaskProducer.send(taskId));
    }

    /**
     * 在事务提交后投递向量化任务消息。
     *
     * @param taskId 任务 ID
     */
    private void dispatchIndexTaskAfterCommit(BigInteger taskId) {
        runAfterCommit(() -> indexTaskProducer.send(taskId));
    }

    /**
     * 解析桥接所需的内容类型。
     *
     * @param fileExt 文件后缀
     * @return MIME 类型
     */
    private String resolveBridgeContentType(String fileExt) {
        if ("pdf".equals(fileExt)) {
            return "application/pdf";
        }
        if ("docx".equals(fileExt)) {
            return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
        }
        if ("pptx".equals(fileExt)) {
            return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
        }
        if ("xlsx".equals(fileExt)) {
            return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
        }
        return null;
    }

    /**
     * 在事务提交后调度本地向量化兜底执行。
     *
     * @param taskId 任务 ID
     */
    private void scheduleIndexTaskFallback(BigInteger taskId) {
        scheduleTaskFallback(taskId, "index", () -> selfProxy.handleIndexTask(taskId));
    }

    /**
     * 在事务提交后调度本地解析兜底执行。
     *
     * @param taskId 任务 ID
     */
    private void scheduleParseTaskFallback(BigInteger taskId) {
        scheduleTaskFallback(taskId, "parse", () -> selfProxy.handleParseTask(taskId));
    }

    /**
     * 在事务提交后异步执行本地兜底任务。
     *
     * @param taskId 任务 ID
     * @param taskType 任务类型
     * @param action 执行动作
     */
    private void scheduleTaskFallback(BigInteger taskId, String taskType, Runnable action) {
        Runnable dispatch = () -> documentImportTaskExecutor.execute(() -> {
            try {
                LOG.info("开始执行文档导入本地兜底任务: type={}, taskId={}", taskType, taskId);
                action.run();
                LOG.info("文档导入本地兜底任务执行完成: type={}, taskId={}", taskType, taskId);
            } catch (Exception e) {
                LOG.error("执行文档导入本地兜底任务失败: type={}, taskId={}", taskType, taskId, e);
            }
        });
        runAfterCommit(dispatch);
    }

    /**
     * 在事务提交后执行指定动作；若当前无事务则立即执行。
     *
     * @param action 执行动作
     */
    private void runAfterCommit(Runnable action) {
        if (TransactionSynchronizationManager.isSynchronizationActive()
            && TransactionSynchronizationManager.isActualTransactionActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    action.run();
                }
            });
            return;
        }
        action.run();
    }

    private String readFileContent(String filePath, String fileName) {
        try (InputStream inputStream = storageService.readStream(filePath)) {
            return com.easyagents.core.file2text.File2TextUtil.readFromStream(inputStream, fileName, null);
        } catch (IOException e) {
            throw new BusinessException("文件解析失败：" + e.getMessage());
        }
    }

    private String resolvePreferredText(DocumentParsedResult parsedResult) {
        if (parsedResult == null) {
            return null;
        }
        if (StringUtil.hasText(parsedResult.getPreferredText())) {
            return parsedResult.getPreferredText();
        }
        if (StringUtil.hasText(parsedResult.getMarkdown())) {
            return parsedResult.getMarkdown();
        }
        return parsedResult.getPlainText();
    }

    /**
     * 触摸运行中任务的修改时间，避免监控视角下任务长时间无变化。
     *
     * @param task 任务实体
     */
    private void touchRunningTask(DocumentImportTask task) {
        task.setModified(new Date());
        task.setModifiedBy(resolveOperatorId());
        documentImportTaskService.updateById(task, false);
    }

    private StrategyConfig resolveStrategyConfig(DocumentCollection knowledge,
                                                StrategyConfig requestConfig,
                                                AnalysisResult analysisResult) {
        Map<String, Object> options = knowledge.getOptions() == null
            ? Collections.emptyMap()
            : knowledge.getOptions();
        String recommended = analysisResult.getRecommendedStrategyCode();
        String defaultStrategyCode = asString(options.get(DocumentImportKeys.KEY_SPLITTER_DEFAULT_STRATEGY));
        String fallbackStrategyCode = asString(options.get(DocumentImportKeys.KEY_SPLITTER_FALLBACK_STRATEGY));
        Boolean autoRecommendEnabled = asBoolean(options.get(DocumentImportKeys.KEY_SPLITTER_AUTO_RECOMMEND_ENABLED), true);

        StrategyConfig config = readProfileConfig(options, defaultStrategyCode);
        if (config == null) {
            config = StrategyConfig.defaults();
        }
        String requestedStrategyCode = requestConfig == null ? null : requestConfig.getStrategyCode();
        String strategyCode = StringUtil.hasText(requestedStrategyCode) ? requestedStrategyCode : config.getStrategyCode();
        if (!StringUtil.hasText(strategyCode) || RagStrategyCodes.AUTO.equals(strategyCode)) {
            strategyCode = Boolean.TRUE.equals(autoRecommendEnabled)
                ? recommended
                : (StringUtil.hasText(defaultStrategyCode) ? defaultStrategyCode : recommended);
        }
        if (!StringUtil.hasText(strategyCode)) {
            strategyCode = StringUtil.hasText(fallbackStrategyCode)
                ? fallbackStrategyCode
                : RagStrategyCodes.PARAGRAPH_LENGTH;
        }
        StrategyConfig profileConfig = readProfileConfig(options, strategyCode);
        if (profileConfig != null) {
            mergeStrategyConfig(config, profileConfig);
        }
        if (requestConfig != null) {
            mergeStrategyConfig(config, requestConfig);
        }
        config.setStrategyCode(strategyCode);
        if (config.getChunkSize() == null || config.getChunkSize() <= 0) {
            config.setChunkSize(RagDefaults.CHUNK_SIZE);
        }
        if (config.getOverlapSize() == null || config.getOverlapSize() < 0) {
            config.setOverlapSize(RagDefaults.OVERLAP_SIZE);
        }
        if (config.getMdSplitterLevel() == null || config.getMdSplitterLevel() <= 0) {
            config.setMdSplitterLevel(RagDefaults.MD_SPLITTER_LEVEL);
        }
        return config;
    }

    @SuppressWarnings("unchecked")
    private StrategyConfig readProfileConfig(Map<String, Object> options, String strategyCode) {
        if (!StringUtil.hasText(strategyCode)) {
            return null;
        }
        Object profileObject = options.get(DocumentImportKeys.KEY_SPLITTER_STRATEGY_PROFILES);
        if (!(profileObject instanceof Map)) {
            return null;
        }
        Map<String, Object> profileMap = (Map<String, Object>) profileObject;
        Object strategyObject = profileMap.get(strategyCode);
        if (!(strategyObject instanceof Map)) {
            return null;
        }
        Map<String, Object> rawProfile = (Map<String, Object>) strategyObject;
        StrategyConfig config = StrategyConfig.defaults();
        config.setStrategyCode(strategyCode);
        config.setChunkSize(asInteger(rawProfile.get("chunkSize"), config.getChunkSize()));
        config.setOverlapSize(asInteger(rawProfile.get("overlapSize"), config.getOverlapSize()));
        config.setRegex(asString(rawProfile.get("regex")));
        config.setRowsPerChunk(asInteger(rawProfile.get("rowsPerChunk"), config.getRowsPerChunk()));
        config.setMdSplitterLevel(asInteger(rawProfile.get("mdSplitterLevel"), config.getMdSplitterLevel()));
        return config;
    }

    private void mergeStrategyConfig(StrategyConfig target, StrategyConfig source) {
        if (source == null) {
            return;
        }
        if (StringUtil.hasText(source.getStrategyCode())) {
            target.setStrategyCode(source.getStrategyCode());
        }
        if (source.getChunkSize() != null) {
            target.setChunkSize(source.getChunkSize());
        }
        if (source.getOverlapSize() != null) {
            target.setOverlapSize(source.getOverlapSize());
        }
        if (StringUtil.hasText(source.getRegex())) {
            target.setRegex(source.getRegex());
        }
        if (source.getRowsPerChunk() != null) {
            target.setRowsPerChunk(source.getRowsPerChunk());
        }
        if (source.getMdSplitterLevel() != null) {
            target.setMdSplitterLevel(source.getMdSplitterLevel());
        }
    }

    private Map<String, Object> strategyConfigToMap(StrategyConfig strategyConfig) {
        Map<String, Object> map = new LinkedHashMap<String, Object>();
        map.put("strategyCode", strategyConfig.getStrategyCode());
        map.put("chunkSize", strategyConfig.getChunkSize());
        map.put("overlapSize", strategyConfig.getOverlapSize());
        map.put("regex", strategyConfig.getRegex());
        map.put("rowsPerChunk", strategyConfig.getRowsPerChunk());
        map.put("mdSplitterLevel", strategyConfig.getMdSplitterLevel());
        return map;
    }

    private int countChunkWarnings(List<DocumentChunk> chunks) {
        int total = 0;
        if (chunks == null) {
            return 0;
        }
        for (DocumentChunk chunk : chunks) {
            List<String> warnings = readChunkWarnings(chunk);
            total += warnings.size();
        }
        return total;
    }

    /**
     * 将分块预览转换为前端工作台使用的显式模型。
     *
     * @param chunks 分块列表
     * @return 预览分块结果
     */
    private List<DocumentImportDtos.PreviewChunkResult> toPreviewChunkResults(List<DocumentChunk> chunks) {
        List<DocumentImportDtos.PreviewChunkResult> result = new ArrayList<DocumentImportDtos.PreviewChunkResult>();
        if (chunks == null) {
            return result;
        }
        for (DocumentChunk chunk : chunks) {
            DocumentImportDtos.PreviewChunkResult item = new DocumentImportDtos.PreviewChunkResult();
            Map<String, Object> options = chunk.getOptions() == null
                ? Collections.<String, Object>emptyMap()
                : chunk.getOptions();
            item.setAnswer(asString(options.get("qaAnswer")));
            item.setCharCount(asInteger(options.get("charCount"), 0));
            item.setChunkId(chunk.getId() == null ? null : String.valueOf(chunk.getId()));
            item.setChunkType(asString(options.get("chunkType")));
            item.setContent(chunk.getContent());
            item.setRenderMarkdown(asString(options.get(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN)));
            item.setHeadingPath(readStringList(options.get("headingPath")));
            item.setPartNo(asInteger(options.get("partNo"), 1));
            item.setPartTotal(asInteger(options.get("partTotal"), 1));
            item.setQuestion(asString(options.get("qaQuestion")));
            item.setSourceLabel(asString(options.get("sourceLabel")));
            item.setTokenEstimate(asInteger(options.get("tokenEstimate"), 0));
            item.setWarnings(readChunkWarnings(chunk));
            item.setSourceRanges(copySourceRanges(chunk));
            result.add(item);
        }
        return result;
    }

    /**
     * 校验切分结果是否具备稳定原文映射能力。
     *
     * @param chunks 分块列表
     * @param normalizedContent 标准化原文
     */
    private void validatePreviewChunkRanges(List<RagChunk> chunks, String normalizedContent) {
        int maxLength = normalizedContent == null ? 0 : normalizedContent.length();
        if (maxLength <= 0) {
            throw new BusinessException("标准化原文为空，无法生成分块预览");
        }
        if (chunks == null || chunks.isEmpty()) {
            throw new BusinessException("未生成有效分块，请调整策略后重试");
        }
        for (RagChunk chunk : chunks) {
            List<DocumentImportDtos.PreviewSourceRange> ranges = copySourceRanges(chunk);
            if (ranges.isEmpty()) {
                throw new BusinessException("当前分块策略缺少稳定原文映射，请调整策略后重试");
            }
            for (DocumentImportDtos.PreviewSourceRange range : ranges) {
                int start = defaultInt(range.getStart());
                int end = defaultInt(range.getEnd());
                if (start < 0 || end <= start || end > maxLength) {
                    throw new BusinessException("当前分块策略生成了非法原文区间，请调整策略后重试");
                }
            }
        }
    }

    /**
     * 从分块元信息中复制原文区间。
     *
     * @param chunk 分块实体
     * @return 原文区间列表
     */
    @SuppressWarnings("unchecked")
    private List<DocumentImportDtos.PreviewSourceRange> copySourceRanges(RagChunk chunk) {
        List<DocumentImportDtos.PreviewSourceRange> result = new ArrayList<DocumentImportDtos.PreviewSourceRange>();
        if (chunk == null || chunk.getOptions() == null) {
            return result;
        }
        Object rawRanges = chunk.getOptions().get(SOURCE_RANGES_KEY);
        if (!(rawRanges instanceof List<?> rangeList)) {
            return result;
        }
        for (Object item : rangeList) {
            if (!(item instanceof Map<?, ?> rawRange)) {
                continue;
            }
            DocumentImportDtos.PreviewSourceRange range = new DocumentImportDtos.PreviewSourceRange();
            range.setStart(asInteger(rawRange.get("start"), null));
            range.setEnd(asInteger(rawRange.get("end"), null));
            if (range.getStart() != null && range.getEnd() != null) {
                result.add(range);
            }
        }
        return result;
    }

    @SuppressWarnings("unchecked")
    private List<DocumentImportDtos.PreviewSourceRange> copySourceRanges(DocumentChunk chunk) {
        List<DocumentImportDtos.PreviewSourceRange> result = new ArrayList<DocumentImportDtos.PreviewSourceRange>();
        if (chunk == null || chunk.getOptions() == null) {
            return result;
        }
        Object rawRanges = chunk.getOptions().get(SOURCE_RANGES_KEY);
        if (!(rawRanges instanceof List<?> rangeList)) {
            return result;
        }
        for (Object item : rangeList) {
            if (!(item instanceof Map<?, ?> rawRange)) {
                continue;
            }
            DocumentImportDtos.PreviewSourceRange range = new DocumentImportDtos.PreviewSourceRange();
            range.setStart(asInteger(rawRange.get("start"), null));
            range.setEnd(asInteger(rawRange.get("end"), null));
            if (range.getStart() != null && range.getEnd() != null) {
                result.add(range);
            }
        }
        return result;
    }

    private List<String> readChunkWarnings(DocumentChunk chunk) {
        if (chunk == null || chunk.getOptions() == null) {
            return new ArrayList<String>();
        }
        return readStringList(chunk.getOptions().get("warnings"));
    }

    private void clearDocumentParseProgress(Map<String, Object> options) {
        if (options == null || options.isEmpty()) {
            return;
        }
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_PROGRESS_PERCENT);
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_CURRENT_STAGE);
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_PROCESSED_ITEMS);
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_TOTAL_ITEMS);
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_STATUS_MESSAGE);
    }

    private BigInteger generateId(Object entity) {
        FlexIDKeyGenerator generator = new FlexIDKeyGenerator();
        return new BigInteger(String.valueOf(generator.generate(entity, null)));
    }

    private BigInteger resolveOperatorId() {
        try {
            return BigInteger.valueOf(StpUtil.getLoginIdAsLong());
        } catch (Exception ignore) {
            return BigInteger.ZERO;
        }
    }

    private String truncateError(String message) {
        if (!StringUtil.hasText(message)) {
            return "任务执行失败";
        }
        String trimmed = message.trim();
        return trimmed.length() > 1000 ? trimmed.substring(0, 1000) : trimmed;
    }

    private boolean isTaskSuccess(String status) {
        String normalized = status == null ? "" : status.toUpperCase(Locale.ROOT);
        return normalized.contains("SUCCESS") || normalized.contains("COMPLETED") || normalized.contains("DONE");
    }

    private boolean isTaskFailed(String status) {
        String normalized = status == null ? "" : status.toUpperCase(Locale.ROOT);
        return normalized.contains("FAIL") || normalized.contains("ERROR") || normalized.contains("CANCEL");
    }

    private Map<String, Object> copyOptions(Map<String, Object> options) {
        return options == null ? new LinkedHashMap<String, Object>() : new LinkedHashMap<String, Object>(options);
    }

    private String asString(Object value) {
        return value == null ? null : String.valueOf(value);
    }

    private Integer asInteger(Object value, Integer defaultValue) {
        if (value == null) {
            return defaultValue;
        }
        if (value instanceof Number number) {
            return number.intValue();
        }
        if (value instanceof String text && StringUtil.hasText(text)) {
            return Integer.parseInt(text);
        }
        return defaultValue;
    }

    private Boolean asBoolean(Object value, boolean defaultValue) {
        if (value == null) {
            return defaultValue;
        }
        if (value instanceof Boolean bool) {
            return bool;
        }
        if (value instanceof Number number) {
            return number.intValue() != 0;
        }
        return Boolean.parseBoolean(String.valueOf(value));
    }

    private int defaultInt(Integer value) {
        return value == null ? 0 : value;
    }

    private static class ParsedKnowledgeContent {
        private final String documentLlmContent;
        private final String documentRenderMarkdown;
        private final Map<String, Object> parseArtifactSummary;

        private ParsedKnowledgeContent(String documentLlmContent,
                                       String documentRenderMarkdown,
                                       Map<String, Object> parseArtifactSummary) {
            this.documentLlmContent = documentLlmContent;
            this.documentRenderMarkdown = documentRenderMarkdown;
            this.parseArtifactSummary = parseArtifactSummary == null
                ? new LinkedHashMap<String, Object>()
                : new LinkedHashMap<String, Object>(parseArtifactSummary);
        }
    }

    private static class StoreExecutionContext {
        private final DocumentCollection knowledge;
        private final EmbeddingModel embeddingModel;
        private final DocumentStore documentStore;
        private final StoreOptions options;
        private final DocumentSearcher searcher;

        private StoreExecutionContext(DocumentCollection knowledge,
                                      EmbeddingModel embeddingModel,
                                      DocumentStore documentStore,
                                      StoreOptions options,
                                      DocumentSearcher searcher) {
            this.knowledge = knowledge;
            this.embeddingModel = embeddingModel;
            this.documentStore = documentStore;
            this.options = options;
            this.searcher = searcher;
        }
    }
}
