mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Change AI engine to execute tools in Java instead of on frontend (#6116)
# Description of Changes Redesign AI engine so that it autogenerates the `tool_models.py` file from the OpenAPI spec so the Python has access to the Java API parameters and the full list of Java tools that it can run. CI ensures that whenever someone modifies a tool endpoint that the AI enigne tool models get updated as well (the dev gets told to run `task engine:tool-models`). There's loads of advantages to having the Java be the one that actually executes the tools, rather than the frontend as it was previously set up to theoretically use: - The AI gets much better descriptions of the params from the API docs - It'll be usable headless in the future so a Java daemon could run to execute ops on files in a folder without the need for the UI to run - The Java already has all the logic it needs to execute the tools - We don't need to parse the TypeScript to find the API (which is hard because the TS wasn't designed to be computer-read to extract the API) I've also hooked up the prototype frontend to ensure it's working properly, and have built it in a way that all the tool names can be translated properly, which was always an issue with previous prototypes of this. --------- Co-authored-by: Anthony Stirling <[email protected]> Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
EthanHealy01
parent
cc9650e7a3
commit
e5767ed58b
+8
-110
@@ -4,8 +4,6 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
@@ -15,24 +13,18 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedConstruction;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import jakarta.servlet.ServletContext;
|
||||
|
||||
import stirling.software.SPDF.model.PipelineConfig;
|
||||
import stirling.software.SPDF.model.PipelineOperation;
|
||||
import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.service.ApiDocService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -40,9 +32,7 @@ class PipelineProcessorTest {
|
||||
|
||||
@Mock ApiDocService apiDocService;
|
||||
|
||||
@Mock UserServiceInterface userService;
|
||||
|
||||
@Mock ServletContext servletContext;
|
||||
@Mock InternalApiClient internalApiClient;
|
||||
|
||||
@Mock TempFileManager tempFileManager;
|
||||
|
||||
@@ -51,9 +41,7 @@ class PipelineProcessorTest {
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
pipelineProcessor =
|
||||
spy(
|
||||
new PipelineProcessor(
|
||||
apiDocService, userService, servletContext, tempFileManager));
|
||||
new PipelineProcessor(apiDocService, internalApiClient, tempFileManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,7 +53,6 @@ class PipelineProcessorTest {
|
||||
config.setOperations(List.of(op));
|
||||
|
||||
Resource file = new MyFileByteArrayResource();
|
||||
|
||||
List<Resource> files = List.of(file);
|
||||
|
||||
when(apiDocService.isMultiInput("/api/v1/filter/filter-page-count")).thenReturn(false);
|
||||
@@ -74,13 +61,11 @@ class PipelineProcessorTest {
|
||||
when(apiDocService.isValidOperation(eq("/api/v1/filter/filter-page-count"), anyMap()))
|
||||
.thenReturn(true);
|
||||
|
||||
// Use a FileSystemResource backed by a temp file to avoid FileNotFoundException
|
||||
Path emptyTemp = Files.createTempFile("empty", ".tmp");
|
||||
Resource emptyResource = new FileSystemResource(emptyTemp.toFile());
|
||||
|
||||
doReturn(new ResponseEntity<>(emptyResource, HttpStatus.OK))
|
||||
.when(pipelineProcessor)
|
||||
.sendWebRequest(anyString(), any());
|
||||
when(internalApiClient.post(anyString(), any()))
|
||||
.thenReturn(new ResponseEntity<>(emptyResource, HttpStatus.OK));
|
||||
|
||||
PipelineResult result = pipelineProcessor.runPipelineAgainstFiles(files, config);
|
||||
|
||||
@@ -118,105 +103,18 @@ class PipelineProcessorTest {
|
||||
when(apiDocService.getExtensionTypes(anyBoolean(), anyString())).thenReturn(List.of("pdf"));
|
||||
when(apiDocService.isValidOperation(anyString(), anyMap())).thenReturn(true);
|
||||
|
||||
doReturn(new ResponseEntity<>(outputResource, HttpStatus.OK))
|
||||
.when(pipelineProcessor)
|
||||
.sendWebRequest(anyString(), any());
|
||||
when(internalApiClient.post(anyString(), any()))
|
||||
.thenReturn(new ResponseEntity<>(outputResource, HttpStatus.OK));
|
||||
|
||||
PipelineResult result = pipelineProcessor.runPipelineAgainstFiles(files, config);
|
||||
|
||||
verify(pipelineProcessor).sendWebRequest(anyString(), any());
|
||||
verify(internalApiClient).post(anyString(), any());
|
||||
|
||||
assertFalse(result.isHasErrors());
|
||||
|
||||
// Clean up
|
||||
Files.deleteIfExists(tempPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendWebRequestDoesNotForceContentType() throws Exception {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add(
|
||||
"fileInput",
|
||||
new ByteArrayResource("data".getBytes(StandardCharsets.UTF_8)) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "input.pdf";
|
||||
}
|
||||
});
|
||||
|
||||
Path tempPath = Files.createTempFile("pipeline-test", ".tmp");
|
||||
var tempFile = mock(stirling.software.common.util.TempFile.class);
|
||||
when(tempFile.getPath()).thenReturn(tempPath);
|
||||
when(tempFile.getFile()).thenReturn(tempPath.toFile());
|
||||
when(tempFileManager.createManagedTempFile("pipeline")).thenReturn(tempFile);
|
||||
|
||||
var capturedHeaders = new org.springframework.http.HttpHeaders[1];
|
||||
|
||||
try (MockedConstruction<org.springframework.web.client.RestTemplate> ignored =
|
||||
mockConstruction(
|
||||
org.springframework.web.client.RestTemplate.class,
|
||||
(mock, context) -> {
|
||||
when(mock.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
var entity = invocation.getArgument(0);
|
||||
capturedHeaders[0] =
|
||||
((org.springframework.http.HttpEntity<?>)
|
||||
entity)
|
||||
.getHeaders();
|
||||
return (org.springframework.web.client
|
||||
.RequestCallback)
|
||||
request -> {};
|
||||
});
|
||||
|
||||
when(mock.execute(
|
||||
anyString(),
|
||||
eq(org.springframework.http.HttpMethod.POST),
|
||||
any(),
|
||||
any()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
var extractor =
|
||||
(org.springframework.web.client
|
||||
.ResponseExtractor<
|
||||
ResponseEntity<Resource>>)
|
||||
invocation.getArgument(3);
|
||||
ClientHttpResponse response =
|
||||
mock(ClientHttpResponse.class);
|
||||
when(response.getBody())
|
||||
.thenReturn(
|
||||
new ByteArrayInputStream(
|
||||
"ok"
|
||||
.getBytes(
|
||||
StandardCharsets
|
||||
.UTF_8)));
|
||||
var headers =
|
||||
new org.springframework.http.HttpHeaders();
|
||||
headers.add(
|
||||
org.springframework.http.HttpHeaders
|
||||
.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"out.pdf\"");
|
||||
when(response.getHeaders()).thenReturn(headers);
|
||||
lenient()
|
||||
.when(response.getStatusCode())
|
||||
.thenReturn(HttpStatus.OK);
|
||||
return extractor.extractData(response);
|
||||
});
|
||||
})) {
|
||||
ResponseEntity<Resource> response =
|
||||
pipelineProcessor.sendWebRequest(
|
||||
"http://localhost/api/v1/general/merge-pdfs", body);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertNull(capturedHeaders[0].getContentType());
|
||||
} finally {
|
||||
Files.deleteIfExists(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static class MyFileByteArrayResource extends ByteArrayResource {
|
||||
public MyFileByteArrayResource() {
|
||||
super("data".getBytes());
|
||||
|
||||
@@ -9,6 +9,8 @@ import java.util.Map;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@@ -348,6 +350,123 @@ class ApiDocServiceTest {
|
||||
assertTrue(apiDocService.isMultiInput("/miso"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUnpackZipResponseDetectsMultiOutputType() throws Exception {
|
||||
String json = "{\"description\": \"Output:PDF Type:SIMO\"}";
|
||||
JsonNode postNode = mapper.readTree(json);
|
||||
ApiEndpoint endpoint = new ApiEndpoint("/split", postNode);
|
||||
setApiDocumentation(Map.of("/split", endpoint));
|
||||
setApiDocsJsonRootNode();
|
||||
assertTrue(apiDocService.shouldUnpackZipResponse("/split"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUnpackZipResponseDetectsMimoType() throws Exception {
|
||||
String json = "{\"description\": \"Output:PDF Type:MIMO\"}";
|
||||
JsonNode postNode = mapper.readTree(json);
|
||||
ApiEndpoint endpoint = new ApiEndpoint("/overlay", postNode);
|
||||
setApiDocumentation(Map.of("/overlay", endpoint));
|
||||
setApiDocsJsonRootNode();
|
||||
assertTrue(apiDocService.shouldUnpackZipResponse("/overlay"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUnpackZipResponseDetectsZipOutputDeclaration() throws Exception {
|
||||
String json = "{\"description\": \"Output:ZIP-PDF Type:SISO\"}";
|
||||
JsonNode postNode = mapper.readTree(json);
|
||||
ApiEndpoint endpoint = new ApiEndpoint("/split-by-sections", postNode);
|
||||
setApiDocumentation(Map.of("/split-by-sections", endpoint));
|
||||
setApiDocsJsonRootNode();
|
||||
assertTrue(apiDocService.shouldUnpackZipResponse("/split-by-sections"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUnpackZipResponseReturnsFalseForSisoPdf() throws Exception {
|
||||
String json = "{\"description\": \"Input:PDF Output:PDF Type:SISO\"}";
|
||||
JsonNode postNode = mapper.readTree(json);
|
||||
ApiEndpoint endpoint = new ApiEndpoint("/rotate", postNode);
|
||||
setApiDocumentation(Map.of("/rotate", endpoint));
|
||||
setApiDocsJsonRootNode();
|
||||
assertFalse(apiDocService.shouldUnpackZipResponse("/rotate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUnpackZipResponseReturnsFalseForUnknownOperation() throws Exception {
|
||||
setApiDocumentation(Map.of());
|
||||
assertFalse(apiDocService.shouldUnpackZipResponse("/unknown"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Coverage test: every Stirling endpoint whose ZIP response is a transport for multiple typed
|
||||
* results (SIMO/MIMO or Output:ZIP-PDF / Output:IMAGE/ZIP etc.) must be classified as {@code
|
||||
* shouldUnpackZipResponse = true}. Descriptions below are the real
|
||||
* {@code @Operation(description=...)} strings from each controller, so if a controller is
|
||||
* renamed, tweaked or introduced without a {@code Type:} / {@code Output:ZIP-*} tag, this test
|
||||
* breaks, surfacing the bug before {@code AiWorkflowService} silently registers a multi-result
|
||||
* ZIP as a single file.
|
||||
*
|
||||
* <p>Add a new row here whenever a new unpack-eligible endpoint is introduced. Descriptions can
|
||||
* be trimmed to the part containing the relevant tags.
|
||||
*/
|
||||
@ParameterizedTest(name = "{0} → shouldUnpackZipResponse")
|
||||
@CsvSource(
|
||||
textBlock =
|
||||
"""
|
||||
/api/v1/general/split-pages, 'Split pages. Input:PDF Output:PDF Type:SIMO'
|
||||
/api/v1/general/split-pdf-by-sections, 'Split. Input:PDF Output:ZIP-PDF Type:SISO'
|
||||
/api/v1/general/split-by-size-or-count, 'Split by size. Input:PDF Output:ZIP-PDF Type:SISO'
|
||||
/api/v1/general/split-pdf-by-chapters, 'Split by chapters. Input:PDF Output:ZIP-PDF Type:SISO'
|
||||
/api/v1/general/split-for-poster-print, 'Poster split. Input: PDF Output: ZIP-PDF Type: SISO'
|
||||
/api/v1/general/overlay-pdfs, 'Overlay PDFs. Input:PDF Output:PDF Type:MIMO'
|
||||
/api/v1/misc/auto-split-pdf, 'Auto split. Input:PDF Output:ZIP-PDF Type:SISO'
|
||||
/api/v1/misc/extract-images, 'Extract images. Output:IMAGE/ZIP Type:SIMO'
|
||||
/api/v1/misc/extract-image-scans, 'Extract image scans. Input:PDF Output:IMAGE/ZIP Type:SIMO'
|
||||
""")
|
||||
void shouldUnpackZipResponseClassifiesKnownUnpackableEndpoints(
|
||||
String endpoint, String description) throws Exception {
|
||||
String json = mapper.writeValueAsString(Map.of("description", description));
|
||||
JsonNode postNode = mapper.readTree(json);
|
||||
setApiDocumentation(Map.of(endpoint, new ApiEndpoint(endpoint, postNode)));
|
||||
setApiDocsJsonRootNode();
|
||||
assertTrue(
|
||||
apiDocService.shouldUnpackZipResponse(endpoint),
|
||||
() ->
|
||||
"Expected shouldUnpackZipResponse=true for "
|
||||
+ endpoint
|
||||
+ " with description: "
|
||||
+ description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse coverage: endpoints whose ZIP response is the deliverable itself (or that return
|
||||
* single non-ZIP files) must not be flagged for unpacking. Catches regressions where a change
|
||||
* to the classifier accidentally widens the positive match.
|
||||
*/
|
||||
@ParameterizedTest(name = "{0} → !shouldUnpackZipResponse")
|
||||
@CsvSource(
|
||||
textBlock =
|
||||
"""
|
||||
/api/v1/general/rotate-pdf, 'Rotate. Input:PDF Output:PDF Type:SISO'
|
||||
/api/v1/general/merge-pdfs, 'Merge. Input:PDF Output:PDF Type:MISO'
|
||||
/api/v1/misc/compress-pdf, 'Compress. Input:PDF Output:PDF Type:SISO'
|
||||
/api/v1/misc/flatten, 'Flatten forms. Input:PDF Output:PDF Type:SISO'
|
||||
/api/v1/security/get-attachments, 'Extract attachments. Input:PDF Output:ZIP Type:SISO'
|
||||
""")
|
||||
void shouldUnpackZipResponseRejectsNonUnpackableEndpoints(String endpoint, String description)
|
||||
throws Exception {
|
||||
String json = mapper.writeValueAsString(Map.of("description", description));
|
||||
JsonNode postNode = mapper.readTree(json);
|
||||
setApiDocumentation(Map.of(endpoint, new ApiEndpoint(endpoint, postNode)));
|
||||
setApiDocsJsonRootNode();
|
||||
assertFalse(
|
||||
apiDocService.shouldUnpackZipResponse(endpoint),
|
||||
() ->
|
||||
"Expected shouldUnpackZipResponse=false for "
|
||||
+ endpoint
|
||||
+ " with description: "
|
||||
+ description);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorAcceptsNullUserService() {
|
||||
ApiDocService service = new ApiDocService(mapper, servletContext, null);
|
||||
|
||||
Reference in New Issue
Block a user