feat: 完成S02桥接能力并接通M09工作流文档解析闭环

- 新增统一文档解析桥接子域,封装 easy-agents 文档解析门面

- 支持工作流开始节点文件上传与素材选择的单文件对象输入

- DocNode 改为文档解析节点,PDF 走统一解析,非 PDF 保持默认读取
This commit is contained in:
2026-04-14 19:57:32 +08:00
parent 855e93ecbf
commit a41b50959e
30 changed files with 2475 additions and 20 deletions

View File

@@ -0,0 +1,190 @@
package tech.easyflow.ai.document.service.impl;
import com.easyagents.document.core.DocumentParseService;
import com.easyagents.document.core.model.ParseRequest;
import com.easyagents.document.core.model.ParseResponse;
import com.easyagents.document.core.model.ParseResult;
import com.easyagents.document.core.model.ParseTaskInfo;
import com.easyagents.document.core.model.ParseTaskStatus;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentParseScenario;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.support.DocumentSourceLoader;
import tech.easyflow.ai.document.support.DocumentParseRequestFactory;
import tech.easyflow.ai.document.support.DocumentParseResultMapper;
import tech.easyflow.common.filestorage.FileStorageService;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
/**
* {@link DocumentParseBridgeServiceImpl} 单元测试。
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentParseBridgeServiceImplTest {
/**
* 验证同步解析成功透传并返回标准化结果。
*/
@Test
public void shouldParseSuccessfully() {
FakeDocumentParseService parseService = new FakeDocumentParseService();
DocumentParseBridgeServiceImpl bridgeService = buildBridgeService(parseService);
DocumentParsedResult document = bridgeService.parse(buildSource(), DocumentParseScenario.WORKFLOW_TEXT);
Assert.assertEquals("# demo", document.getPreferredText());
Assert.assertFalse(parseService.lastParseRequest.getReturnMiddleJson());
Assert.assertFalse(parseService.lastParseRequest.getReturnImages());
}
/**
* 验证异步提交、状态查询和结果查询链路可用。
*/
@Test
public void shouldSupportAsyncFlow() {
FakeDocumentParseService parseService = new FakeDocumentParseService();
DocumentParseBridgeServiceImpl bridgeService = buildBridgeService(parseService);
DocumentParseTaskStatus taskStatus = bridgeService.submit(buildSource(), DocumentParseScenario.KNOWLEDGE_IMPORT);
DocumentParseTaskStatus queriedStatus = bridgeService.queryTask("task-1");
DocumentParsedResult queriedResult = bridgeService.queryResult("task-1");
Assert.assertEquals("task-1", taskStatus.getTaskId());
Assert.assertEquals("running", queriedStatus.getStatus());
Assert.assertEquals("# demo", queriedResult.getPreferredText());
}
/**
* 验证聚合查询在完成状态下会附带标准化结果。
*/
@Test
public void shouldQueryTaskInfoSuccessfully() {
FakeDocumentParseService parseService = new FakeDocumentParseService();
parseService.taskStatusValue = "completed";
DocumentParseBridgeServiceImpl bridgeService = buildBridgeService(parseService);
DocumentParseTaskInfo taskInfo = bridgeService.queryTaskInfo("task-1");
Assert.assertEquals("completed", taskInfo.getStatus());
Assert.assertNotNull(taskInfo.getResult());
Assert.assertEquals("# demo", taskInfo.getResult().getPreferredText());
}
/**
* 验证缺少底层服务时抛出稳定错误码。
*/
@Test
public void shouldThrowWhenServiceDisabled() {
DocumentParseBridgeServiceImpl bridgeService = buildBridgeService(null);
try {
bridgeService.parse(buildSource(), DocumentParseScenario.WORKFLOW_TEXT);
Assert.fail("expected DocumentParseBridgeException");
} catch (DocumentParseBridgeException e) {
Assert.assertEquals("service_not_enabled", e.getCode());
}
}
private DocumentParseBridgeServiceImpl buildBridgeService(DocumentParseService parseService) {
return new DocumentParseBridgeServiceImpl(
parseService,
new DocumentSourceLoader(new InMemoryFileStorageService()),
new DocumentParseRequestFactory(),
new DocumentParseResultMapper()
);
}
private DocumentSourceRef buildSource() {
DocumentSourceRef sourceRef = DocumentSourceRef.ofBytes("demo.pdf", "pdf-data".getBytes(StandardCharsets.UTF_8));
sourceRef.setContentType("application/pdf");
sourceRef.setSize(8L);
return sourceRef;
}
private static class InMemoryFileStorageService implements FileStorageService {
@Override
public String save(org.springframework.web.multipart.MultipartFile file) {
return null;
}
@Override
public void delete(String path) {
}
@Override
public InputStream readStream(String path) {
return new ByteArrayInputStream("pdf-data".getBytes(StandardCharsets.UTF_8));
}
@Override
public long getFileSize(String path) {
return 8L;
}
}
private static class FakeDocumentParseService implements DocumentParseService {
private ParseRequest lastParseRequest;
private String taskStatusValue = "running";
@Override
public ParseResponse parse(ParseRequest request) {
this.lastParseRequest = request;
return buildResponse();
}
@Override
public ParseTaskStatus submit(ParseRequest request) {
this.lastParseRequest = request;
ParseTaskStatus status = new ParseTaskStatus();
status.setTaskId("task-1");
status.setStatus("submitted");
status.setFileNames(Collections.singletonList("demo.pdf"));
return status;
}
@Override
public ParseTaskStatus queryTask(String taskId) {
ParseTaskStatus status = new ParseTaskStatus();
status.setTaskId(taskId);
status.setStatus(taskStatusValue);
status.setFileNames(Collections.singletonList("demo.pdf"));
return status;
}
@Override
public ParseResponse queryResult(String taskId) {
return buildResponse();
}
@Override
public ParseTaskInfo queryTaskInfo(String taskId) {
ParseTaskInfo taskInfo = ParseTaskInfo.fromStatus(queryTask(taskId));
if ("completed".equals(taskStatusValue)) {
taskInfo.setResult(buildResponse());
}
return taskInfo;
}
private ParseResponse buildResponse() {
ParseResult result = new ParseResult();
result.setFileName("demo.pdf");
result.setMarkdown("# demo");
result.setPlainText("demo");
ParseResponse response = new ParseResponse();
response.setResults(Collections.singletonList(result));
return response;
}
}
}

View File

@@ -0,0 +1,57 @@
package tech.easyflow.ai.document.support;
import com.easyagents.document.core.model.ParseRequest;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.document.model.DocumentParseScenario;
/**
* {@link DocumentParseRequestFactory} 单元测试。
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentParseRequestFactoryTest {
/**
* 验证工作流文本场景只请求最小文本结果。
*/
@Test
public void shouldBuildWorkflowTextScenarioRequest() {
DocumentParseRequestFactory factory = new DocumentParseRequestFactory();
ParseRequest request = factory.build(buildSource(), DocumentParseScenario.WORKFLOW_TEXT);
Assert.assertNull(request.getParseMethod());
Assert.assertNull(request.getFormulaEnabled());
Assert.assertNull(request.getTableEnabled());
Assert.assertTrue(request.getReturnMarkdown());
Assert.assertFalse(request.getReturnMiddleJson());
Assert.assertFalse(request.getReturnContentList());
Assert.assertFalse(request.getReturnImages());
}
/**
* 验证知识库导入场景保留结构化工件。
*/
@Test
public void shouldBuildKnowledgeImportScenarioRequest() {
DocumentParseRequestFactory factory = new DocumentParseRequestFactory();
ParseRequest request = factory.build(buildSource(), DocumentParseScenario.KNOWLEDGE_IMPORT);
Assert.assertTrue(request.getReturnMarkdown());
Assert.assertTrue(request.getReturnMiddleJson());
Assert.assertTrue(request.getReturnContentList());
Assert.assertTrue(request.getReturnImages());
}
private LoadedDocumentSource buildSource() {
LoadedDocumentSource source = new LoadedDocumentSource();
source.setFileName("demo.pdf");
source.setContentType("application/pdf");
source.setContentBytes("pdf-data".getBytes());
source.setSize(8L);
return source;
}
}

View File

@@ -0,0 +1,82 @@
package tech.easyflow.ai.document.support;
import com.easyagents.document.core.model.ParseArtifacts;
import com.easyagents.document.core.model.ParseResult;
import com.easyagents.document.core.model.ParseResponse;
import com.easyagents.document.core.model.ParseTaskInfo;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import java.util.Collections;
/**
* {@link DocumentParseResultMapper} 单元测试。
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentParseResultMapperTest {
/**
* 验证 preferredText 按 markdown 优先、plainText 回退。
*/
@Test
public void shouldPreferMarkdown() {
DocumentParseResultMapper mapper = new DocumentParseResultMapper();
ParseResult result = new ParseResult();
result.setFileName("demo.pdf");
result.setMarkdown("# title");
result.setPlainText("plain");
DocumentParsedResult mapped = mapper.map(result);
Assert.assertEquals("# title", mapped.getPreferredText());
}
/**
* 验证结构化工件被正确映射。
*/
@Test
public void shouldMapArtifacts() {
DocumentParseResultMapper mapper = new DocumentParseResultMapper();
ParseResult result = new ParseResult();
ParseArtifacts artifacts = new ParseArtifacts();
artifacts.setMiddleJson(Collections.singletonMap("page", 1));
artifacts.setContentList(Collections.singletonList("block"));
artifacts.setModelOutput(Collections.singletonMap("raw", "ok"));
result.setArtifacts(artifacts);
DocumentParsedResult mapped = mapper.map(result);
Assert.assertNotNull(mapped.getArtifacts());
Assert.assertEquals(Collections.singletonMap("page", 1), mapped.getArtifacts().getMiddleJson());
Assert.assertEquals(Collections.singletonList("block"), mapped.getArtifacts().getContentList());
Assert.assertEquals(Collections.singletonMap("raw", "ok"), mapped.getArtifacts().getModelOutput());
}
/**
* 验证任务聚合结果被正确映射。
*/
@Test
public void shouldMapTaskInfo() {
DocumentParseResultMapper mapper = new DocumentParseResultMapper();
ParseTaskInfo taskInfo = new ParseTaskInfo();
taskInfo.setTaskId("task-1");
taskInfo.setStatus("completed");
ParseResult result = new ParseResult();
result.setFileName("demo.pdf");
result.setMarkdown("# title");
ParseResponse response = new ParseResponse();
response.setResults(Collections.singletonList(result));
taskInfo.setResult(response);
DocumentParseTaskInfo mapped = mapper.map(taskInfo);
Assert.assertEquals("task-1", mapped.getTaskId());
Assert.assertNotNull(mapped.getResult());
Assert.assertEquals("# title", mapped.getResult().getPreferredText());
}
}

View File

@@ -0,0 +1,133 @@
package tech.easyflow.ai.document.support;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.common.filestorage.FileStorageService;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import com.sun.net.httpserver.HttpServer;
/**
* {@link DocumentSourceLoader} 单元测试。
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentSourceLoaderTest {
/**
* 验证可从 filePath 正常读取文件内容。
*
* @throws IOException 测试读取异常
*/
@Test
public void shouldLoadContentFromFilePath() throws IOException {
DocumentSourceLoader loader = new DocumentSourceLoader(new FakeFileStorageService("demo-pdf".getBytes(StandardCharsets.UTF_8)));
DocumentSourceRef sourceRef = DocumentSourceRef.ofPath("/attachment/test/demo.pdf");
LoadedDocumentSource loadedSource = loader.load(sourceRef);
Assert.assertEquals("demo.pdf", loadedSource.getFileName());
Assert.assertEquals(8L, loadedSource.getSize().longValue());
Assert.assertArrayEquals("demo-pdf".getBytes(StandardCharsets.UTF_8), loadedSource.getContentBytes());
}
/**
* 验证缺少有效来源时抛出明确异常。
*/
@Test
public void shouldThrowWhenSourceMissing() {
DocumentSourceLoader loader = new DocumentSourceLoader(new FakeFileStorageService(new byte[0]));
try {
loader.load(new DocumentSourceRef());
Assert.fail("expected DocumentParseBridgeException");
} catch (DocumentParseBridgeException e) {
Assert.assertEquals("unsupported_source", e.getCode());
}
}
/**
* 验证 filePath 为远端 URL 时不会误走存储读取。
*/
@Test
public void shouldPreferRemoteDownloadWhenFilePathIsRemoteUrl() throws IOException {
DocumentSourceLoader loader = new DocumentSourceLoader(new FailingFileStorageService());
HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
byte[] body = "demo-pdf".getBytes(StandardCharsets.UTF_8);
server.createContext("/demo.pdf", exchange -> {
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
DocumentSourceRef sourceRef = new DocumentSourceRef();
sourceRef.setFileName("demo.pdf");
sourceRef.setFilePath("http://127.0.0.1:" + server.getAddress().getPort() + "/demo.pdf");
LoadedDocumentSource loadedSource = loader.load(sourceRef);
Assert.assertEquals("demo.pdf", loadedSource.getFileName());
Assert.assertArrayEquals(body, loadedSource.getContentBytes());
} finally {
server.stop(0);
}
}
private static class FakeFileStorageService implements FileStorageService {
private final byte[] content;
private FakeFileStorageService(byte[] content) {
this.content = content;
}
@Override
public String save(org.springframework.web.multipart.MultipartFile file) {
return null;
}
@Override
public void delete(String path) {
}
@Override
public InputStream readStream(String path) {
return new ByteArrayInputStream(content);
}
@Override
public long getFileSize(String path) {
return content.length;
}
}
private static class FailingFileStorageService implements FileStorageService {
@Override
public String save(org.springframework.web.multipart.MultipartFile file) {
return null;
}
@Override
public void delete(String path) {
}
@Override
public InputStream readStream(String path) throws IOException {
throw new IOException("should not read remote url from storage");
}
@Override
public long getFileSize(String path) {
return 0L;
}
}
}

View File

@@ -0,0 +1,278 @@
package tech.easyflow.ai.node;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import com.sun.net.httpserver.HttpServer;
/**
* {@link DocNodeFileContentExtractor} 单元测试。
*
* @author Codex
* @since 2026-04-14
*/
public class DocNodeFileContentExtractorTest {
/**
* 验证 PDF 文件会走统一文档解析桥接服务。
*/
@Test
public void shouldUseDocumentBridgeForPdf() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new FakeFileStorageService(),
new FakeReaderManager("ignored")
);
String content = extractor.extract(buildFileValue("demo.pdf", "/files/demo.pdf", "application/pdf"));
Assert.assertEquals("# parsed", content);
Assert.assertNotNull(bridgeService.lastSource);
Assert.assertEquals("demo.pdf", bridgeService.lastSource.getFileName());
}
/**
* 验证非 PDF 文件会继续走默认读取器。
*/
@Test
public void shouldUseDefaultReaderForNonPdf() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new FakeFileStorageService(),
new FakeReaderManager("plain text")
);
String content = extractor.extract(buildFileValue("demo.docx", "/files/demo.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"));
Assert.assertEquals("plain text", content);
Assert.assertNull(bridgeService.lastSource);
}
/**
* 验证缺少 filePath 时会抛出明确异常。
*/
@Test
public void shouldRejectMissingFilePath() {
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
new RecordingDocumentParseBridgeService(),
new FakeFileStorageService(),
new FakeReaderManager("plain text")
);
try {
extractor.extract(buildFileValue("demo.pdf", null, "application/pdf"));
Assert.fail("expected BusinessException");
} catch (BusinessException e) {
Assert.assertEquals("文件输入缺少 filePath", e.getMessage());
}
}
/**
* 验证解析结果为空时不会回退旧 PDF 读取链路。
*/
@Test
public void shouldFailWhenPdfParseResultIsEmpty() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
bridgeService.response.setPreferredText(null);
bridgeService.response.setMarkdown(null);
bridgeService.response.setPlainText(null);
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new FakeFileStorageService(),
new FakeReaderManager("pdf fallback")
);
try {
extractor.extract(buildFileValue("demo.pdf", "/files/demo.pdf", "application/pdf"));
Assert.fail("expected BusinessException");
} catch (BusinessException e) {
Assert.assertEquals("PDF 文档解析结果为空", e.getMessage());
}
}
/**
* 验证远端素材 URL 的非 PDF 文件不会误走本地存储读取。
*/
@Test
public void shouldReadRemoteUrlForNonPdf() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
HttpServer server;
try {
server = HttpServer.create(new InetSocketAddress(0), 0);
} catch (IOException e) {
throw new RuntimeException(e);
}
byte[] body = "remote text".getBytes(StandardCharsets.UTF_8);
server.createContext("/demo.docx", exchange -> {
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new FailingFileStorageService(),
new ReadingReaderManager()
);
String content = extractor.extract(buildFileValue(
"demo.docx",
"http://127.0.0.1:" + server.getAddress().getPort() + "/demo.docx",
""
));
Assert.assertEquals("remote text", content);
Assert.assertNull(bridgeService.lastSource);
} finally {
server.stop(0);
}
}
private Map<String, Object> buildFileValue(String fileName, String filePath, String contentType) {
Map<String, Object> value = new HashMap<String, Object>();
value.put("fileName", fileName);
value.put("filePath", filePath);
value.put("contentType", contentType);
value.put("size", 16L);
value.put("url", filePath);
return value;
}
private static class RecordingDocumentParseBridgeService implements DocumentParseBridgeService {
private final DocumentParsedResult response = new DocumentParsedResult();
private DocumentSourceRef lastSource;
private RecordingDocumentParseBridgeService() {
response.setPreferredText("# parsed");
response.setMarkdown("# parsed");
response.setPlainText("parsed");
}
@Override
public DocumentParsedResult parse(DocumentSourceRef source, tech.easyflow.ai.document.model.DocumentParseScenario scenario) {
this.lastSource = source;
return response;
}
@Override
public DocumentParseTaskStatus submit(DocumentSourceRef source, tech.easyflow.ai.document.model.DocumentParseScenario scenario) {
return new DocumentParseTaskStatus();
}
@Override
public DocumentParseTaskStatus queryTask(String taskId) {
return new DocumentParseTaskStatus();
}
@Override
public DocumentParsedResult queryResult(String taskId) {
return response;
}
@Override
public DocumentParseTaskInfo queryTaskInfo(String taskId) {
return new DocumentParseTaskInfo();
}
}
private static class FakeFileStorageService implements FileStorageService {
@Override
public String save(org.springframework.web.multipart.MultipartFile file) {
return null;
}
@Override
public void delete(String path) {
}
@Override
public String save(File file, String prePath) {
return null;
}
@Override
public InputStream readStream(String path) throws IOException {
return new ByteArrayInputStream("doc".getBytes(StandardCharsets.UTF_8));
}
@Override
public long getFileSize(String path) {
return 3L;
}
}
private static class FakeReaderManager extends ReaderManager {
private final String content;
private FakeReaderManager(String content) {
this.content = content;
}
@Override
public ReadDocService getReader() {
return (fileName, is) -> content;
}
}
private static class ReadingReaderManager extends ReaderManager {
@Override
public ReadDocService getReader() {
return (fileName, is) -> {
try {
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
};
}
}
private static class FailingFileStorageService implements FileStorageService {
@Override
public String save(org.springframework.web.multipart.MultipartFile file) {
return null;
}
@Override
public void delete(String path) {
}
@Override
public String save(File file, String prePath) {
return null;
}
@Override
public InputStream readStream(String path) throws IOException {
throw new IOException("should not read remote url from storage");
}
@Override
public long getFileSize(String path) {
return 0L;
}
}
}