From 0570c4c4d9e7bf4786a02ab770ef75f116ac3719 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:30:33 +0100 Subject: [PATCH] Create-PDF engine: render from a structured document (#7018) --- app/allowed-licenses.json | 12 ++ app/proprietary/build.gradle | 14 ++ .../api/CreatePdfAgentController.java | 55 ++++-- .../model/api/ai/create/AiDocument.java | 35 ++++ .../service/AiDocumentHtmlRenderer.java | 135 +++++++++++++ .../templates/ai/create}/document.html.jinja2 | 40 ++-- .../policy/engine/PolicyExecutorTest.java | 4 +- .../service/AiDocumentHtmlRendererTest.java | 139 ++++++++++++++ build.gradle | 9 + engine/pyproject.toml | 1 - .../src/stirling/agents/pdf_create/agent.py | 41 ++-- engine/src/stirling/contracts/pdf_create.py | 14 +- .../src/stirling/models/agent_tool_models.py | 2 +- engine/tests/agents/test_pdf_create.py | 177 +++--------------- engine/uv.lock | 2 - 15 files changed, 453 insertions(+), 227 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java rename {engine/src/stirling/agents/pdf_create/templates => app/proprietary/src/main/resources/templates/ai/create}/document.html.jinja2 (87%) create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index 9f1ff96359..88ed8ba4d5 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -208,6 +208,18 @@ "moduleName": ".*", "moduleLicense": "The W3C License" }, + { + "moduleName": "com.google.re2j:re2j", + "moduleLicense": "Go License" + }, + { + "moduleName": "com.hubspot:algebra", + "moduleLicense": null + }, + { + "moduleName": "com.hubspot.immutables:immutables-exceptions", + "moduleLicense": null + }, { "moduleName": ".*", "moduleLicense": "UnRar License" diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index fb21ed0d0c..1e51b820ae 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -66,6 +66,20 @@ dependencies { implementation "com.google.code.gson:gson:${gsonVersion}" + // jinjava/jjwt transitively request older Jackson 2 versions; declare the current + // version directly so it is selected consistently (root build.gradle pins are the fallback). + runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}" + runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}" + + implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") { + // Compile-time-only annotation artifacts (class-retention annotations, not needed at + // runtime) whose declared licences (LGPL / none) fail the licence compatibility check. + exclude group: 'com.google.code.findbugs', module: 'annotations' + exclude group: 'org.derive4j', module: 'derive4j-annotation' + exclude group: 'com.hubspot.immutables', module: 'hubspot-style' + exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings' + } + api 'io.micrometer:micrometer-registry-prometheus' api "io.jsonwebtoken:jjwt-api:${jwtVersion}" 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 index 15b7982dec..5e2298937a 100644 --- 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 @@ -8,12 +8,14 @@ import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; 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 org.springframework.web.server.ResponseStatusException; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Hidden; @@ -24,18 +26,24 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; 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; +import stirling.software.proprietary.model.api.ai.create.AiDocument; +import stirling.software.proprietary.service.AiDocumentHtmlRenderer; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; /** - * Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint. + * Dispatchable tool that converts an AI-generated document model 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. + * emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as + * structured fields; the HTML is built here from a fixed template. */ @Slf4j @Hidden @@ -48,6 +56,9 @@ public class CreatePdfAgentController { private final TempFileManager tempFileManager; private final CustomPDFDocumentFactory pdfDocumentFactory; private final RuntimePathConfig runtimePathConfig; + private final ApplicationProperties applicationProperties; + private final ObjectMapper objectMapper; + private final AiDocumentHtmlRenderer htmlRenderer; /** * Returns true only when WeasyPrint is definitively unavailable — either the binary could not @@ -74,32 +85,42 @@ public class CreatePdfAgentController { value = "/create-pdf-from-html-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( - summary = "Convert AI-generated HTML to a PDF", + summary = "Convert an AI-generated document 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) + "Accepts a structured document as a JSON 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 createPdf( + @RequestParam("document") String document, @RequestParam("filename") String filename) throws Exception { + if (!applicationProperties.getAiEngine().isEnabled()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + + AiDocument model; + try { + model = objectMapper.readValue(document, AiDocument.class); + } catch (JacksonException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + + String html = htmlRenderer.render(model); + log.info( - "[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}", - htmlContent.length()); + "[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}", + html.length()); try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html"); TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) { - Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8); + Files.writeString(htmlFile.getPath(), html, 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()); @@ -126,8 +147,8 @@ public class CreatePdfAgentController { // 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()); + try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) { + pdDocument.save(tempOut.getPath().toFile()); } catch (Exception e) { tempOut.close(); throw e; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java new file mode 100644 index 0000000000..6fd95728c2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java @@ -0,0 +1,35 @@ +package stirling.software.proprietary.model.api.ai.create; + +import java.util.List; + +import lombok.Data; + +@Data +public class AiDocument { + + private String title; + private String subtitle; + private String referenceNumber; + private Style style; + private List

sections; + + @Data + public static class Style { + private String primaryColor; + private String backgroundColor; + private String bodyTextColor; + } + + @Data + public static class Section { + private String type; + private String heading; + private String body; + private List> pairs; + private List columns; + private List> rows; + private List totalRow; + private List items; + private List signatories; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java new file mode 100644 index 0000000000..26eff3113c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java @@ -0,0 +1,135 @@ +package stirling.software.proprietary.service; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; + +import stirling.software.proprietary.model.api.ai.create.AiDocument; + +/** Renders an {@link AiDocument} to HTML using a Jinja template loaded from the classpath. */ +@Component +public class AiDocumentHtmlRenderer { + + private static final String TEMPLATE_PATH = "templates/ai/create/document.html.jinja2"; + + private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$"); + + private final Jinjava jinjava; + private final String template; + + public AiDocumentHtmlRenderer() { + JinjavaConfig config = + JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build(); + this.jinjava = new Jinjava(config); + this.template = loadTemplate(); + } + + public String render(AiDocument doc) { + return jinjava.render(template, buildContext(doc)); + } + + private static Map buildContext(AiDocument doc) { + Map context = new LinkedHashMap<>(); + context.put("title", doc.getTitle()); + context.put("subtitle", doc.getSubtitle()); + context.put("reference_number", doc.getReferenceNumber()); + + AiDocument.Style style = doc.getStyle(); + if (style != null) { + context.put("style_primary", safeColor(style.getPrimaryColor())); + context.put("style_background", safeColor(style.getBackgroundColor())); + context.put("style_body", safeColor(style.getBodyTextColor())); + } + + List> sections = new ArrayList<>(); + if (doc.getSections() != null) { + for (AiDocument.Section section : doc.getSections()) { + if (section != null && section.getType() != null) { + sections.add(buildSection(section)); + } + } + } + context.put("sections", sections); + return context; + } + + private static Map buildSection(AiDocument.Section section) { + Map node = new LinkedHashMap<>(); + node.put("type", section.getType()); + node.put("heading", section.getHeading()); + switch (section.getType()) { + case "text" -> node.put("paragraphs", paragraphs(section.getBody())); + case "key_value" -> node.put("pairs", pairs(section.getPairs())); + case "line_items" -> { + node.put("columns", orEmpty(section.getColumns())); + node.put("rows", orEmptyRows(section.getRows())); + node.put("total_row", emptyToNull(section.getTotalRow())); + } + case "bullet_list" -> node.put("items", orEmpty(section.getItems())); + case "signature" -> node.put("signatories", orEmpty(section.getSignatories())); + default -> {} + } + return node; + } + + private static List paragraphs(String body) { + String text = body == null ? "" : body; + List out = new ArrayList<>(); + for (String paragraph : text.split("\n\n")) { + out.add(paragraph.replace("\n", " ")); + } + return out; + } + + private static List> pairs(List> pairs) { + List> out = new ArrayList<>(); + if (pairs != null) { + for (List pair : pairs) { + Map node = new LinkedHashMap<>(); + node.put("label", pair.isEmpty() ? "" : pair.get(0)); + node.put("value", pair.size() < 2 ? "" : pair.get(1)); + out.add(node); + } + } + return out; + } + + private static List orEmpty(List values) { + return values == null ? List.of() : values; + } + + private static List> orEmptyRows(List> rows) { + return rows == null ? List.of() : rows; + } + + private static List emptyToNull(List values) { + return values == null || values.isEmpty() ? null : values; + } + + private static String safeColor(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null; + } + + private static String loadTemplate() { + try { + return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 b/app/proprietary/src/main/resources/templates/ai/create/document.html.jinja2 similarity index 87% rename from engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 rename to app/proprietary/src/main/resources/templates/ai/create/document.html.jinja2 index b969458f5e..0a4a2cfc20 100644 --- a/engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 +++ b/app/proprietary/src/main/resources/templates/ai/create/document.html.jinja2 @@ -1,3 +1,4 @@ +{%- autoescape true -%} @@ -175,18 +176,18 @@ color: var(--color-label); } -{%- if doc.style %} +{%- if style_primary or style_background or style_body %} @@ -195,16 +196,16 @@
-
{{ doc.title }}
- {%- if doc.subtitle %} -
{{ doc.subtitle }}
+
{{ title }}
+ {%- if subtitle %} +
{{ subtitle }}
{%- endif %} - {%- if doc.reference_number %} -
{{ doc.reference_number }}
+ {%- if reference_number %} +
{{ reference_number }}
{%- endif %}
-{%- for section in doc.sections %} +{%- for section in sections %} {%- if section.type == "text" %}
@@ -212,8 +213,8 @@

{{ section.heading }}

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

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

+ {%- for para in section.paragraphs %} +

{{ para }}

{%- endfor %}
@@ -225,10 +226,10 @@ {%- endif %} - {%- for label, value in section.pairs %} + {%- for pair in section.pairs %} - - + + {%- endfor %} @@ -299,3 +300,4 @@ +{%- endautoescape %} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java index 9b10d9090e..4d9ea1deb7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java @@ -166,8 +166,8 @@ class PolicyExecutorTest { new PipelineStep( createPdf, Map.of( - "htmlContent", - "

hi

", + "document", + "{\"title\":\"PO\",\"sections\":[]}", "filename", "purchase-order.pdf"))), PolicyInputs.of(List.of()), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java new file mode 100644 index 0000000000..7b491c3539 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java @@ -0,0 +1,139 @@ +package stirling.software.proprietary.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.model.api.ai.create.AiDocument; + +class AiDocumentHtmlRendererTest { + + private final AiDocumentHtmlRenderer renderer = new AiDocumentHtmlRenderer(); + + private static AiDocument.Section section(String type) { + AiDocument.Section s = new AiDocument.Section(); + s.setType(type); + return s; + } + + private static AiDocument document(String title, List sections) { + AiDocument doc = new AiDocument(); + doc.setTitle(title); + doc.setSections(sections); + return doc; + } + + @Test + void rendersAllSectionTypes() { + AiDocument.Section text = section("text"); + text.setBody("Some prose text."); + AiDocument.Section kv = section("key_value"); + kv.setPairs(List.of(List.of("Key", "Value"))); + AiDocument.Section items = section("line_items"); + items.setColumns(List.of("A", "B")); + items.setRows(List.of(List.of("1", "2"))); + AiDocument.Section bullets = section("bullet_list"); + bullets.setItems(List.of("item one")); + AiDocument.Section sign = section("signature"); + sign.setSignatories(List.of("Alice")); + + String html = renderer.render(document("All", List.of(text, kv, items, bullets, sign))); + + assertTrue(html.contains("")); + assertTrue(html.contains("Some prose text.")); + assertTrue(html.contains("Key") && html.contains("Value")); + assertTrue(html.contains("")); + } + + @Test + void totalRowAbsentWhenNotProvided() { + AiDocument.Section items = section("line_items"); + items.setColumns(List.of("Item")); + items.setRows(List.of(List.of("Widget"))); + + assertFalse( + renderer.render(document("Table", List.of(items))) + .contains("")); + } + + @Test + void rendersSubtitleAndReference() { + AiDocument doc = document("My Doc", List.of()); + doc.setSubtitle("Subtitle Here"); + doc.setReferenceNumber("REF-42"); + + String html = renderer.render(doc); + + assertTrue(html.contains("Subtitle Here")); + assertTrue(html.contains("REF-42")); + } + + @Test + void appliesHexColourOverride() { + AiDocument doc = document("Styled", List.of()); + AiDocument.Style style = new AiDocument.Style(); + style.setPrimaryColor("#ff00ff"); + style.setBackgroundColor("#111111"); + doc.setStyle(style); + + String html = renderer.render(doc); + + assertTrue(html.contains("--color-primary: #ff00ff")); + assertTrue(html.contains("--color-bg: #111111")); + } + + @Test + void ignoresColourWithDisallowedCharacters() { + AiDocument doc = document("Styled", List.of()); + AiDocument.Style style = new AiDocument.Style(); + style.setPrimaryColor("rgb(255, 0, 0)"); + doc.setStyle(style); + + String html = renderer.render(doc); + + assertFalse(html.contains("rgb(")); + assertTrue(html.contains("")); + } + + @Test + void ignoresNonHexColour() { + AiDocument doc = document("Styled", List.of()); + AiDocument.Style style = new AiDocument.Style(); + style.setPrimaryColor("magenta"); + style.setBackgroundColor("#fff"); + doc.setStyle(style); + + String html = renderer.render(doc); + + assertFalse(html.contains("--color-primary: magenta")); + assertFalse(html.contains("--color-bg: #fff;")); + } +} diff --git a/build.gradle b/build.gradle index 073bccc654..e2f47e26fb 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,8 @@ ext { okhttpBomVersion = "5.3.2" gsonVersion = "2.14.0" guavaVersion = "33.6.0-jre" + jinjavaVersion = "2.8.3" + jackson2Version = "2.21.2" bucket4jVersion = "8.19.0" archunitVersion = "1.4.2" batikVersion = "1.19" @@ -222,6 +224,13 @@ subprojects { resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}" // CVE-2024-47554: commons-io DoS prevention resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}" + // Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request older versions); + // pin the family to a current release and keep modules aligned. + resolutionStrategy.force "com.fasterxml.jackson.core:jackson-core:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jackson2Version}" // Keep BouncyCastle modules aligned to avoid runtime linkage errors resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}" resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}" diff --git a/engine/pyproject.toml b/engine/pyproject.toml index fa972c8495..7bd2fa7bea 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -5,7 +5,6 @@ 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/pdf_create/agent.py b/engine/src/stirling/agents/pdf_create/agent.py index bd159060df..671d4867ca 100644 --- a/engine/src/stirling/agents/pdf_create/agent.py +++ b/engine/src/stirling/agents/pdf_create/agent.py @@ -10,7 +10,7 @@ Flow: 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. + 6. The assembled document is emitted as structured fields. 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. @@ -22,9 +22,7 @@ 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 @@ -51,8 +49,6 @@ 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. @@ -166,10 +162,13 @@ Analyse the user's request and produce a DocumentMeta with: 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. + colour or colour scheme (e.g. "make it red", "use navy blue"). Express it as a 6-digit hex + code in #RRGGBB format (map any named colour to its hex value yourself, e.g. "navy" → + "#000080"). No other format is accepted. Leave null if no colour is stated. +- style_background_color: page background colour, same #RRGGBB format. Set only if explicitly + requested. +- style_body_text_color: body text colour, same #RRGGBB format. 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 @@ -299,15 +298,6 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str: # ── 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("-") @@ -320,7 +310,6 @@ def _safe_filename(title: str) -> str: 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, @@ -401,14 +390,12 @@ class PdfCreateAgent: sections=all_sections, ) - # ── Phase 6: render ──────────────────────────────────────────────────── - logger.info("[pdf-create] phase 6/6: rendering HTML") - html = self._render(doc) + # ── Phase 6: emit ────────────────────────────────────────────────────── filename = _safe_filename(plan.title) logger.info( - "[pdf-create] done — filename=%r html_bytes=%d", + "[pdf-create] done — filename=%r sections=%d", filename, - len(html), + len(all_sections), ) return EditPlanResponse( @@ -417,7 +404,7 @@ class PdfCreateAgent: ToolOperationStep( tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT, parameters=CreatePdfFromHtmlAgentParams( - html_content=html, + document=doc.model_dump_json(), filename=filename, ), ) @@ -437,7 +424,3 @@ class PdfCreateAgent: 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/contracts/pdf_create.py b/engine/src/stirling/contracts/pdf_create.py index 761a192dd1..00d5eb4091 100644 --- a/engine/src/stirling/contracts/pdf_create.py +++ b/engine/src/stirling/contracts/pdf_create.py @@ -1,14 +1,14 @@ """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. +CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document. 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. + 5. The document is emitted as structured fields. The LLM never writes HTML. """ from __future__ import annotations @@ -81,14 +81,12 @@ type DocumentSection = Annotated[ ] -# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the -#
{{ label }}{{ value }}{{ pair.label }}{{ pair.value }}
")); + assertTrue(html.contains("item one")); + assertTrue(html.contains("Alice")); + } + + @Test + void rendersMarkupCharactersAsText() { + AiDocument.Section text = section("text"); + text.setBody("a x & y"); + + String html = renderer.render(document("Doc", List.of(text))); + + assertFalse(html.contains("")); + assertTrue(html.contains("<b>")); + } + + @Test + void totalRowRenderedWhenPresent() { + AiDocument.Section items = section("line_items"); + items.setColumns(List.of("Item", "Total")); + items.setRows(List.of(List.of("Widget", "$10"))); + items.setTotalRow(List.of("Total", "$10")); + + assertTrue( + renderer.render(document("Table", List.of(items))) + .contains("