feat: 支持知识库导入 PPTX 与 XLSX 文档

- 打通 Office 文档桥接解析、解析进度承接与图片引用改写

- 落地 PPTX 按页分块、XLSX 行窗口分块以及预览与检索渲染闭环
This commit is contained in:
2026-04-18 13:01:17 +08:00
parent ad67ba85ad
commit 4130381658
28 changed files with 2876 additions and 120 deletions

View File

@@ -35,10 +35,14 @@ public class DocumentParseBridgeException extends RuntimeException {
public static DocumentParseBridgeException serviceNotEnabled() {
return new DocumentParseBridgeException(
"service_not_enabled",
"统一文档解析服务未启用,请先配置 easy-agents.document.pdf.provider"
"统一文档解析服务未启用,请先配置 easy-agents.document.ocr.provider=mineru"
);
}
public static DocumentParseBridgeException serviceNotEnabled(String message) {
return new DocumentParseBridgeException("service_not_enabled", message);
}
public static DocumentParseBridgeException unsupportedSource(String message) {
return new DocumentParseBridgeException("unsupported_source", message);
}

View File

@@ -22,6 +22,11 @@ public class DocumentParseTaskStatus {
private String statusUrl;
private String resultUrl;
private Integer queuedAhead;
private Integer progressPercent;
private String currentStage;
private Integer processedItems;
private Integer totalItems;
private String statusMessage;
public String getTaskId() {
return taskId;
@@ -110,4 +115,44 @@ public class DocumentParseTaskStatus {
public void setQueuedAhead(Integer queuedAhead) {
this.queuedAhead = queuedAhead;
}
public Integer getProgressPercent() {
return progressPercent;
}
public void setProgressPercent(Integer progressPercent) {
this.progressPercent = progressPercent;
}
public String getCurrentStage() {
return currentStage;
}
public void setCurrentStage(String currentStage) {
this.currentStage = currentStage;
}
public Integer getProcessedItems() {
return processedItems;
}
public void setProcessedItems(Integer processedItems) {
this.processedItems = processedItems;
}
public Integer getTotalItems() {
return totalItems;
}
public void setTotalItems(Integer totalItems) {
this.totalItems = totalItems;
}
public String getStatusMessage() {
return statusMessage;
}
public void setStatusMessage(String statusMessage) {
this.statusMessage = statusMessage;
}
}

View File

@@ -5,6 +5,10 @@ import com.easyagents.document.core.entity.ParseResponse;
import com.easyagents.document.core.entity.ParseResult;
import com.easyagents.document.core.entity.ParseTaskInfo;
import com.easyagents.document.core.entity.ParseTaskStatus;
import com.easyagents.document.pdf.PdfDocumentParseService;
import com.easyagents.document.pptx.PptxDocumentParseService;
import com.easyagents.document.xlsx.XlsxDocumentParseService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.Nullable;
@@ -20,8 +24,13 @@ import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.ai.document.support.DocumentSourceLoader;
import tech.easyflow.ai.document.support.DocumentParseRequestFactory;
import tech.easyflow.ai.document.support.DocumentParseResultMapper;
import tech.easyflow.ai.document.support.DocumentParseSourceType;
import tech.easyflow.ai.document.support.LoadedDocumentSource;
import tech.easyflow.ai.utils.DocUtil;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.function.Function;
/**
* 统一文档解析桥接门面默认实现。
@@ -33,18 +42,33 @@ import tech.easyflow.ai.utils.DocUtil;
public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeService {
private static final Logger LOG = LoggerFactory.getLogger(DocumentParseBridgeServiceImpl.class);
private static final String DEFAULT_DOCUMENT_PARSE_SERVICE_BEAN_NAME = "documentParseService";
@Nullable
private final DocumentParseService documentParseService;
private final DocumentParseService defaultDocumentParseService;
@Nullable
private final PdfDocumentParseService pdfDocumentParseService;
@Nullable
private final PptxDocumentParseService pptxDocumentParseService;
@Nullable
private final XlsxDocumentParseService xlsxDocumentParseService;
private final DocumentSourceLoader documentSourceLoader;
private final DocumentParseRequestFactory parseRequestFactory;
private final DocumentParseResultMapper parseResultMapper;
public DocumentParseBridgeServiceImpl(@Nullable DocumentParseService documentParseService,
public DocumentParseBridgeServiceImpl(@Nullable
@Qualifier(DEFAULT_DOCUMENT_PARSE_SERVICE_BEAN_NAME)
DocumentParseService defaultDocumentParseService,
@Nullable PdfDocumentParseService pdfDocumentParseService,
@Nullable PptxDocumentParseService pptxDocumentParseService,
@Nullable XlsxDocumentParseService xlsxDocumentParseService,
DocumentSourceLoader documentSourceLoader,
DocumentParseRequestFactory parseRequestFactory,
DocumentParseResultMapper parseResultMapper) {
this.documentParseService = documentParseService;
this.defaultDocumentParseService = defaultDocumentParseService;
this.pdfDocumentParseService = pdfDocumentParseService;
this.pptxDocumentParseService = pptxDocumentParseService;
this.xlsxDocumentParseService = xlsxDocumentParseService;
this.documentSourceLoader = documentSourceLoader;
this.parseRequestFactory = parseRequestFactory;
this.parseResultMapper = parseResultMapper;
@@ -59,7 +83,8 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
LoadedDocumentSource loadedSource = prepareSupportedSource(source);
LOG.info("桥接服务开始同步解析文档: fileName={}, contentType={}, scenario={}",
loadedSource.getFileName(), loadedSource.getContentType(), scenario);
ParseResponse response = requireService().parse(parseRequestFactory.build(loadedSource, scenario));
DocumentParseService parseService = resolveService(loadedSource);
ParseResponse response = parseService.parse(parseRequestFactory.build(loadedSource, scenario));
DocumentParsedResult result = parseResultMapper.map(extractSingleResult(response, false));
LOG.info("桥接服务同步解析完成: fileName={}, scenario={}, preferredTextLength={}",
loadedSource.getFileName(), scenario, resolveTextLength(result));
@@ -84,7 +109,8 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
LoadedDocumentSource loadedSource = prepareSupportedSource(source);
LOG.info("桥接服务开始提交异步解析任务: fileName={}, contentType={}, scenario={}",
loadedSource.getFileName(), loadedSource.getContentType(), scenario);
ParseTaskStatus taskStatus = requireService().submit(parseRequestFactory.build(loadedSource, scenario));
DocumentParseService parseService = resolveService(loadedSource);
ParseTaskStatus taskStatus = parseService.submit(parseRequestFactory.build(loadedSource, scenario));
DocumentParseTaskStatus mappedStatus = parseResultMapper.map(taskStatus);
LOG.info("桥接服务异步解析任务提交完成: fileName={}, scenario={}, providerTaskId={}, status={}",
loadedSource.getFileName(), scenario, mappedStatus.getTaskId(), mappedStatus.getStatus());
@@ -109,7 +135,8 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
throw DocumentParseBridgeException.taskFailed("taskId 不能为空");
}
try {
return parseResultMapper.map(requireService().queryTask(taskId));
ParseTaskStatus taskStatus = executeAgainstTaskService(taskId, service -> service.queryTask(taskId));
return parseResultMapper.map(taskStatus);
} catch (DocumentParseBridgeException e) {
throw e;
} catch (Exception e) {
@@ -127,7 +154,7 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
}
try {
LOG.info("桥接服务开始获取异步解析结果: providerTaskId={}", taskId);
ParseResponse response = requireService().queryResult(taskId);
ParseResponse response = executeAgainstTaskService(taskId, service -> service.queryResult(taskId));
DocumentParsedResult result = parseResultMapper.map(extractSingleResult(response, true));
LOG.info("桥接服务获取异步解析结果完成: providerTaskId={}, preferredTextLength={}",
taskId, resolveTextLength(result));
@@ -150,7 +177,7 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
throw DocumentParseBridgeException.taskFailed("taskId 不能为空");
}
try {
ParseTaskInfo taskInfo = requireService().queryTaskInfo(taskId);
ParseTaskInfo taskInfo = executeAgainstTaskService(taskId, service -> service.queryTaskInfo(taskId));
DocumentParseTaskInfo mappedTaskInfo = parseResultMapper.map(taskInfo);
LOG.info("桥接服务查询异步解析任务状态: providerTaskId={}, status={}, hasResult={}",
taskId,
@@ -177,39 +204,84 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
return text == null ? 0 : text.length();
}
private DocumentParseService requireService() {
if (documentParseService == null) {
throw DocumentParseBridgeException.serviceNotEnabled();
}
return documentParseService;
}
private LoadedDocumentSource prepareSupportedSource(DocumentSourceRef source) {
LoadedDocumentSource loadedSource = documentSourceLoader.load(source);
if (!isSupportedByBridge(loadedSource)) {
throw DocumentParseBridgeException.unsupportedSource("统一文档解析桥接当前仅支持 PDF、DOCX 文件");
throw DocumentParseBridgeException.unsupportedSource("统一文档解析桥接当前仅支持 PDF、DOCX、PPTX、XLSX 文件");
}
return loadedSource;
}
private boolean isSupportedByBridge(LoadedDocumentSource loadedSource) {
String contentType = loadedSource.getContentType();
if (StringUtils.hasText(contentType)) {
String normalizedContentType = contentType.toLowerCase();
if (normalizedContentType.contains("pdf")
|| normalizedContentType.contains("wordprocessingml.document")) {
return true;
return DocumentParseSourceType.resolve(loadedSource.getFileName(), loadedSource.getContentType()) != DocumentParseSourceType.UNSUPPORTED;
}
private DocumentParseService resolveService(LoadedDocumentSource loadedSource) {
DocumentParseSourceType sourceType = DocumentParseSourceType.resolve(loadedSource.getFileName(), loadedSource.getContentType());
switch (sourceType) {
case PDF:
return requireSpecificService(pdfDocumentParseService, defaultDocumentParseService, "PDF");
case DOCX:
return requireSpecificService(defaultDocumentParseService, pdfDocumentParseService, "DOCX");
case PPTX:
return requireSpecificService(pptxDocumentParseService, null, "PPTX");
case XLSX:
return requireSpecificService(xlsxDocumentParseService, null, "XLSX");
default:
throw DocumentParseBridgeException.unsupportedSource("当前文件类型暂不支持桥接解析");
}
}
private DocumentParseService requireSpecificService(@Nullable DocumentParseService primaryService,
@Nullable DocumentParseService fallbackService,
String sourceType) {
if (primaryService != null) {
return primaryService;
}
if (fallbackService != null) {
return fallbackService;
}
throw DocumentParseBridgeException.serviceNotEnabled("未启用 " + sourceType + " 文档解析服务");
}
private <T> T executeAgainstTaskService(String taskId, Function<DocumentParseService, T> action) {
List<DocumentParseService> services = availableServices();
if (services.isEmpty()) {
throw DocumentParseBridgeException.serviceNotEnabled();
}
Exception lastException = null;
for (DocumentParseService service : services) {
try {
return action.apply(service);
} catch (Exception exception) {
lastException = exception;
LOG.debug("桥接服务任务查询尝试失败,准备切换下一个解析服务: taskId={}, service={}",
taskId,
service.getClass().getSimpleName(),
exception);
}
}
String fileName = loadedSource.getFileName();
if (!StringUtils.hasText(fileName) || !fileName.contains(".")) {
return false;
if (lastException instanceof RuntimeException) {
throw (RuntimeException) lastException;
}
String suffix = DocUtil.normalizeSuffix(DocUtil.getSuffix(fileName));
if ("pdf".equals(suffix) || "docx".equals(suffix)) {
return true;
throw DocumentParseBridgeException.taskFailed("未找到可处理当前任务ID的文档解析服务", lastException);
}
private List<DocumentParseService> availableServices() {
LinkedHashSet<DocumentParseService> services = new LinkedHashSet<DocumentParseService>();
if (pptxDocumentParseService != null) {
services.add(pptxDocumentParseService);
}
return false;
if (xlsxDocumentParseService != null) {
services.add(xlsxDocumentParseService);
}
if (pdfDocumentParseService != null) {
services.add(pdfDocumentParseService);
}
if (defaultDocumentParseService != null) {
services.add(defaultDocumentParseService);
}
return new ArrayList<DocumentParseService>(services);
}
private ParseResult extractSingleResult(ParseResponse response, boolean resultFetchPhase) {

View File

@@ -2,6 +2,9 @@ package tech.easyflow.ai.document.support;
import com.easyagents.document.core.entity.ParseFile;
import com.easyagents.document.core.entity.ParseRequest;
import com.easyagents.document.core.entity.PdfParseRequest;
import com.easyagents.document.core.entity.PptxParseRequest;
import com.easyagents.document.core.entity.XlsxParseRequest;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentParseScenario;
@@ -31,12 +34,28 @@ public class DocumentParseRequestFactory {
if (scenario == null) {
throw DocumentParseBridgeException.requestBuildFailed("解析场景不能为空");
}
ParseRequest request = new ParseRequest();
ParseRequest request = createTypedRequest(source);
request.addFile(ParseFile.of(source.getFileName(), source.getContentBytes(), source.getContentType()));
applyScenario(request, scenario);
return request;
}
private ParseRequest createTypedRequest(LoadedDocumentSource source) {
DocumentParseSourceType sourceType = DocumentParseSourceType.resolve(source.getFileName(), source.getContentType());
switch (sourceType) {
case PDF:
return new PdfParseRequest();
case PPTX:
return new PptxParseRequest();
case XLSX:
return new XlsxParseRequest();
case DOCX:
return new ParseRequest();
default:
throw DocumentParseBridgeException.requestBuildFailed("当前文件类型暂不支持桥接解析");
}
}
private void applyScenario(ParseRequest request, DocumentParseScenario scenario) {
switch (scenario) {
case WORKFLOW_TEXT:

View File

@@ -69,6 +69,11 @@ public class DocumentParseResultMapper {
status.setStatusUrl(taskStatus.getStatusUrl());
status.setResultUrl(taskStatus.getResultUrl());
status.setQueuedAhead(taskStatus.getQueuedAhead());
status.setProgressPercent(taskStatus.getProgressPercent());
status.setCurrentStage(taskStatus.getCurrentStage());
status.setProcessedItems(taskStatus.getProcessedItems());
status.setTotalItems(taskStatus.getTotalItems());
status.setStatusMessage(taskStatus.getStatusMessage());
return status;
}
@@ -104,6 +109,11 @@ public class DocumentParseResultMapper {
status.setStatusUrl(taskStatus.getStatusUrl());
status.setResultUrl(taskStatus.getResultUrl());
status.setQueuedAhead(taskStatus.getQueuedAhead());
status.setProgressPercent(taskStatus.getProgressPercent());
status.setCurrentStage(taskStatus.getCurrentStage());
status.setProcessedItems(taskStatus.getProcessedItems());
status.setTotalItems(taskStatus.getTotalItems());
status.setStatusMessage(taskStatus.getStatusMessage());
}
private String resolvePreferredText(ParseResult parseResult) {

View File

@@ -0,0 +1,70 @@
package tech.easyflow.ai.document.support;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.utils.DocUtil;
/**
* 统一文档解析桥接支持的源文件类型。
*
* @author Codex
* @since 2026-04-17
*/
public enum DocumentParseSourceType {
PDF,
DOCX,
PPTX,
XLSX,
UNSUPPORTED;
/**
* 根据文件名与内容类型推断文档类型。
*
* @param fileName 文件名
* @param contentType MIME 类型
* @return 文档类型
*/
public static DocumentParseSourceType resolve(String fileName, String contentType) {
if (StringUtils.hasText(contentType)) {
String normalizedContentType = contentType.toLowerCase();
if (normalizedContentType.contains("pdf")) {
return PDF;
}
if (normalizedContentType.contains("wordprocessingml.document")) {
return DOCX;
}
if (normalizedContentType.contains("presentationml.presentation")) {
return PPTX;
}
if (normalizedContentType.contains("spreadsheetml.sheet")) {
return XLSX;
}
}
if (!StringUtils.hasText(fileName) || !fileName.contains(".")) {
return UNSUPPORTED;
}
String suffix = DocUtil.normalizeSuffix(DocUtil.getSuffix(fileName));
if ("pdf".equals(suffix)) {
return PDF;
}
if ("docx".equals(suffix)) {
return DOCX;
}
if ("pptx".equals(suffix)) {
return PPTX;
}
if ("xlsx".equals(suffix)) {
return XLSX;
}
return UNSUPPORTED;
}
/**
* 判断是否属于 Office 首版接入类型。
*
* @return 是否是本次 Office 类型
*/
public boolean isOffice() {
return this == PPTX || this == XLSX;
}
}

View File

@@ -286,6 +286,7 @@ public final class DocumentImportDtos {
private String chunkId;
private String chunkType;
private String content;
private String renderMarkdown;
private List<String> headingPath = new ArrayList<String>();
private Integer partNo;
private Integer partTotal;
@@ -335,6 +336,14 @@ public final class DocumentImportDtos {
this.content = content;
}
public String getRenderMarkdown() {
return renderMarkdown;
}
public void setRenderMarkdown(String renderMarkdown) {
this.renderMarkdown = renderMarkdown;
}
public List<String> getHeadingPath() {
return headingPath;
}

View File

@@ -22,4 +22,19 @@ public final class DocumentImportKeys {
public static final String KEY_DOCUMENT_PARSE_METADATA = "parse.metadata";
public static final String KEY_DOCUMENT_PARSE_WARNINGS = "parse.warnings";
public static final String KEY_DOCUMENT_PROVIDER_TASK_ID = "parse.providerTaskId";
public static final String KEY_DOCUMENT_PARSE_IMAGE_URLS = "parse.imageUrls";
public static final String KEY_DOCUMENT_PARSE_IMAGE_COUNT = "parse.imageCount";
public static final String KEY_DOCUMENT_PARSE_IMAGE_STORAGE_PREFIX = "parse.imageStoragePrefix";
public static final String KEY_DOCUMENT_PARSE_PROGRESS_PERCENT = "parse.progressPercent";
public static final String KEY_DOCUMENT_PARSE_CURRENT_STAGE = "parse.currentStage";
public static final String KEY_DOCUMENT_PARSE_PROCESSED_ITEMS = "parse.processedItems";
public static final String KEY_DOCUMENT_PARSE_TOTAL_ITEMS = "parse.totalItems";
public static final String KEY_DOCUMENT_PARSE_STATUS_MESSAGE = "parse.statusMessage";
public static final String KEY_DOCUMENT_RENDER_MARKDOWN = "renderMarkdown";
public static final String KEY_DOCUMENT_PAGE_INDEX = "pageIndex";
public static final String KEY_DOCUMENT_SHEET_NAME = "sheetName";
public static final String KEY_DOCUMENT_ROW_START = "rowStart";
public static final String KEY_DOCUMENT_ROW_END = "rowEnd";
public static final String KEY_DOCUMENT_IMAGE_REFS = "imageRefs";
public static final String KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY = "parseArtifactSummary";
}

View File

@@ -6,6 +6,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -116,11 +117,21 @@ public class DocumentImportTaskStatusStreamService {
payload.put("totalChunks", document.getTotalChunks());
payload.put("completedChunks", document.getCompletedChunks());
payload.put("failedChunks", document.getFailedChunks());
payload.put("parseCurrentStage", readOptionAsString(document, DocumentImportKeys.KEY_DOCUMENT_PARSE_CURRENT_STAGE));
payload.put("parseStatusMessage", readOptionAsString(document, DocumentImportKeys.KEY_DOCUMENT_PARSE_STATUS_MESSAGE));
payload.put("lastTaskError", document.getLastTaskError());
payload.put("taskModifiedAt", document.getTaskModifiedAt());
return payload;
}
private String readOptionAsString(Document document, String key) {
if (document == null || document.getOptions() == null || key == null) {
return null;
}
Object value = document.getOptions().get(key);
return value == null ? null : String.valueOf(value);
}
private void sendAsync(String topicKey, SseEmitter emitter, String eventName, Map<String, Object> payload) {
sseThreadPool.execute(() -> {
try {

View File

@@ -4,6 +4,8 @@ public class KnowledgeSearchResultItem {
private Integer sorting;
private String content;
private String renderMarkdown;
private String sourceFileName;
private Double score;
private String hitSource;
private Double vectorScore;
@@ -25,6 +27,22 @@ public class KnowledgeSearchResultItem {
this.content = content;
}
public String getRenderMarkdown() {
return renderMarkdown;
}
public void setRenderMarkdown(String renderMarkdown) {
this.renderMarkdown = renderMarkdown;
}
public String getSourceFileName() {
return sourceFileName;
}
public void setSourceFileName(String sourceFileName) {
this.sourceFileName = sourceFileName;
}
public Double getScore() {
return score;
}

View File

@@ -32,6 +32,7 @@ import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.mapper.DocumentChunkMapper;
import tech.easyflow.ai.mapper.DocumentCollectionMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
@@ -406,6 +407,14 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
return false;
}
item.setContent(content);
String renderMarkdown = hitSnapshot.findChunkRenderMarkdown(item.getId());
if (StringUtil.hasText(renderMarkdown)) {
item.addMetadata("renderMarkdown", renderMarkdown);
}
String sourceFileName = hitSnapshot.findSourceFileName(item.getId());
if (StringUtil.hasText(sourceFileName)) {
item.addMetadata("sourceFileName", sourceFileName);
}
return true;
})
.collect(Collectors.toList());
@@ -596,6 +605,30 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
}
return StringUtil.noText(documentChunk.getContent()) ? null : documentChunk.getContent();
}
private String findChunkRenderMarkdown(Object chunkId) {
DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
if (documentChunk == null || documentChunk.getDocumentId() == null || documentChunk.getOptions() == null) {
return null;
}
if (!documentMap.containsKey(String.valueOf(documentChunk.getDocumentId()))) {
return null;
}
Object renderMarkdown = documentChunk.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN);
return renderMarkdown == null ? null : String.valueOf(renderMarkdown);
}
private String findSourceFileName(Object chunkId) {
DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
if (documentChunk == null || documentChunk.getDocumentId() == null) {
return null;
}
tech.easyflow.ai.entity.Document sourceDocument = documentMap.get(String.valueOf(documentChunk.getDocumentId()));
if (sourceDocument == null || StringUtil.noText(sourceDocument.getTitle())) {
return null;
}
return sourceDocument.getTitle();
}
}
private String buildFaqPromptContent(FaqItem faqItem, List<Map<String, String>> images) {