mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
create agent (#6520)
Added the create agent. Use [these prompts](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/main/docgen/backend/default_templates/sample_prompts.md) to test or try your own :) Here’s the one I use ``` Hey, I need to generate an employee expense report for reimbursement. Company: Summit Consulting Partners Company address: 88 Riverside Plaza, Suite 1400, New York, NY 10069 Accounting department email: [email protected] Employee details: * Employee Name: Michael Tran * Employee ID: EMP-1047 * Department: Client Services * Report Date: January 20th, 2026 * Reporting Period: January 5th, 2026 – January 16th, 2026 * Manager Approver: Laura Simmons Trip purpose: Client onsite meetings with Atlantic Energy Solutions in Boston, MA. Expense items: * Flight (NYC to Boston roundtrip) — $325.40 — January 5th, 2026 — Airline ticket * Hotel (3 nights at Harborview Hotel) — $822.75 — January 5th-8th, 2026 * Taxi from airport to hotel — $48.00 — January 5th, 2026 * Client dinner (3 attendees) — $186.20 — January 6th, 2026 * Parking at JFK Airport — $72.00 — January 5th-8th, 2026 * Breakfast (per diem not used) — $18.50 — January 7th, 2026 * Uber to client office — $22.10 — January 7th, 2026 * Printing + presentation materials — $46.90 — January 8th, 2026 * Lunch with client — $39.75 — January 8th, 2026 * Office supplies (notebooks, pens) — $27.60 — January 10th, 2026 * Mileage reimbursement (client visit in NJ, 42 miles @ $0.67/mile) — $28.14 — January 14th, 2026 * Team lunch meeting (internal) — $64.30 — January 15th, 2026 Reimbursement method should be direct deposit. Add a notes section stating: "All receipts attached. Expenses are business-related and comply with company travel policy." ``` --------- Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
parent
9b877d4f8d
commit
88adb7adad
@@ -97,6 +97,17 @@ public class InternalApiClient {
|
||||
headers.add("X-API-KEY", apiKey);
|
||||
}
|
||||
|
||||
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
|
||||
// without this RestTemplate would use urlencoded instead of the multipart the controller
|
||||
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
|
||||
boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/");
|
||||
boolean hasFilePart =
|
||||
body.values().stream()
|
||||
.flatMap(java.util.List::stream)
|
||||
.anyMatch(v -> v instanceof Resource);
|
||||
if (isAiTool && !hasFilePart) {
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
}
|
||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
|
||||
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
|
||||
*
|
||||
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
|
||||
* template so sanitization is intentionally skipped.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class CreatePdfAgentController {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
/**
|
||||
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
||||
* be launched at all, or it launched but immediately failed to load a required system library.
|
||||
* Other conversion failures (bad HTML, output errors, etc.) return false so they surface as
|
||||
* real errors rather than a misleading "dependency missing" message.
|
||||
*/
|
||||
private static boolean isMissingDependencyError(IOException e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg == null) return false;
|
||||
// OS could not start the process — binary not on PATH or not at the configured path.
|
||||
if (msg.contains("Cannot run program")) return true;
|
||||
// Process started but crashed immediately loading a shared library.
|
||||
// "cannot load library" — Python/cffi error (Linux and macOS via pip)
|
||||
// "Library not loaded" / "image not found" — macOS dyld error (Homebrew installs)
|
||||
String lower = msg.toLowerCase();
|
||||
if (lower.contains("cannot load library")) return true;
|
||||
if (lower.contains("library not loaded")) return true;
|
||||
if (lower.contains("image not found")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
value = "/create-pdf-from-html-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Convert AI-generated HTML to a PDF",
|
||||
description =
|
||||
"Accepts an HTML document as a plain-text parameter and returns a PDF."
|
||||
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
|
||||
+ " plan step; it is not intended for direct client use.")
|
||||
public ResponseEntity<Resource> createPdfFromHtml(
|
||||
@RequestParam("htmlContent") String htmlContent,
|
||||
@RequestParam("filename") String filename)
|
||||
throws Exception {
|
||||
|
||||
log.info(
|
||||
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
|
||||
htmlContent.length());
|
||||
|
||||
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
|
||||
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
|
||||
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getWeasyPrintPath());
|
||||
command.add("-e");
|
||||
command.add("utf-8");
|
||||
command.add("-v");
|
||||
// SSRF: the HTML is self-contained and the engine validates style colours, so no
|
||||
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
|
||||
command.add(htmlFile.getAbsolutePath());
|
||||
command.add(pdfFile.getAbsolutePath());
|
||||
|
||||
try {
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
} catch (IOException e) {
|
||||
if (isMissingDependencyError(e)) {
|
||||
throw new IOException(
|
||||
"AI document creation is not available on this server because a required"
|
||||
+ " system dependency is not installed. Please contact your"
|
||||
+ " system administrator.");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
String safeFilename = Filenames.toSimpleFileName(filename);
|
||||
if (safeFilename == null || safeFilename.isBlank() || !safeFilename.endsWith(".pdf")) {
|
||||
safeFilename = "generated-document.pdf";
|
||||
}
|
||||
|
||||
// Stamp the standard Stirling metadata onto the WeasyPrint output and write the result
|
||||
// straight to the response temp file. Loading from the file and saving to the file
|
||||
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
|
||||
// which matters for large generated documents.
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
document.save(tempOut.getPath().toFile());
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
}
|
||||
|
||||
log.info(
|
||||
"[create-pdf-agent] PDF ready — filename={} bytes={}",
|
||||
safeFilename,
|
||||
Files.size(tempOut.getPath()));
|
||||
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempOut, safeFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -17,6 +17,7 @@ import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
@@ -175,7 +176,6 @@ public class AiWorkflowService {
|
||||
initialRequest.setFiles(files);
|
||||
initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory()));
|
||||
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
|
||||
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
|
||||
|
||||
WorkflowState state = new WorkflowState.Pending(initialRequest);
|
||||
@@ -580,6 +580,10 @@ public class AiWorkflowService {
|
||||
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(toolTimeoutMessage(e.getEndpointPath(), e)));
|
||||
} catch (HttpServerErrorException e) {
|
||||
String reason = extractDetailFromHttpError(e);
|
||||
log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason);
|
||||
return new WorkflowState.Terminal(cannotContinue(reason));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -605,6 +609,27 @@ public class AiWorkflowService {
|
||||
return String.format("The %s tool failed: %s", endpointPath, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the {@code detail} field from an HTTP error response body if it is valid JSON,
|
||||
* otherwise falls back to the exception message. This lets controller-level error messages
|
||||
* (e.g. missing system dependency) surface cleanly in the chat response.
|
||||
*/
|
||||
private String extractDetailFromHttpError(HttpServerErrorException e) {
|
||||
try {
|
||||
String body = e.getResponseBodyAsString();
|
||||
if (body != null && !body.isBlank()) {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
JsonNode detail = node.get("detail");
|
||||
if (detail != null && detail.isTextual() && !detail.asText().isBlank()) {
|
||||
return detail.asText();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fall through to generic message
|
||||
}
|
||||
return "The request could not be completed. Please try again or contact your system administrator.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt the AI workflow's {@link ProgressListener} to the engine's {@link
|
||||
* PolicyProgressListener}: each step start maps to an {@code EXECUTING_TOOL} progress event
|
||||
|
||||
Reference in New Issue
Block a user