Skip to content

Commit b130242

Browse files
authored
Add Java orchestrator to connect to the AI engine (Stirling-Tools#6003)
# Description of Changes Add Java orchestration layer which can connect and go back and forth with the AI engine to get results for the user. It's expected that the AI engine will not be publicly available and this Java layer will always be in front of it, to manage sessions and auth etc.
1 parent fbae819 commit b130242

28 files changed

Lines changed: 1222 additions & 76 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ venv.bak/
181181
.idea/
182182
*.iml
183183
out/
184+
.junie/
184185

185186
# Ignore Mac DS_Store files
186187
.DS_Store

app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public class ApplicationProperties {
7575
private AutoPipeline autoPipeline = new AutoPipeline();
7676
private ProcessExecutor processExecutor = new ProcessExecutor();
7777
private PdfEditor pdfEditor = new PdfEditor();
78+
private AiEngine aiEngine = new AiEngine();
7879

7980
@Bean
8081
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -231,6 +232,13 @@ public static class Library {
231232
}
232233
}
233234

235+
@Data
236+
public static class AiEngine {
237+
private boolean enabled = false;
238+
private String url = "http://localhost:5001";
239+
private int timeoutSeconds = 120;
240+
}
241+
234242
@Data
235243
public static class Legal {
236244
private String termsAndConditions;

app/core/src/main/resources/settings.yml.template

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,11 @@ processExecutor:
325325
ghostscriptTimeoutMinutes: 30
326326
ocrMyPdfTimeoutMinutes: 30
327327

328+
aiEngine:
329+
enabled: false # Set to 'true' to enable the AI engine integration
330+
url: http://localhost:5001 # URL of the Python AI engine
331+
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
332+
328333
pdfEditor:
329334
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
330335
cache:
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package stirling.software.proprietary.controller.api;
2+
3+
import java.io.IOException;
4+
5+
import org.springframework.http.HttpStatus;
6+
import org.springframework.http.MediaType;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.ModelAttribute;
10+
import org.springframework.web.bind.annotation.PostMapping;
11+
import org.springframework.web.bind.annotation.RequestBody;
12+
import org.springframework.web.bind.annotation.RequestMapping;
13+
import org.springframework.web.bind.annotation.RestController;
14+
import org.springframework.web.server.ResponseStatusException;
15+
16+
import io.swagger.v3.oas.annotations.Operation;
17+
import io.swagger.v3.oas.annotations.tags.Tag;
18+
19+
import jakarta.validation.Valid;
20+
21+
import lombok.RequiredArgsConstructor;
22+
import lombok.extern.slf4j.Slf4j;
23+
24+
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
25+
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
26+
import stirling.software.proprietary.service.AiEngineClient;
27+
import stirling.software.proprietary.service.AiWorkflowService;
28+
29+
import tools.jackson.core.JacksonException;
30+
import tools.jackson.databind.JsonNode;
31+
import tools.jackson.databind.ObjectMapper;
32+
33+
@Slf4j
34+
@RestController
35+
@RequestMapping("/api/v1/ai")
36+
@RequiredArgsConstructor
37+
@Tag(name = "AI Engine", description = "Endpoints for AI-powered PDF workflows")
38+
public class AiEngineController {
39+
40+
private final AiEngineClient aiEngineClient;
41+
private final AiWorkflowService aiWorkflowService;
42+
private final ObjectMapper objectMapper;
43+
44+
@GetMapping("/health")
45+
@Operation(
46+
summary = "AI engine health check",
47+
description = "Returns the health status of the AI engine including configured models")
48+
public ResponseEntity<String> health() throws IOException {
49+
String response = aiEngineClient.get("/health");
50+
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
51+
}
52+
53+
@PostMapping(value = "/orchestrate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
54+
@Operation(
55+
summary = "Run an AI workflow against a PDF",
56+
description =
57+
"Accepts a PDF upload and a user message and returns an AI workflow result")
58+
public ResponseEntity<AiWorkflowResponse> orchestrate(
59+
@Valid @ModelAttribute AiWorkflowRequest request) throws IOException {
60+
return ResponseEntity.ok(aiWorkflowService.orchestrate(request));
61+
}
62+
63+
@PostMapping(value = "/pdf/edit", consumes = MediaType.APPLICATION_JSON_VALUE)
64+
@Operation(
65+
summary = "Generate a PDF edit plan",
66+
description =
67+
"Sends a user message to the PDF edit agent which returns a structured plan"
68+
+ " of tool operations to perform")
69+
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
70+
validateJson(requestBody);
71+
String response = aiEngineClient.post("/api/v1/pdf/edit", requestBody);
72+
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
73+
}
74+
75+
private void validateJson(String body) {
76+
try {
77+
objectMapper.readValue(body, JsonNode.class);
78+
} catch (JacksonException e) {
79+
throw new ResponseStatusException(
80+
HttpStatus.BAD_REQUEST, "Request body is not valid JSON");
81+
}
82+
}
83+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package stirling.software.proprietary.model.api.ai;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonValue;
5+
6+
/**
7+
* Types of content that can be extracted from a PDF and sent to the AI.
8+
*
9+
* <p>Values MUST match {@code PdfContentType} in {@code engine/src/stirling/contracts/common.py}.
10+
*/
11+
public enum AiPdfContentType {
12+
// Document-level structured data
13+
PAGE_LAYOUT("page_layout"),
14+
DOCUMENT_METADATA("document_metadata"),
15+
ENCRYPTION_INFO("encryption_info"),
16+
BOOKMARKS("bookmarks"),
17+
LAYERS("layers"),
18+
EMBEDDED_FILES("embedded_files"),
19+
JAVASCRIPT("javascript"),
20+
LINKS("links"),
21+
IMAGE_INFO("image_info"),
22+
FONTS("fonts"),
23+
24+
// Text and content
25+
PAGE_TEXT("page_text"),
26+
FULL_TEXT("full_text"),
27+
FORM_FIELDS("form_fields"),
28+
ANNOTATIONS("annotations"),
29+
SIGNATURES("signatures"),
30+
STRUCTURE_TREE("structure_tree"),
31+
XMP_METADATA("xmp_metadata"),
32+
33+
// Heavy content
34+
COMPLIANCE("compliance"),
35+
IMAGES("images");
36+
37+
private final String value;
38+
39+
AiPdfContentType(String value) {
40+
this.value = value;
41+
}
42+
43+
@JsonValue
44+
public String getValue() {
45+
return value;
46+
}
47+
48+
@JsonCreator
49+
public static AiPdfContentType fromValue(String value) {
50+
for (AiPdfContentType type : values()) {
51+
if (type.value.equals(value)) {
52+
return type;
53+
}
54+
}
55+
throw new IllegalArgumentException("Unknown PDF content type: " + value);
56+
}
57+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package stirling.software.proprietary.model.api.ai;
2+
3+
import org.springframework.http.MediaType;
4+
import org.springframework.web.multipart.MultipartFile;
5+
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
8+
import jakarta.validation.constraints.NotNull;
9+
10+
import lombok.Data;
11+
12+
@Data
13+
@Schema(description = "A single PDF file input")
14+
public class AiWorkflowFileInput {
15+
16+
@NotNull
17+
@Schema(
18+
description = "The input PDF file",
19+
contentMediaType = MediaType.APPLICATION_PDF_VALUE,
20+
format = "binary")
21+
private MultipartFile fileInput;
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package stirling.software.proprietary.model.api.ai;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
8+
import lombok.Data;
9+
10+
@Data
11+
@Schema(description = "Per-file content extraction request from the AI engine")
12+
public class AiWorkflowFileRequest {
13+
14+
@Schema(description = "Original filename of the requested file", example = "contract.pdf")
15+
private String fileName;
16+
17+
@Schema(description = "Specific 1-based page numbers to extract from this file")
18+
private List<Integer> pageNumbers = new ArrayList<>();
19+
20+
@Schema(description = "Content types to extract from this file")
21+
private List<AiPdfContentType> contentTypes = new ArrayList<>();
22+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package stirling.software.proprietary.model.api.ai;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonValue;
5+
6+
/**
7+
* Discriminator values for AI workflow responses.
8+
*
9+
* <p>Values MUST match {@code WorkflowOutcome} in {@code engine/src/stirling/contracts/common.py}.
10+
*/
11+
public enum AiWorkflowOutcome {
12+
ANSWER("answer"),
13+
NOT_FOUND("not_found"),
14+
NEED_CONTENT("need_content"),
15+
PLAN("plan"),
16+
NEED_CLARIFICATION("need_clarification"),
17+
CANNOT_DO("cannot_do"),
18+
TOOL_CALL("tool_call"),
19+
COMPLETED("completed"),
20+
UNSUPPORTED_CAPABILITY("unsupported_capability"),
21+
CANNOT_CONTINUE("cannot_continue");
22+
23+
private final String value;
24+
25+
AiWorkflowOutcome(String value) {
26+
this.value = value;
27+
}
28+
29+
@JsonValue
30+
public String getValue() {
31+
return value;
32+
}
33+
34+
@JsonCreator
35+
public static AiWorkflowOutcome fromValue(String value) {
36+
for (AiWorkflowOutcome outcome : values()) {
37+
if (outcome.value.equals(value)) {
38+
return outcome;
39+
}
40+
}
41+
throw new IllegalArgumentException("Unknown AI workflow outcome: " + value);
42+
}
43+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package stirling.software.proprietary.model.api.ai;
2+
3+
import java.util.List;
4+
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
7+
import jakarta.validation.constraints.NotBlank;
8+
import jakarta.validation.constraints.NotNull;
9+
10+
import lombok.Data;
11+
12+
@Data
13+
@Schema(description = "Run an AI workflow against one or more PDF files")
14+
public class AiWorkflowRequest {
15+
16+
@NotNull
17+
@Schema(description = "The input PDF files")
18+
private List<AiWorkflowFileInput> fileInputs;
19+
20+
@NotBlank
21+
@Schema(description = "The user message to orchestrate", example = "Summarise these documents")
22+
private String userMessage;
23+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package stirling.software.proprietary.model.api.ai;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.Map;
6+
7+
import io.swagger.v3.oas.annotations.media.Schema;
8+
9+
import lombok.Data;
10+
11+
@Data
12+
@Schema(description = "Structured AI workflow result")
13+
public class AiWorkflowResponse {
14+
15+
@Schema(description = "Workflow outcome")
16+
private AiWorkflowOutcome outcome;
17+
18+
@Schema(description = "Answer returned by the AI workflow when applicable")
19+
private String answer;
20+
21+
@Schema(description = "Summary returned by the AI workflow when applicable")
22+
private String summary;
23+
24+
@Schema(description = "Rationale returned by the AI workflow when applicable")
25+
private String rationale;
26+
27+
@Schema(description = "Reason when the AI workflow cannot proceed")
28+
private String reason;
29+
30+
@Schema(description = "Clarification question for the user when more input is required")
31+
private String question;
32+
33+
@Schema(
34+
description =
35+
"Unsupported capability identifier when the workflow cannot route the request")
36+
private String capability;
37+
38+
@Schema(description = "Message returned for unsupported capability outcomes")
39+
private String message;
40+
41+
@Schema(description = "Supporting evidence snippets from extracted PDF text")
42+
private List<AiWorkflowTextSelection> evidence = new ArrayList<>();
43+
44+
@Schema(description = "Structured tool steps when the workflow returns a plan")
45+
private List<Map<String, Object>> steps = new ArrayList<>();
46+
47+
@Schema(description = "Per-file text extraction requests from the AI engine")
48+
private List<AiWorkflowFileRequest> files = new ArrayList<>();
49+
50+
@Schema(description = "Maximum number of pages the AI engine wants text extracted from")
51+
private Integer maxPages;
52+
53+
@Schema(description = "Maximum number of characters the AI engine wants extracted")
54+
private Integer maxCharacters;
55+
56+
@Schema(description = "AI engine capability to resume with on the next turn")
57+
private String resumeWith;
58+
}

0 commit comments

Comments
 (0)