mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Add edit text support to stirling engine (#6245)
# Description of Changes Hooks up the (alpha) PDF Editor backend to the AI engine Edit Agent via an intermediary API which is easier for the agent to call. It suffers from all the same issues that the PDF Editor does in actually editing the text, but should also benefit from any fixes to that. It also adds protection against the underlying tools misbehaving by hanging, and fixes a hanging bug in the PDF Editor. --------- Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
co-authored by
EthanHealy01
parent
294b616a63
commit
575684ee4b
+41
-4
@@ -30,6 +30,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
@@ -323,10 +324,12 @@ public class AiWorkflowService {
|
||||
result.files(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("Tool {} timed out: {}", endpointPath, e.getMessage());
|
||||
return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e)));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("Tool execution failed: " + e.getMessage()));
|
||||
return new WorkflowState.Terminal(cannotContinue(toolFailureMessage(endpointPath, e)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +417,10 @@ public class AiWorkflowService {
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
summary, currentFiles, inputFileNames(filesById), lastReport));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(toolTimeoutMessage(e.getEndpointPath(), e)));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -425,6 +432,20 @@ public class AiWorkflowService {
|
||||
return filesById.values().stream().map(MultipartFile::getOriginalFilename).toList();
|
||||
}
|
||||
|
||||
private static String toolTimeoutMessage(String endpointPath, InternalApiTimeoutException e) {
|
||||
return String.format(
|
||||
"The %s tool did not respond within %d seconds and was aborted. The underlying"
|
||||
+ " operation may be hung; try again, run on a smaller file, or use a"
|
||||
+ " different approach.",
|
||||
endpointPath, e.getReadTimeout().toSeconds());
|
||||
}
|
||||
|
||||
private static String toolFailureMessage(String endpointPath, Throwable cause) {
|
||||
String reason =
|
||||
cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName();
|
||||
return String.format("The %s tool failed: %s", endpointPath, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
|
||||
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
|
||||
@@ -477,8 +498,15 @@ public class AiWorkflowService {
|
||||
}
|
||||
for (Map.Entry<String, Object> entry : parameters.entrySet()) {
|
||||
if (entry.getValue() instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
body.add(entry.getKey(), item);
|
||||
if (containsStructuredElements(list)) {
|
||||
// Endpoints binding lists of structured objects (e.g. /security/redact's
|
||||
// redactions, /general/edit-text's edits) parse a single JSON string field via
|
||||
// a property editor. Pre-serialize the whole list so binding succeeds.
|
||||
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
|
||||
} else {
|
||||
for (Object item : list) {
|
||||
body.add(entry.getKey(), item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body.add(entry.getKey(), entry.getValue());
|
||||
@@ -529,6 +557,15 @@ public class AiWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsStructuredElements(List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> || item instanceof List<?>) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Resource> toResources(Map<String, MultipartFile> filesById) throws IOException {
|
||||
List<Resource> resources = new ArrayList<>();
|
||||
for (MultipartFile file : filesById.values()) {
|
||||
|
||||
+155
@@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
@@ -30,6 +31,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
@@ -44,6 +46,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.FileStorage.StoredFile;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
@@ -251,6 +254,158 @@ class AiWorkflowServiceTest {
|
||||
verify(internalApiClient, times(1)).post(eq(COMPRESS_ENDPOINT), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parameterListOfStructuredObjectsIsJsonEncodedAsSingleField() throws IOException {
|
||||
// Endpoints like /general/edit-text and /security/redact bind a List<StructuredObject>
|
||||
// from a single JSON form field via a property editor. The plan executor must
|
||||
// pre-serialize such lists rather than splitting them into repeated form fields.
|
||||
String editTextEndpoint = "/api/v1/general/edit-text";
|
||||
MockMultipartFile input = pdf("input.pdf", "bytes");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{
|
||||
"outcome":"plan",
|
||||
"summary":"Find and replace",
|
||||
"steps":[
|
||||
{"tool":"%s","parameters":{
|
||||
"edits":[{"find":"foo","replace":"bar"},{"find":"baz","replace":"qux"}],
|
||||
"useRegex":false
|
||||
}}
|
||||
]
|
||||
}
|
||||
"""
|
||||
.formatted(editTextEndpoint));
|
||||
when(toolMetadataService.isMultiInput(editTextEndpoint)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(editTextEndpoint)).thenReturn(false);
|
||||
stubEndpoint(editTextEndpoint, pdfResource("edited", "edited.pdf"));
|
||||
stubFileStorage();
|
||||
|
||||
service.orchestrate(requestFor(input, "find and replace"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
|
||||
ArgumentCaptor.forClass(MultiValueMap.class);
|
||||
verify(internalApiClient).post(eq(editTextEndpoint), bodyCaptor.capture());
|
||||
MultiValueMap<String, Object> body = bodyCaptor.getValue();
|
||||
|
||||
// The structured-list field must be serialized as ONE JSON-string entry, not multiple
|
||||
// entries.
|
||||
List<Object> editsValues = body.get("edits");
|
||||
assertNotNull(editsValues);
|
||||
assertEquals(1, editsValues.size());
|
||||
assertEquals(
|
||||
"[{\"find\":\"foo\",\"replace\":\"bar\"},{\"find\":\"baz\",\"replace\":\"qux\"}]",
|
||||
editsValues.get(0));
|
||||
|
||||
// Primitive fields keep the original behavior (single value, not JSON-wrapped).
|
||||
List<Object> useRegex = body.get("useRegex");
|
||||
assertNotNull(useRegex);
|
||||
assertEquals(1, useRegex.size());
|
||||
assertEquals(false, useRegex.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parameterListOfPrimitivesIsSentAsRepeatedFormFields() throws IOException {
|
||||
// Endpoints like /misc/ocr-pdf bind List<String> via Spring's repeated-form-field
|
||||
// convention. The executor must preserve that behavior for primitive lists.
|
||||
String ocrEndpoint = "/api/v1/misc/ocr-pdf";
|
||||
MockMultipartFile input = pdf("input.pdf", "bytes");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{
|
||||
"outcome":"plan",
|
||||
"summary":"OCR the document",
|
||||
"steps":[
|
||||
{"tool":"%s","parameters":{"languages":["eng","fra","deu"]}}
|
||||
]
|
||||
}
|
||||
"""
|
||||
.formatted(ocrEndpoint));
|
||||
when(toolMetadataService.isMultiInput(ocrEndpoint)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(ocrEndpoint)).thenReturn(false);
|
||||
stubEndpoint(ocrEndpoint, pdfResource("ocred", "ocred.pdf"));
|
||||
stubFileStorage();
|
||||
|
||||
service.orchestrate(requestFor(input, "ocr"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
|
||||
ArgumentCaptor.forClass(MultiValueMap.class);
|
||||
verify(internalApiClient).post(eq(ocrEndpoint), bodyCaptor.capture());
|
||||
List<Object> languages = bodyCaptor.getValue().get("languages");
|
||||
assertNotNull(languages);
|
||||
assertEquals(List.of("eng", "fra", "deu"), languages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolTimeoutSurfacesAsCannotContinueWithFriendlyMessage() throws IOException {
|
||||
// When an internal tool hangs, InternalApiClient throws InternalApiTimeoutException after
|
||||
// its read timeout fires. The workflow must convert that into a clean CANNOT_CONTINUE
|
||||
// outcome so the user sees an actionable message rather than the request hanging forever.
|
||||
MockMultipartFile input = pdf("input.pdf", "bytes");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{"outcome":"tool_call","tool":"%s","parameters":{"angle":90},"rationale":"Rotating"}
|
||||
"""
|
||||
.formatted(ROTATE_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(ROTATE_ENDPOINT)).thenReturn(false);
|
||||
when(internalApiClient.post(eq(ROTATE_ENDPOINT), any()))
|
||||
.thenThrow(
|
||||
new InternalApiTimeoutException(
|
||||
ROTATE_ENDPOINT,
|
||||
java.time.Duration.ofSeconds(300),
|
||||
new java.io.IOException("Read timed out")));
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "rotate"));
|
||||
|
||||
assertEquals(AiWorkflowOutcome.CANNOT_CONTINUE, result.getOutcome());
|
||||
assertNotNull(result.getReason());
|
||||
assertTrue(
|
||||
result.getReason().contains(ROTATE_ENDPOINT),
|
||||
"reason should mention the failing tool path: " + result.getReason());
|
||||
assertTrue(
|
||||
result.getReason().contains("300"),
|
||||
"reason should mention the timeout duration: " + result.getReason());
|
||||
verify(internalApiClient, times(1)).post(eq(ROTATE_ENDPOINT), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void planTimeoutOnLaterStepStillSurfacesCleanly() throws IOException {
|
||||
// Multi-step plans must also handle a hung tool gracefully (no partial leaks) and the
|
||||
// failure message must identify which step failed so the user can iterate.
|
||||
MockMultipartFile input = pdf("input.pdf", "bytes");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{
|
||||
"outcome":"plan",
|
||||
"summary":"Rotate then compress",
|
||||
"steps":[
|
||||
{"tool":"%s","parameters":{"angle":90}},
|
||||
{"tool":"%s","parameters":{}}
|
||||
]
|
||||
}
|
||||
"""
|
||||
.formatted(ROTATE_ENDPOINT, COMPRESS_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
stubEndpoint(ROTATE_ENDPOINT, pdfResource("rotated", "rotated.pdf"));
|
||||
// No fileStorage stub here: the second step times out before any output reaches storage.
|
||||
when(internalApiClient.post(eq(COMPRESS_ENDPOINT), any()))
|
||||
.thenThrow(
|
||||
new InternalApiTimeoutException(
|
||||
COMPRESS_ENDPOINT,
|
||||
java.time.Duration.ofSeconds(300),
|
||||
new java.io.IOException("Read timed out")));
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "rotate then compress"));
|
||||
|
||||
assertEquals(AiWorkflowOutcome.CANNOT_CONTINUE, result.getOutcome());
|
||||
assertTrue(result.getReason().contains(COMPRESS_ENDPOINT));
|
||||
// The first step actually executed; the failure happened on the second.
|
||||
verify(internalApiClient, times(1)).post(eq(ROTATE_ENDPOINT), any());
|
||||
verify(internalApiClient, times(1)).post(eq(COMPRESS_ENDPOINT), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolCallWithoutEndpointFallsBackToCannotContinue() throws IOException {
|
||||
MockMultipartFile input = pdf("input.pdf", "bytes");
|
||||
|
||||
Reference in New Issue
Block a user