diff --git a/.github/scripts/check_language_toml.py b/.github/scripts/check_language_toml.py index b931bac52..638951693 100644 --- a/.github/scripts/check_language_toml.py +++ b/.github/scripts/check_language_toml.py @@ -13,7 +13,7 @@ Usage: """ # Sample for Windows: -# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-GB/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml +# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml import argparse import glob @@ -211,7 +211,7 @@ def check_for_differences(reference_file, file_list, branch, actor): ) continue - if basename_current_file == basename_reference_file and locale_dir == "en-GB": + if basename_current_file == basename_reference_file and locale_dir == "en-US": continue if ( @@ -308,7 +308,7 @@ def check_for_differences(reference_file, file_list, branch, actor): report.append("## ❌ Overall Check Status: **_Failed_**") report.append("") report.append( - f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-GB/translation.toml)" + f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-US/translation.toml)" ) else: report.append("## ✅ Overall Check Status: **_Success_**") diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 71071262c..aca0c4f83 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -293,7 +293,7 @@ jobs: SECURITY_ENABLELOGIN: "true" SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}" SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}" - SYSTEM_DEFAULTLOCALE: en-GB + SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}" UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Embedded Architecture" UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}" diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index b55650ec3..4e72e57bf 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -388,7 +388,7 @@ jobs: environment: DISABLE_ADDITIONAL_FEATURES: "${DISABLE_ADDITIONAL_FEATURES}" SECURITY_ENABLELOGIN: "${LOGIN_SECURITY}" - SYSTEM_DEFAULTLOCALE: en-GB + SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: "Stirling-PDF PR#${PR_NUMBER}" UI_HOMEDESCRIPTION: "PR#${PR_NUMBER} for Stirling-PDF Latest" UI_APPNAMENAVBAR: "PR#${PR_NUMBER}" diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index 7e3186361..b7a277f87 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -166,16 +166,16 @@ jobs: // Determine reference file let referenceFilePath; - if (changedFiles.includes("frontend/editor/public/locales/en-GB/translation.toml")) { + if (changedFiles.includes("frontend/editor/public/locales/en-US/translation.toml")) { console.log("Using PR branch reference file."); const { data: fileContent } = await github.rest.repos.getContent({ owner: prRepoOwner, repo: prRepoName, - path: "frontend/editor/public/locales/en-GB/translation.toml", + path: "frontend/editor/public/locales/en-US/translation.toml", ref: branch, }); - referenceFilePath = "pr-branch-translation-en-GB.toml"; + referenceFilePath = "pr-branch-translation-en-US.toml"; const content = Buffer.from(fileContent.content, "base64").toString("utf-8"); fs.writeFileSync(referenceFilePath, content); } else { @@ -183,11 +183,11 @@ jobs: const { data: fileContent } = await github.rest.repos.getContent({ owner: repoOwner, repo: repoName, - path: "frontend/editor/public/locales/en-GB/translation.toml", + path: "frontend/editor/public/locales/en-US/translation.toml", ref: "main", }); - referenceFilePath = "main-branch-translation-en-GB.toml"; + referenceFilePath = "main-branch-translation-en-US.toml"; const content = Buffer.from(fileContent.content, "base64").toString("utf-8"); fs.writeFileSync(referenceFilePath, content); } @@ -293,6 +293,6 @@ jobs: run: | echo "Cleaning up temporary files..." rm -rf pr-branch - rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt + rm -f pr-branch-translation-en-US.toml main-branch-translation-en-US.toml changed_files.txt result.txt echo "Cleanup complete." continue-on-error: true # Ensure cleanup runs even if previous steps fail diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index e7af240c3..f94ded653 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -188,7 +188,7 @@ jobs: environment: DISABLE_ADDITIONAL_FEATURES: "true" SECURITY_ENABLELOGIN: "false" - SYSTEM_DEFAULTLOCALE: en-GB + SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: "Stirling-PDF V2" UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split" UI_APPNAMENAVBAR: "V2 Deployment" diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 4b93c27d4..b91884c82 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -62,7 +62,7 @@ jobs: - name: Sync translation TOML files run: | - python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-GB/translation.toml" --branch main + python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main - name: pre-commit run run: | @@ -100,7 +100,7 @@ jobs: This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made: #### **1. Synchronization of Translation Files** - - Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`. + - Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`. - Ensured consistency and synchronization across all supported language files. - Highlighted any missing or incomplete translations. - **Format**: TOML diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index 9a395120a..350d6c077 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -129,7 +129,7 @@ jobs: environment: DISABLE_ADDITIONAL_FEATURES: "true" SECURITY_ENABLELOGIN: "false" - SYSTEM_DEFAULTLOCALE: en-GB + SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: "Stirling-PDF Test" UI_HOMEDESCRIPTION: "Test Deployment" UI_APPNAMENAVBAR: "Test" diff --git a/ADDING_TOOLS.md b/ADDING_TOOLS.md index 579a9647a..9c22246a7 100644 --- a/ADDING_TOOLS.md +++ b/ADDING_TOOLS.md @@ -200,9 +200,9 @@ const [ToolName] = (props: BaseToolProps) => { ``` ## 5. Add Translations -Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately. +Update translation files. **Important: Only update `en-US` files** - other languages are handled separately. -**File to update:** `frontend/editor/public/locales/en-GB/translation.toml` +**File to update:** `frontend/editor/public/locales/en-US/translation.toml` **Required Translation Keys**: ```toml @@ -251,7 +251,7 @@ Update translation files. **Important: Only update `en-GB` files** - other langu ``` **Translation Notes:** -- **Only update `en-GB/translation.toml`** - other locale files are managed separately +- **Only update `en-US/translation.toml`** - other locale files are managed separately - Use descriptive keys that match your component's `t()` calls - Include tooltip translations if you created tooltip hooks - Add `options.*` keys if your tool has settings with descriptions diff --git a/AGENTS.md b/AGENTS.md index 7c89413c4..6e4e67510 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -426,7 +426,7 @@ The frontend is organized with a clear separation of concerns: ## Translation Rules -- **CRITICAL**: Always update translations in `en-GB` only, never `en-US` +- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately - Translation files are located in `frontend/editor/public/locales/` ## Important Notes diff --git a/DeveloperGuide.md b/DeveloperGuide.md index f6f87e824..23b2652ce 100644 --- a/DeveloperGuide.md +++ b/DeveloperGuide.md @@ -52,6 +52,17 @@ This guide focuses on developing for Stirling 2.0, including both the React fron - Rust and Cargo (required for Tauri desktop app development) - Tauri CLI (install with `cargo install tauri-cli`) +### Optional System Dependencies + +These are not required to run the app but enable specific features. The app detects them at startup and disables the relevant features if they are missing. + +| Dependency | Feature | Install | +|---|---|---| +| LibreOffice | File-to-PDF conversions | `brew install libreoffice` / `apt install libreoffice` | +| Tesseract | OCR | `brew install tesseract` / `apt install tesseract-ocr` | +| WeasyPrint | AI document creation | `brew install weasyprint` / `apt install weasyprint` | +| qpdf | PDF optimisation | `brew install qpdf` / `apt install qpdf` | + ### Setup Steps 1. Clone the repository: @@ -576,7 +587,7 @@ When adding a new feature or modifying existing ones in Stirling-PDF, you'll nee Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like: - `messages.properties` (default, usually English) -- `messages_en_GB.properties` +- `messages_en_US.properties` - `messages_fr_FR.properties` - `messages_de_DE.properties` - etc. diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index c4663c769..8df2d5e41 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -112,6 +112,17 @@ public class InternalApiClient { // every caller of this dispatcher is an automation surface by design. headers.add(AUTOMATION_HEADER, "true"); + // 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> entity = new HttpEntity<>(body, headers); RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class); diff --git a/app/core/src/main/java/stirling/software/SPDF/config/LocaleConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/LocaleConfiguration.java index 97fbb4d21..7d57c1efe 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/LocaleConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/LocaleConfiguration.java @@ -37,8 +37,8 @@ public class LocaleConfiguration implements WebMvcConfigurer { public LocaleResolver localeResolver() { SessionLocaleResolver slr = new SessionLocaleResolver(); String appLocaleEnv = applicationProperties.getSystem().getDefaultLocale(); - Locale defaultLocale = // Fallback to UK locale if environment variable is not set - Locale.UK; + Locale defaultLocale = // Fallback to US locale if environment variable is not set + Locale.US; if (appLocaleEnv != null && !appLocaleEnv.isEmpty()) { Locale tempLocale = Locale.forLanguageTag(appLocaleEnv); String tempLanguageTag = tempLocale.toLanguageTag(); @@ -51,7 +51,7 @@ public class LocaleConfiguration implements WebMvcConfigurer { defaultLocale = tempLocale; } else { System.err.println( - "Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back to default en-GB."); + "Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back to default en-US."); } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsController.java index e5d3ba884..4b53ea4ea 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsController.java @@ -48,7 +48,7 @@ public class AdditionalLanguageJsController { } } // Fallback - return "en_GB"; + return "en_US"; } """); writer.flush(); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 957a0a51a..fdf8f0fef 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -167,7 +167,7 @@ legal: impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder system: - defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc) + defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) showUpdate: false # see when a new update is available diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsControllerTest.java index 58c1b2e4e..28d8bb414 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsControllerTest.java @@ -23,7 +23,7 @@ class AdditionalLanguageJsControllerTest { LanguageService lang = mock(LanguageService.class); // LinkedHashSet for deterministic order in the array when(lang.getSupportedLanguages()) - .thenReturn(new LinkedHashSet<>(List.of("de_DE", "en_GB"))); + .thenReturn(new LinkedHashSet<>(List.of("de_DE", "en_US"))); MockMvc mvc = MockMvcBuilders.standaloneSetup(new AdditionalLanguageJsController(lang)).build(); @@ -36,9 +36,9 @@ class AdditionalLanguageJsControllerTest { .string( containsString( "const supportedLanguages =" - + " [\"de_DE\",\"en_GB\"];"))) + + " [\"de_DE\",\"en_US\"];"))) .andExpect(content().string(containsString("function getDetailedLanguageCode()"))) - .andExpect(content().string(containsString("return \"en_GB\";"))); + .andExpect(content().string(containsString("return \"en_US\";"))); verify(lang, times(1)).getSupportedLanguages(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java new file mode 100644 index 000000000..15b7982de --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java @@ -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. + * + *

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 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 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); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 8f82a3658..0178e041a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -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 diff --git a/devGuide/EXCEPTION_HANDLING_GUIDE.md b/devGuide/EXCEPTION_HANDLING_GUIDE.md index 666b70e2b..f1ab95abe 100644 --- a/devGuide/EXCEPTION_HANDLING_GUIDE.md +++ b/devGuide/EXCEPTION_HANDLING_GUIDE.md @@ -16,7 +16,7 @@ Java forms the core of Stirling-PDF. When adding new features or handling errors 2. **Use `try-with-resources`** when working with streams or other closable resources to ensure clean-up even on failure. 3. **Return meaningful HTTP status codes** in controllers by throwing `ResponseStatusException` or using `@ExceptionHandler` methods. 4. **Log with context** using the project’s logging framework. Include identifiers or IDs that help trace the issue. -5. **Internationalise messages** by placing user-facing text in `messages_en_GB.properties` and referencing them with message keys. +5. **Internationalise messages** by placing user-facing text in `messages_en_US.properties` and referencing them with message keys. ## JavaScript @@ -55,11 +55,11 @@ except Exception as err: ## Internationalisation (i18n) -All user-visible error strings should be defined in the main translation file (`messages_en_GB.properties`). Other language files will use the same keys. Refer to messages in code rather than hard-coding text. +All user-visible error strings should be defined in the main translation file (`messages_en_US.properties`). Other language files will use the same keys. Refer to messages in code rather than hard-coding text. When creating new messages: -1. Add the English phrase to `messages_en_GB.properties`. +1. Add the English phrase to `messages_en_US.properties`. 2. Reference the message key in your Java, JavaScript, or Python code. 3. Update other localisation files as needed. diff --git a/devGuide/HowToAddNewLanguage.md b/devGuide/HowToAddNewLanguage.md index b2aef3742..09bc83d09 100644 --- a/devGuide/HowToAddNewLanguage.md +++ b/devGuide/HowToAddNewLanguage.md @@ -16,7 +16,7 @@ Fork Stirling-PDF and create a new branch out of `main`. - Use hyphenated format: `pl-PL` (not underscore) 2. Copy the reference translation file: - - Source: `frontend/editor/public/locales/en-GB/translation.toml` + - Source: `frontend/editor/public/locales/en-US/translation.toml` - Destination: `frontend/editor/public/locales/pl-PL/translation.toml` 3. Translate all entries in the TOML file @@ -47,10 +47,10 @@ ignore = [ ## Add New Translation Tags > [!IMPORTANT] -> If you add any new translation tags, they must first be added to the `en-GB/translation.toml` file. This ensures consistency across all language files. +> If you add any new translation tags, they must first be added to the `en-US/translation.toml` file. This ensures consistency across all language files. -- New translation tags **must be added** to `frontend/editor/public/locales/en-GB/translation.toml` to maintain a reference for other languages. -- After adding the new tags to `en-GB/translation.toml`, add and translate them in the respective language file (e.g., `pl-PL/translation.toml`). +- New translation tags **must be added** to `frontend/editor/public/locales/en-US/translation.toml` to maintain a reference for other languages. +- After adding the new tags to `en-US/translation.toml`, add and translate them in the respective language file (e.g., `pl-PL/translation.toml`). - Use the scripts in `scripts/translations/` to validate and manage translations (see `scripts/translations/README.md`) Make sure to place the entry under the correct language section. This helps maintain the accuracy of translation progress statistics and ensures that the translation tool or scripts do not misinterpret the completion rate. diff --git a/docs/counter_translation.md b/docs/counter_translation.md index b2cdd7445..8468dfe1b 100644 --- a/docs/counter_translation.md +++ b/docs/counter_translation.md @@ -3,7 +3,7 @@ ## Overview The script [`scripts/counter_translation.py`](../scripts/counter_translation.py) checks the translation progress of the property files in the directory `app/core/src/main/resources/`. -It compares each `messages_*.properties` file with the English reference file `messages_en_GB.properties` and calculates a percentage of completion for each language. +It compares each `messages_*.properties` file with the English reference file `messages_en_US.properties` and calculates a percentage of completion for each language. In addition to console output, the script automatically updates the progress badges in the project’s `README.md` and maintains the configuration file [`scripts/ignore_translation.toml`](../scripts/ignore_translation.toml), which lists translation keys to be ignored for each language. diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 7bd2fa7be..fa972c849 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -5,6 +5,7 @@ description = "AI Document Engine" requires-python = ">=3.13" dependencies = [ "fastapi>=0.116.0", + "jinja2>=3.1.0", "pgvector>=0.3.6", "psycopg[binary,pool]>=3.2", "pydantic>=2.0.0", diff --git a/engine/src/stirling/agents/__init__.py b/engine/src/stirling/agents/__init__.py index 5410ac098..c22bd6c97 100644 --- a/engine/src/stirling/agents/__init__.py +++ b/engine/src/stirling/agents/__init__.py @@ -2,6 +2,7 @@ from .execution import ExecutionPlanningAgent from .orchestrator import OrchestratorAgent +from .pdf_create import PdfCreateAgent from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection from .pdf_questions import PdfQuestionAgent from .pdf_review import PdfReviewAgent @@ -10,6 +11,7 @@ from .user_spec import UserSpecAgent __all__ = [ "ExecutionPlanningAgent", "OrchestratorAgent", + "PdfCreateAgent", "PdfEditAgent", "PdfEditParameterSelector", "PdfEditPlanSelection", diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py index d2a0b4a19..c73d9dab3 100644 --- a/engine/src/stirling/agents/orchestrator.py +++ b/engine/src/stirling/agents/orchestrator.py @@ -8,6 +8,7 @@ from pydantic_ai import Agent from pydantic_ai.output import ToolOutput from pydantic_ai.tools import RunContext +from stirling.agents.pdf_create import PdfCreateAgent from stirling.agents.pdf_edit import PdfEditAgent from stirling.agents.pdf_questions import PdfQuestionAgent from stirling.agents.pdf_review import PdfReviewAgent @@ -26,6 +27,7 @@ from stirling.contracts import ( format_conversation_history, format_file_names, ) +from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse from stirling.services import AppRuntime logger = logging.getLogger(__name__) @@ -76,6 +78,16 @@ class OrchestratorAgent: "Delegate requests to convert a PDF to Markdown or extract its content as readable text." ), ), + ToolOutput( + self.delegate_pdf_create, + name="delegate_pdf_create", + description=( + "Delegate requests to create a new PDF document from scratch based on a" + " description. Use this when the user wants to generate a new document" + " (e.g. 'create an invoice', 'write a report', 'make a contract'," + " 'draft a letter'). No input file is required." + ), + ), ToolOutput( self.unsupported_capability, name="unsupported_capability", @@ -92,6 +104,8 @@ class OrchestratorAgent: "Use delegate_pdf_review when the user wants the PDF returned with review" " comments attached — anything like 'review this', 'annotate with comments'," " 'leave feedback on the PDF'. " + "Use delegate_pdf_create when the user wants to generate a new document from" + " scratch with no input file — invoices, reports, letters, contracts, etc. " "Use delegate_pdf_ingest for any request to convert a PDF to Markdown " "or extract its content as readable text. " "Use unsupported_capability when the user asks about the assistant itself " @@ -133,12 +147,13 @@ class OrchestratorAgent: return await self._run_pdf_edit(request) case SupportedCapability.AGENT_DRAFT: return await self._run_agent_draft(request) + case SupportedCapability.PDF_CREATE: + return await self._run_pdf_create(request) case ( SupportedCapability.ORCHESTRATE | SupportedCapability.AGENT_REVISE | SupportedCapability.AGENT_NEXT_ACTION | SupportedCapability.MATH_AUDITOR_AGENT - | SupportedCapability.PDF_TO_MARKDOWN ): raise ValueError(f"Cannot resume orchestrator with capability: {capability}") case _ as unreachable: @@ -175,6 +190,12 @@ class OrchestratorAgent: async def _run_pdf_review(self, request: OrchestratorRequest) -> PdfReviewOrchestrateResponse: return await PdfReviewAgent(self.runtime).orchestrate(request) + async def delegate_pdf_create(self, ctx: RunContext[OrchestratorDeps]) -> PdfCreateOrchestrateResponse: + return await self._run_pdf_create(ctx.deps.request) + + async def _run_pdf_create(self, request: OrchestratorRequest) -> PdfCreateOrchestrateResponse: + return await PdfCreateAgent(self.runtime).orchestrate(request) + async def unsupported_capability( self, ctx: RunContext[OrchestratorDeps], diff --git a/engine/src/stirling/agents/pdf_create/__init__.py b/engine/src/stirling/agents/pdf_create/__init__.py new file mode 100644 index 000000000..20e3b356a --- /dev/null +++ b/engine/src/stirling/agents/pdf_create/__init__.py @@ -0,0 +1,3 @@ +from .agent import PdfCreateAgent + +__all__ = ["PdfCreateAgent"] diff --git a/engine/src/stirling/agents/pdf_create/agent.py b/engine/src/stirling/agents/pdf_create/agent.py new file mode 100644 index 000000000..bd159060d --- /dev/null +++ b/engine/src/stirling/agents/pdf_create/agent.py @@ -0,0 +1,443 @@ +"""PDF Create Agent — chunked multi-agent pipeline. + +Flow: + 1. MetaPlannerAgent (smart_model) analyses the request and produces DocumentMeta: + title, tone, shared terms, style, and cannot_do_reason. No sections yet. + 2. SectionPlannerAgent (smart_model) reads the meta and produces DocumentSections: + ordered list of PlannedSection with heading, type, depth, and key_points. + 3. Python assembles DocumentPlan from meta + sections, then groups sections into + chunks, each staying under the output-token ceiling. + 4. SectionWriterAgents (smart_model) run in parallel via asyncio.gather. + Each returns a WrittenSections with fully populated DocumentSection objects. + 5. The assembler collects sections in plan order → GeneratedDocument. + 6. Jinja renders the document to HTML. The LLM never writes HTML. + +The planner is split into two calls (meta then sections) so each LLM output schema +stays small enough for grammar compilation on all model tiers including Haiku. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader +from pydantic_ai import Agent +from pydantic_ai.output import NativeOutput + +from stirling.contracts import ( + EditCannotDoResponse, + EditPlanResponse, + OrchestratorRequest, + ToolOperationStep, + format_conversation_history, +) +from stirling.contracts.pdf_create import ( + DocumentMeta, + DocumentPlan, + DocumentSection, + DocumentSections, + GeneratedDocument, + PdfCreateOrchestrateResponse, + PlannedSection, + SectionDepth, + WrittenSections, +) +from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams +from stirling.services import AppRuntime + +logger = logging.getLogger(__name__) + +_TEMPLATES_DIR = Path(__file__).parent / "templates" + +# ── Token budget ────────────────────────────────────────────────────────────────────────────────── + +# Conservative per-section token estimates mapped from planner-assigned depth. +_DEPTH_TOKENS: dict[SectionDepth, int] = { + SectionDepth.BRIEF: 250, + SectionDepth.STANDARD: 550, + SectionDepth.DETAILED: 1200, +} + +# Maximum output tokens per writer call. Stays well below the quality cliff (~4k). +_CHUNK_CEILING = 3000 + +# Cap on simultaneous writer calls so a large document doesn't open a burst of LLM +# connections and trip provider rate limits. +_MAX_PARALLEL_WRITERS = 10 + +# ── Chunk dataclass ─────────────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class _Chunk: + index: int + sections: list[PlannedSection] + # Descriptions of neighbouring chunks from the plan — passed to writers as + # read-only context so they can open/close their sections naturally. + context_before: str | None + context_after: str | None + + +# ── Chunking logic ──────────────────────────────────────────────────────────────────────────────── + + +def _describe_sections(sections: list[PlannedSection]) -> str: + """One-line summary of a chunk used as neighbour context.""" + return "; ".join(f'"{s.heading}" ({s.type.value})' for s in sections) + + +def _make_chunks(sections: list[PlannedSection]) -> list[_Chunk]: + """Group planned sections into chunks, each under _CHUNK_CEILING output tokens. + + Section boundaries are atomic — a section is never split across chunks. + A single section whose estimated cost exceeds the ceiling gets its own chunk. + asyncio.gather preserves insertion order so chunk index is only used for logging. + """ + if not sections: + return [] + + groups: list[list[PlannedSection]] = [] + current: list[PlannedSection] = [] + current_tokens = 0 + + for section in sections: + cost = _DEPTH_TOKENS[section.depth] + if current and current_tokens + cost > _CHUNK_CEILING: + groups.append(current) + current = [section] + current_tokens = cost + else: + current.append(section) + current_tokens += cost + + if current: + groups.append(current) + + chunks: list[_Chunk] = [] + for i, group in enumerate(groups): + context_before = _describe_sections(groups[i - 1]) if i > 0 else None + context_after = _describe_sections(groups[i + 1]) if i < len(groups) - 1 else None + chunks.append( + _Chunk( + index=i, + sections=group, + context_before=context_before, + context_after=context_after, + ) + ) + + return chunks + + +# ── Prompts ─────────────────────────────────────────────────────────────────────────────────────── + +_META_PLANNER_SYSTEM_PROMPT = """\ +You are a document planner. Your job is Step 1 of 2: produce the document header — NOT the +section list (that comes in Step 2) and NOT any body text (section writers handle that). + +Analyse the user's request and produce a DocumentMeta with: + +- title, subtitle (if appropriate), reference_number (only if the user supplies one explicitly) + +- tone_brief: one sentence describing register and style + (e.g. "Formal legal language, third person, present tense." or + "Professional business tone, active voice.") + +- shared_terms: consistent names for key entities AND ground-truth facts used throughout + the document. Two rules: + 1. Capture EVERY value the user states explicitly that could be referenced in more than + one section. This includes — but is not limited to: + · Named parties, organisations, products, or systems + · Numeric values: amounts, quantities, percentages, durations, limits + · Identifiers: version numbers, reference codes, model names + · Dates and time periods + · Units of measure or currency + 2. For any fact that will appear in two or more sections and that the user did NOT specify + (e.g. a default time, a standard rate, a typical threshold), assign ONE specific value + here. Do NOT let multiple writers independently invent the same fact. + Examples: {"the Agreement": "this Non-Disclosure Agreement", "the Client": "Acme Corp", + "contract value": "£120,000", "notice period": "30 days"} + +- document_context: a single sentence anchoring the temporal or versioning context of the + document, if the user provides one. Leave empty if the user provides no such context. + +- style_primary_color: accent and heading colour. Set ONLY when the user explicitly names a + colour or colour scheme (e.g. "make it red", "use navy blue"). Use CSS named colours + (e.g. "magenta", "navy", "crimson") or hex values. Leave null if no colour is stated. +- style_background_color: page background colour. Set only if explicitly requested. +- style_body_text_color: body text colour. Set only if explicitly requested. + +- cannot_do_reason: set this ONLY when the request is not asking to create a document at all + (e.g. a question, a greeting, an edit request to an existing document). Never set it + because the document is large, complex, or technically detailed. Leave null otherwise. + +RULES: +1. Extract ALL information the user provides. Do not invent content. +2. Do not produce any sections — that is Step 2. +""" + +_SECTIONS_PLANNER_SYSTEM_PROMPT = """\ +You are a document planner. Your job is Step 2 of 2: produce the ordered section list for +a document whose header has already been decided. Do NOT write any body text. + +You will be given: + - The document meta (title, tone, shared terms, etc.) produced in Step 1 + - The original user request + +Produce a DocumentSections with an ordered list of PlannedSection objects. + +For each section choose: + type — the most appropriate section type: + text — prose paragraphs (narrative, obligations, terms, descriptions) + key_value — labelled fields (parties, dates, metadata, identifiers) + line_items — tables with column headers (expenses, schedules, item lists) + bullet_list — unordered items (requirements, responsibilities, definitions) + signature — sign-off blocks for named parties or roles + + depth — honest estimate of content volume: + brief (~250 tokens) — 1-2 items, a short paragraph, or a small table + standard (~550 tokens) — a few paragraphs, a medium table, or a moderate list + detailed (~1200 tokens) — long clauses, complex multi-row tables, or dense content + + key_points — specific points this section MUST cover, taken directly from the user's input. + These are instructions to the writer, not summaries. Be precise and complete. + Every fact, name, date, amount, and requirement the user provides must appear somewhere. + For large documents, include enough key_points that the writer can produce substantial + content. + +RULES: +1. Extract ALL information the user provides. Do not invent content. +2. Assign depth honestly — for a long detailed document most sections will be detailed. +3. For large documents, produce as many sections as needed — there is no section count limit. +4. Use the shared_terms from the meta exactly when writing key_points. +""" + +_WRITER_SYSTEM_PROMPT = """\ +You are a section writer for a structured document. +Write ONLY the sections assigned to you — no extras, no merging, no skipping. + +SECTION TYPES — produce sections of exactly the requested type: + text — prose paragraphs. Use \\n\\n between paragraphs. + key_value — list of (label, value) pairs. Labels ≤ 5 words. Values verbatim from the data. + line_items — table. Every row must have exactly as many cells as there are columns. + bullet_list — flat list of items. + signature — list of signatory names/roles. + +RULES: +1. Write ONLY the sections in your assignment list, in the order given. +2. Cover every key_point listed for each section. Do not omit any. +3. Use the shared_terms exactly — no paraphrasing or substituting alternatives. + Shared terms are ground truth. If your general knowledge or a common default would + produce a different value (e.g. a different duration, amount, date, or version number), + the shared term takes precedence. This applies everywhere in the document, including + boilerplate, FAQ, and summary sections. +4. Match the depth for each section: brief = concise, standard = moderate, \ +detailed = thorough. +5. Maintain the document's tone throughout. +6. Do not reference other sections by number (e.g. "as defined in Section 3"). +7. If a document_context is provided, use it to anchor any dates, versions, or time + references you generate. Do not invent a different temporal or versioning context. +""" + + +def _build_sections_prompt(meta: DocumentMeta, user_request: str, history: str) -> str: + lines: list[str] = [ + "Document meta from Step 1:", + f" Title: {meta.title}", + f" Tone: {meta.tone_brief}", + ] + if meta.subtitle: + lines.append(f" Subtitle: {meta.subtitle}") + if meta.document_context: + lines.append(f" Document context: {meta.document_context}") + if meta.shared_terms: + lines.append(" Shared terms:") + for term, referent in meta.shared_terms.items(): + lines.append(f" {term} → {referent}") + + lines.append(f"\nConversation history:\n{history}") + lines.append(f"\nUser request: {user_request}") + return "\n".join(lines) + + +def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str: + lines: list[str] = [ + f"Document: {plan.title}", + f"Tone: {plan.tone_brief}", + ] + + if plan.document_context: + lines.append(f"Document context: {plan.document_context}") + + if plan.shared_terms: + lines.append("Ground-truth facts and shared terms (use exactly — these override defaults):") + for term, referent in plan.shared_terms.items(): + lines.append(f" {term} → {referent}") + + if chunk.context_before: + lines.append(f"\nThe sections BEFORE yours cover: {chunk.context_before}") + if chunk.context_after: + lines.append(f"The sections AFTER yours cover: {chunk.context_after}") + + lines.append(f"\nWrite these {len(chunk.sections)} section(s) in order:") + for i, s in enumerate(chunk.sections, 1): + lines.append(f"\n--- Section {i} ---") + lines.append(f"Heading: {s.heading}") + lines.append(f"Type: {s.type.value}") + lines.append(f"Depth: {s.depth.value}") + lines.append("Key points to cover:") + for point in s.key_points: + lines.append(f" - {point}") + + return "\n".join(lines) + + +# ── Helpers ─────────────────────────────────────────────────────────────────────────────────────── + + +def _build_jinja_env() -> Environment: + return Environment( + loader=FileSystemLoader(str(_TEMPLATES_DIR)), + autoescape=True, + trim_blocks=True, + lstrip_blocks=True, + ) + + +def _safe_filename(title: str) -> str: + slug = re.sub(r"[^\w\s-]", "", title.lower()) + slug = re.sub(r"[\s_-]+", "-", slug).strip("-") + return (slug[:60] or "document") + ".pdf" + + +# ── Agent ───────────────────────────────────────────────────────────────────────────────────────── + + +class PdfCreateAgent: + def __init__(self, runtime: AppRuntime) -> None: + self.runtime = runtime + self._jinja_env = _build_jinja_env() + + self._meta_planner: Agent[None, DocumentMeta] = Agent( + model=runtime.smart_model, + output_type=NativeOutput(DocumentMeta), + system_prompt=_META_PLANNER_SYSTEM_PROMPT, + model_settings={**runtime.smart_model_settings, "temperature": 0.1}, + ) + + self._sections_planner: Agent[None, DocumentSections] = Agent( + model=runtime.smart_model, + output_type=NativeOutput(DocumentSections), + system_prompt=_SECTIONS_PLANNER_SYSTEM_PROMPT, + model_settings={**runtime.smart_model_settings, "temperature": 0.1}, + ) + + self._writer: Agent[None, WrittenSections] = Agent( + model=runtime.smart_model, + output_type=NativeOutput(WrittenSections), + system_prompt=_WRITER_SYSTEM_PROMPT, + model_settings={**runtime.smart_model_settings, "temperature": 0.3}, + ) + + async def orchestrate(self, request: OrchestratorRequest) -> PdfCreateOrchestrateResponse: + history = format_conversation_history(request.conversation_history) + + # ── Phase 1: plan meta ───────────────────────────────────────────────── + logger.info("[pdf-create] phase 1/6: planning document meta") + meta_prompt = f"Conversation history:\n{history}\n\nUser request: {request.user_message}" + meta_result = await self._meta_planner.run(meta_prompt) + meta = meta_result.output + + if meta.cannot_do_reason: + logger.info("[pdf-create] cannot_do: %s", meta.cannot_do_reason) + return EditCannotDoResponse(reason=meta.cannot_do_reason) + + logger.info("[pdf-create] meta: title=%r tone=%r", meta.title, meta.tone_brief) + + # ── Phase 2: plan sections ───────────────────────────────────────────── + logger.info("[pdf-create] phase 2/6: planning sections") + sections_prompt = _build_sections_prompt(meta, request.user_message, history) + sections_result = await self._sections_planner.run(sections_prompt) + planned_sections = sections_result.output + + if not planned_sections.sections: + logger.info("[pdf-create] sections planner returned empty sections") + return EditCannotDoResponse(reason="No document sections could be planned from the request.") + + plan = DocumentPlan.assemble(meta, planned_sections) + + # ── Phase 3: chunk ───────────────────────────────────────────────────── + chunks = _make_chunks(plan.sections) + logger.info( + "[pdf-create] phase 3/6: chunked — sections=%d chunks=%d", + len(plan.sections), + len(chunks), + ) + + # ── Phase 4: write in parallel, bounded ──────────────────────────────── + logger.info("[pdf-create] phase 4/6: writing %d chunk(s) in parallel", len(chunks)) + total_chunks = len(chunks) + semaphore = asyncio.Semaphore(_MAX_PARALLEL_WRITERS) + written_chunks: list[WrittenSections] = await asyncio.gather( + *[self._write_chunk(plan, chunk, total_chunks, semaphore) for chunk in chunks] + ) + + # ── Phase 5: assemble in plan order (gather preserves insertion order) ── + all_sections: list[DocumentSection] = [] + for written in written_chunks: + all_sections.extend(written.sections) + + logger.info("[pdf-create] phase 5/6: assembled %d sections", len(all_sections)) + + doc = GeneratedDocument( + title=plan.title, + subtitle=plan.subtitle, + reference_number=plan.reference_number, + style=plan.style, + sections=all_sections, + ) + + # ── Phase 6: render ──────────────────────────────────────────────────── + logger.info("[pdf-create] phase 6/6: rendering HTML") + html = self._render(doc) + filename = _safe_filename(plan.title) + logger.info( + "[pdf-create] done — filename=%r html_bytes=%d", + filename, + len(html), + ) + + return EditPlanResponse( + summary=f"Created {plan.title}", + steps=[ + ToolOperationStep( + tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT, + parameters=CreatePdfFromHtmlAgentParams( + html_content=html, + filename=filename, + ), + ) + ], + ) + + async def _write_chunk( + self, plan: DocumentPlan, chunk: _Chunk, total_chunks: int, semaphore: asyncio.Semaphore + ) -> WrittenSections: + async with semaphore: + prompt = _build_writer_prompt(plan, chunk) + result = await self._writer.run(prompt) + logger.info( + "[pdf-create] chunk %d/%d wrote %d sections", + chunk.index + 1, + total_chunks, + len(result.output.sections), + ) + return result.output + + def _render(self, doc: GeneratedDocument) -> str: + template = self._jinja_env.get_template("document.html.jinja2") + return template.render(doc=doc) diff --git a/engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 b/engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 new file mode 100644 index 000000000..b969458f5 --- /dev/null +++ b/engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 @@ -0,0 +1,301 @@ + + + + + +{%- if doc.style %} + +{%- endif %} + + + +

+
{{ doc.title }}
+ {%- if doc.subtitle %} +
{{ doc.subtitle }}
+ {%- endif %} + {%- if doc.reference_number %} +
{{ doc.reference_number }}
+ {%- endif %} +
+ +{%- for section in doc.sections %} + +{%- if section.type == "text" %} +
+ {%- if section.heading %} +

{{ section.heading }}

+ {%- endif %} +
+ {%- for para in section.body.split('\n\n') %} +

{{ para | replace('\n', ' ') }}

+ {%- endfor %} +
+
+ +{%- elif section.type == "key_value" %} +
+ {%- if section.heading %} +

{{ section.heading }}

+ {%- endif %} + + + {%- for label, value in section.pairs %} + + + + + {%- endfor %} + +
{{ label }}{{ value }}
+
+ +{%- elif section.type == "line_items" %} +
+ {%- if section.heading %} +

{{ section.heading }}

+ {%- endif %} + + + + {%- for col in section.columns %} + + {%- endfor %} + + + + {%- for row in section.rows %} + + {%- for cell in row %} + + {%- endfor %} + + {%- endfor %} + {%- if section.total_row %} + + {%- for cell in section.total_row %} + + {%- endfor %} + + {%- endif %} + +
{{ col }}
{{ cell }}
{{ cell }}
+
+ +{%- elif section.type == "bullet_list" %} +
+ {%- if section.heading %} +

{{ section.heading }}

+ {%- endif %} + +
+ +{%- elif section.type == "signature" %} +
+ {%- if section.heading %} +

{{ section.heading }}

+ {%- endif %} +
+ {%- for signatory in section.signatories %} +
+
+
{{ signatory }}
+
+ {%- endfor %} +
+
+ +{%- endif %} +{%- endfor %} + + + diff --git a/engine/src/stirling/contracts/__init__.py b/engine/src/stirling/contracts/__init__.py index 4bc4febcf..6c8d99d12 100644 --- a/engine/src/stirling/contracts/__init__.py +++ b/engine/src/stirling/contracts/__init__.py @@ -80,6 +80,15 @@ from .pdf_comments import ( PdfCommentResponse, TextChunk, ) +from .pdf_create import ( + DocumentMeta, + DocumentSections, + PdfCreateCannotDoResponse, + PdfCreateOrchestrateResponse, + PdfCreateRequest, + PdfCreateResponse, + PdfCreateSuccessResponse, +) from .pdf_edit import ( EditCannotDoResponse, EditClarificationRequest, @@ -130,6 +139,8 @@ __all__ = [ "DeleteDocumentResponse", "PurgeOwnerResponse", "Discrepancy", + "DocumentMeta", + "DocumentSections", "DiscrepancyKind", "EditCannotDoResponse", "EditClarificationRequest", @@ -164,6 +175,11 @@ __all__ = [ "PdfCommentRequest", "PdfCommentResponse", "PdfContentType", + "PdfCreateCannotDoResponse", + "PdfCreateOrchestrateResponse", + "PdfCreateRequest", + "PdfCreateResponse", + "PdfCreateSuccessResponse", "PdfEditRequest", "PdfEditResponse", "PdfEditTerminalResponse", diff --git a/engine/src/stirling/contracts/common.py b/engine/src/stirling/contracts/common.py index b8030c58b..8f35c9ceb 100644 --- a/engine/src/stirling/contracts/common.py +++ b/engine/src/stirling/contracts/common.py @@ -88,11 +88,11 @@ class SupportedCapability(StrEnum): PDF_EDIT = "pdf_edit" PDF_QUESTION = "pdf_question" PDF_REVIEW = "pdf_review" + PDF_CREATE = "pdf_create" AGENT_DRAFT = "agent_draft" AGENT_REVISE = "agent_revise" AGENT_NEXT_ACTION = "agent_next_action" MATH_AUDITOR_AGENT = "math_auditor_agent" - PDF_TO_MARKDOWN = "pdf_to_markdown" class ConversationMessage(ApiModel): diff --git a/engine/src/stirling/contracts/pdf_create.py b/engine/src/stirling/contracts/pdf_create.py new file mode 100644 index 000000000..761a192dd --- /dev/null +++ b/engine/src/stirling/contracts/pdf_create.py @@ -0,0 +1,226 @@ +"""Contracts for the PDF Create Agent. + +The agent accepts a natural-language prompt and returns a single +CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML. + +Pipeline: + 1. PlannerAgent (smart_model) → DocumentPlan: structured skeleton, no body text. + 2. Python chunks the plan by token budget. + 3. SectionWriterAgents (smart_model, parallel) → WrittenSections per chunk. + 4. Assembler collects sections in plan order → GeneratedDocument. + 5. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML. +""" + +from __future__ import annotations + +import re +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import Field, field_validator + +from stirling.models import ApiModel + +from .common import ConversationMessage +from .pdf_edit import EditCannotDoResponse, EditPlanResponse + + +class SectionType(StrEnum): + TEXT = "text" + KEY_VALUE = "key_value" + LINE_ITEMS = "line_items" + BULLET_LIST = "bullet_list" + SIGNATURE = "signature" + + +class TextSection(ApiModel): + """One or more prose paragraphs. Use for introductions, summaries, and narrative content.""" + + type: Literal[SectionType.TEXT] = SectionType.TEXT + heading: str | None = None + body: str = Field(description="Paragraph text. Use \\n\\n to separate paragraphs.") + + +class KeyValueSection(ApiModel): + """Labelled fields. Use for contact info, dates, invoice details, and metadata.""" + + type: Literal[SectionType.KEY_VALUE] = SectionType.KEY_VALUE + heading: str | None = None + pairs: list[tuple[str, str]] = Field(description="List of (label, value) pairs.") + + +class LineItemsSection(ApiModel): + """A table with column headers and data rows. Use for invoices, expenses, schedules.""" + + type: Literal[SectionType.LINE_ITEMS] = SectionType.LINE_ITEMS + heading: str | None = None + columns: list[str] = Field(description="Column header names.") + rows: list[list[str]] = Field(description="Data rows; each row must match columns in length.") + total_row: list[str] | None = None + + +class BulletListSection(ApiModel): + """An unordered list. Use for requirements, responsibilities, or any enumerated items.""" + + type: Literal[SectionType.BULLET_LIST] = SectionType.BULLET_LIST + heading: str | None = None + items: list[str] + + +class SignatureSection(ApiModel): + """Signature blocks. Use when the document requires sign-off from named parties.""" + + type: Literal[SectionType.SIGNATURE] = SectionType.SIGNATURE + heading: str | None = None + signatories: list[str] = Field(description="Names or roles to sign, e.g. 'John Smith, CEO'.") + + +type DocumentSection = Annotated[ + TextSection | KeyValueSection | LineItemsSection | BulletListSection | SignatureSection, + Field(discriminator="type"), +] + + +# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the +#