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:
James Brunton
2026-04-20 15:57:11 +01:00
committed by GitHub
co-authored by Anthony Stirling EthanHealy01
parent cc9650e7a3
commit e5767ed58b
45 changed files with 3565 additions and 1285 deletions
@@ -124,7 +124,9 @@ public class SplitPdfByChaptersController {
@MultiFileResponse
@Operation(
summary = "Split PDFs by Chapters",
description = "Splits a PDF into chapters and returns a ZIP file.")
description =
"Splits a PDF into chapters and returns a ZIP file. Input:PDF Output:ZIP-PDF"
+ " Type:SISO")
public ResponseEntity<StreamingResponseBody> splitPdf(
@ModelAttribute SplitPdfByChaptersRequest request) throws Exception {
MultipartFile file = request.getFileInput();
@@ -11,36 +11,26 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.github.pixee.security.ZipSecurity;
import jakarta.servlet.ServletContext;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.SPDFApplication;
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.model.enumeration.Role;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFile;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.ZipExtractionUtils;
@Service
@Slf4j
@@ -48,20 +38,16 @@ public class PipelineProcessor {
private final ApiDocService apiDocService;
private final UserServiceInterface userService;
private final ServletContext servletContext;
private final InternalApiClient internalApiClient;
private final TempFileManager tempFileManager;
public PipelineProcessor(
ApiDocService apiDocService,
@Autowired(required = false) UserServiceInterface userService,
ServletContext servletContext,
InternalApiClient internalApiClient,
TempFileManager tempFileManager) {
this.apiDocService = apiDocService;
this.userService = userService;
this.servletContext = servletContext;
this.internalApiClient = internalApiClient;
this.tempFileManager = tempFileManager;
}
@@ -84,48 +70,6 @@ public class PipelineProcessor {
return name.substring(0, underscoreIndex) + extension;
}
// Allowlist of URL path prefixes permitted through the pipeline.
private static final List<String> ALLOWED_PIPELINE_PATH_PREFIXES =
List.of(
"/api/v1/general/",
"/api/v1/misc/",
"/api/v1/security/",
"/api/v1/convert/",
"/api/v1/filter/");
private void validatePipelineUrl(String url) {
// Strip scheme+host to get the path portion for comparison
String path = url;
int schemeEnd = url.indexOf("://");
if (schemeEnd != -1) {
int pathStart = url.indexOf('/', schemeEnd + 3);
path = pathStart != -1 ? url.substring(pathStart) : "/";
}
final String pathToCheck = path;
boolean allowed = ALLOWED_PIPELINE_PATH_PREFIXES.stream().anyMatch(pathToCheck::contains);
if (!allowed) {
log.warn("Blocked pipeline request to disallowed URL: {}", url);
throw new SecurityException(
"Pipeline operation not permitted for endpoint: " + pathToCheck);
}
}
private String getApiKeyForUser() {
if (userService == null) return "";
String username = userService.getCurrentUsername();
if (username != null && !username.equals("anonymousUser")) {
return userService.getApiKeyForUser(username);
}
// Scheduled/internal context — no user in security context
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
}
private String getBaseUrl() {
String contextPath = servletContext.getContextPath();
String port = SPDFApplication.getStaticPort();
return "http://localhost:" + port + contextPath + "/";
}
PipelineResult runPipelineAgainstFiles(List<Resource> outputFiles, PipelineConfig config)
throws Exception {
PipelineResult result = new PipelineResult();
@@ -153,7 +97,6 @@ public class PipelineProcessor {
"Invalid operation: " + operation + " with parameters: " + parameters);
}
String url = getBaseUrl() + operation;
List<Resource> newOutputFiles = new ArrayList<>();
if (!isMultiInputOperation) {
for (Resource file : outputFiles) {
@@ -175,12 +118,15 @@ public class PipelineProcessor {
body.add(entry.getKey(), entry.getValue());
}
}
ResponseEntity<Resource> response = sendWebRequest(url, body);
ResponseEntity<Resource> response =
internalApiClient.post(operation, body);
// If the operation is filter and the response body is null or empty,
// skip
// this
// file
if (response.getBody() instanceof TempFileResource tempFileResource) {
if (response.getBody()
instanceof
InternalApiClient.TempFileResource tempFileResource) {
result.addTempFile(tempFileResource.getTempFile());
}
@@ -257,8 +203,9 @@ public class PipelineProcessor {
body.add(entry.getKey(), entry.getValue());
}
}
ResponseEntity<Resource> response = sendWebRequest(url, body);
if (response.getBody() instanceof TempFileResource tempFileResource) {
ResponseEntity<Resource> response = internalApiClient.post(operation, body);
if (response.getBody()
instanceof InternalApiClient.TempFileResource tempFileResource) {
result.addTempFile(tempFileResource.getTempFile());
}
// Handle the response
@@ -312,43 +259,6 @@ public class PipelineProcessor {
return result;
}
/* package */ ResponseEntity<Resource> sendWebRequest(
String url, MultiValueMap<String, Object> body) {
validatePipelineUrl(url);
RestTemplate restTemplate = new RestTemplate();
// Set up headers, including API key
HttpHeaders headers = new HttpHeaders();
String apiKey = getApiKeyForUser();
if (apiKey != null && !apiKey.isEmpty()) {
headers.add("X-API-KEY", apiKey);
}
// Let the message converter set the multipart boundary/content type
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback =
restTemplate.httpEntityCallback(entity, Resource.class /* response type hint */);
return restTemplate.execute(
url,
HttpMethod.POST,
requestCallback,
response -> {
try {
TempFile tempFile = tempFileManager.createManagedTempFile("pipeline");
Files.copy(
response.getBody(),
tempFile.getPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
TempFileResource resource = new TempFileResource(tempFile);
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.body(resource);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
private List<Resource> processOutputFiles(
String operation,
ResponseEntity<Resource> response,
@@ -367,13 +277,15 @@ public class PipelineProcessor {
newFilename = removeTrailingNaming(extractFilename(response));
}
// Check if the response body is a zip file
if (isZip(response.getBody(), newFilename)) {
if (ZipExtractionUtils.isZip(response.getBody(), newFilename)) {
// Unzip the file and add all the files to the new output files
newOutputFiles.addAll(unzip(response.getBody(), result));
newOutputFiles.addAll(
ZipExtractionUtils.extractZip(
response.getBody(), tempFileManager, result::addTempFile));
} else {
final Resource tempResource = response.getBody();
if (tempResource instanceof TempFileResource) {
result.addTempFile(((TempFileResource) tempResource).getTempFile());
if (tempResource instanceof InternalApiClient.TempFileResource tfr) {
result.addTempFile(tfr.getTempFile());
}
Resource outputResource =
new FileSystemResource(tempResource.getFile()) {
@@ -456,97 +368,4 @@ public class PipelineProcessor {
log.info("Files successfully loaded. Starting processing...");
return outputFiles;
}
private boolean isZip(Resource data, String filename) throws IOException {
if (data == null || data.contentLength() < 4) {
return false;
}
if (filename != null) {
String lower = filename.toLowerCase();
if (lower.endsWith(".cbz")) {
// Treat CBZ as non-zip for our unzipping purposes
return false;
}
}
// Check the first four bytes of the data against the standard zip magic number
try (InputStream is = data.getInputStream()) {
byte[] header = new byte[4];
if (is.read(header) < 4) {
return false;
}
return header[0] == 0x50 && header[1] == 0x4B && header[2] == 0x03 && header[3] == 0x04;
}
}
private boolean isZip(Resource data) throws IOException {
return isZip(data, null);
}
private static final int MAX_UNZIP_DEPTH = 10;
private List<Resource> unzip(Resource data, PipelineResult result) throws IOException {
return unzip(data, result, 0);
}
private List<Resource> unzip(Resource data, PipelineResult result, int depth)
throws IOException {
if (depth > MAX_UNZIP_DEPTH) {
log.warn(
"ZIP nesting depth {} exceeds limit {}, treating as file",
depth,
MAX_UNZIP_DEPTH);
return List.of(data);
}
log.info("Unzipping data of length: {}", data.contentLength());
List<Resource> unzippedFiles = new ArrayList<>();
try (InputStream bais = data.getInputStream();
ZipInputStream zis = ZipSecurity.createHardenedInputStream(bais)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
TempFile tempFile = tempFileManager.createManagedTempFile("unzip");
result.addTempFile(tempFile);
try (OutputStream os = Files.newOutputStream(tempFile.getPath())) {
byte[] buffer = new byte[4096];
int count;
while ((count = zis.read(buffer)) != -1) {
os.write(buffer, 0, count);
}
}
final String filename = entry.getName();
Resource fileResource =
new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return filename;
}
};
// If the unzipped file is a zip file, unzip it
if (isZip(fileResource, filename)) {
log.info("File {} is a zip file. Unzipping...", filename);
unzippedFiles.addAll(unzip(fileResource, result, depth + 1));
} else {
unzippedFiles.add(fileResource);
}
}
}
log.info("Unzipping completed. {} files were unzipped.", unzippedFiles.size());
return unzippedFiles;
}
private static class TempFileResource extends FileSystemResource {
private final TempFile tempFile;
public TempFileResource(TempFile tempFile) {
super(tempFile.getFile());
this.tempFile = tempFile;
}
public TempFile getTempFile() {
return tempFile;
}
}
}
@@ -13,7 +13,8 @@ public class RotatePDFRequest extends PDFFile {
@Schema(
description =
"The angle by which to rotate the PDF file. This should be a multiple of 90.",
"The clockwise angle by which to rotate the PDF file. Must be a multiple of"
+ " 90.",
type = "integer",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"0", "90", "180", "270"})
@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
@@ -30,7 +31,14 @@ import tools.jackson.databind.ObjectMapper;
@Service
@Slf4j
public class ApiDocService {
public class ApiDocService implements stirling.software.common.service.ToolMetadataService {
// Matches a bare "Output:ZIP" declaration (i.e. ZIP is not followed by "-" or "/").
// Bare ZIP means the archive itself is the deliverable (e.g. get-attachments), so it
// should not be auto-unpacked. Wrapper forms like Output:ZIP-PDF or Output:IMAGE/ZIP
// use ZIP as transport for multiple typed results and are safe to unpack.
private static final Pattern BARE_ZIP_OUTPUT =
Pattern.compile("Output\\s*:\\s*ZIP(?![-/])", Pattern.CASE_INSENSITIVE);
private final Map<String, ApiEndpoint> apiDocumentation = new HashMap<>();
@@ -149,6 +157,7 @@ public class ApiDocService {
return endpoint.areParametersValid(parameters);
}
@Override
public boolean isMultiInput(String operationName) {
if (apiDocsJsonRootNode == null || apiDocumentation.isEmpty()) {
loadApiDocumentation();
@@ -166,5 +175,36 @@ public class ApiDocService {
}
return false;
}
@Override
public boolean shouldUnpackZipResponse(String operationName) {
if (apiDocsJsonRootNode == null || apiDocumentation.isEmpty()) {
loadApiDocumentation();
}
if (!apiDocumentation.containsKey(operationName)) {
return false;
}
ApiEndpoint endpoint = apiDocumentation.get(operationName);
String description = endpoint.getDescription();
Matcher typeMatcher =
RegexPatternUtils.getInstance().getApiDocTypePattern().matcher(description);
if (typeMatcher.find()) {
String type = typeMatcher.group(1);
// Multi-output endpoints (SIMO/MIMO) return a ZIP of their outputs.
if (type.endsWith("MO")) {
return true;
}
}
Matcher outputMatcher =
RegexPatternUtils.getInstance().getApiDocOutputTypePattern().matcher(description);
if (outputMatcher.find()) {
String output = outputMatcher.group(1).toUpperCase(Locale.ROOT);
if (output.startsWith("ZIP")) {
// Bare "Output:ZIP" is a single-archive deliverable, not a transport.
return !BARE_ZIP_OUTPUT.matcher(description).find();
}
}
return false;
}
}
// Model class for API Endpoint