From 4bce30624936f0eba48ba38bac38f3d7f4c4fcaa Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Mon, 13 Jul 2026 10:51:13 +0100 Subject: [PATCH 1/4] Create-PDF engine: render from a structured document The create-pdf tool now receives the assembled document as structured fields and builds the HTML from a fixed server-side template, instead of the engine sending pre-rendered HTML. The dispatch endpoint is also gated on the aiEngine flag. --- .../api/CreatePdfAgentController.java | 55 +++- .../model/api/ai/create/AiDocument.java | 35 ++ .../service/AiDocumentHtmlRenderer.java | 261 +++++++++++++++ .../policy/engine/PolicyExecutorTest.java | 4 +- .../service/AiDocumentHtmlRendererTest.java | 139 ++++++++ engine/pyproject.toml | 1 - .../src/stirling/agents/pdf_create/agent.py | 41 +-- .../pdf_create/templates/document.html.jinja2 | 301 ------------------ 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 - 12 files changed, 523 insertions(+), 509 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 create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java delete mode 100644 engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 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 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.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 @@ private static boolean isMissingDependencyError(IOException e) { 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 ResponseEntity createPdfFromHtml( // 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..909f229f2e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java @@ -0,0 +1,261 @@ +package stirling.software.proprietary.service; + +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; + +import stirling.software.proprietary.model.api.ai.create.AiDocument; + +@Component +public class AiDocumentHtmlRenderer { + + private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$"); + + private static final String BASE_STYLE = + """ + + """; + + public String render(AiDocument doc) { + StringBuilder sb = new StringBuilder(); + sb.append("\n\n\n\n"); + sb.append(BASE_STYLE); + appendStyleOverride(sb, doc.getStyle()); + sb.append("\n\n"); + appendHeader(sb, doc); + List sections = doc.getSections(); + if (sections != null) { + for (AiDocument.Section section : sections) { + appendSection(sb, section); + } + } + sb.append("\n"); + return sb.toString(); + } + + private static void appendStyleOverride(StringBuilder sb, AiDocument.Style style) { + if (style == null) { + return; + } + String primary = safeColor(style.getPrimaryColor()); + String background = safeColor(style.getBackgroundColor()); + String body = safeColor(style.getBodyTextColor()); + if (primary == null && background == null && body == null) { + return; + } + sb.append("\n"); + } + + private static void appendHeader(StringBuilder sb, AiDocument doc) { + sb.append("
\n"); + sb.append("
").append(esc(doc.getTitle())).append("
\n"); + if (notBlank(doc.getSubtitle())) { + sb.append("
") + .append(esc(doc.getSubtitle())) + .append("
\n"); + } + if (notBlank(doc.getReferenceNumber())) { + sb.append("
") + .append(esc(doc.getReferenceNumber())) + .append("
\n"); + } + sb.append("
\n"); + } + + private static void appendSection(StringBuilder sb, AiDocument.Section section) { + if (section == null || section.getType() == null) { + return; + } + switch (section.getType()) { + case "text" -> appendText(sb, section); + case "key_value" -> appendKeyValue(sb, section); + case "line_items" -> appendLineItems(sb, section); + case "bullet_list" -> appendBulletList(sb, section); + case "signature" -> appendSignature(sb, section); + default -> {} + } + } + + private static void appendText(StringBuilder sb, AiDocument.Section section) { + sb.append("
\n"); + appendHeading(sb, section); + sb.append("
\n"); + String body = section.getBody() == null ? "" : section.getBody(); + for (String paragraph : body.split("\n\n")) { + sb.append("

").append(esc(paragraph.replace("\n", " "))).append("

\n"); + } + sb.append("
\n
\n"); + } + + private static void appendKeyValue(StringBuilder sb, AiDocument.Section section) { + sb.append("
\n"); + appendHeading(sb, section); + sb.append(" \n \n"); + if (section.getPairs() != null) { + for (List pair : section.getPairs()) { + String label = pair.isEmpty() ? "" : pair.get(0); + String value = pair.size() < 2 ? "" : pair.get(1); + sb.append(" \n \n \n \n"); + } + } + sb.append(" \n
") + .append(esc(label)) + .append("") + .append(esc(value)) + .append("
\n
\n"); + } + + private static void appendLineItems(StringBuilder sb, AiDocument.Section section) { + sb.append("
\n"); + appendHeading(sb, section); + sb.append(" \n \n \n"); + if (section.getColumns() != null) { + for (String column : section.getColumns()) { + sb.append(" \n"); + } + } + sb.append(" \n \n \n"); + if (section.getRows() != null) { + for (List row : section.getRows()) { + sb.append(" \n"); + for (String cell : row) { + sb.append(" \n"); + } + sb.append(" \n"); + } + } + if (section.getTotalRow() != null && !section.getTotalRow().isEmpty()) { + sb.append(" \n"); + for (String cell : section.getTotalRow()) { + sb.append(" \n"); + } + sb.append(" \n"); + } + sb.append(" \n
").append(esc(column)).append("
").append(esc(cell)).append("
").append(esc(cell)).append("
\n
\n"); + } + + private static void appendBulletList(StringBuilder sb, AiDocument.Section section) { + sb.append("
\n"); + appendHeading(sb, section); + sb.append("
    \n"); + if (section.getItems() != null) { + for (String item : section.getItems()) { + sb.append("
  • ").append(esc(item)).append("
  • \n"); + } + } + sb.append("
\n
\n"); + } + + private static void appendSignature(StringBuilder sb, AiDocument.Section section) { + sb.append("
\n"); + appendHeading(sb, section); + sb.append("
\n"); + if (section.getSignatories() != null) { + for (String signatory : section.getSignatories()) { + sb.append("
\n
\n") + .append("
") + .append(esc(signatory)) + .append("
\n
\n"); + } + } + sb.append("
\n
\n"); + } + + private static void appendHeading(StringBuilder sb, AiDocument.Section section) { + if (notBlank(section.getHeading())) { + sb.append("

").append(esc(section.getHeading())).append("

\n"); + } + } + + 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 boolean notBlank(String value) { + return value != null && !value.isBlank(); + } + + private static String esc(String value) { + return value == null ? "" : HtmlUtils.htmlEscape(value); + } +} 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 @@ void noInputGeneratorEndpointIsCalledOnceWithNoFile() throws IOException { 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("")); + 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("")); + } + + @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/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 @@ 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 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 @@ 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 @@ def _make_chunks(sections: list[PlannedSection]) -> list[_Chunk]: 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 @@ async def orchestrate(self, request: OrchestratorRequest) -> PdfCreateOrchestrat 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 @@ async def orchestrate(self, request: OrchestratorRequest) -> PdfCreateOrchestrat ToolOperationStep( tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT, parameters=CreatePdfFromHtmlAgentParams( - html_content=html, + document=doc.model_dump_json(), filename=filename, ), ) @@ -437,7 +424,3 @@ async def _write_chunk( 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 deleted file mode 100644 index b969458f5e..0000000000 --- a/engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 +++ /dev/null @@ -1,301 +0,0 @@ - - - - - -{%- 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 %} -
    - {%- for item in section.items %} -
  • {{ item }}
  • - {%- endfor %} -
-
- -{%- 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/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 @@ class SignatureSection(ApiModel): ] -# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the -# - """; + private final Jinjava jinjava; + private final String template; - public String render(AiDocument doc) { - StringBuilder sb = new StringBuilder(); - sb.append("\n\n\n\n"); - sb.append(BASE_STYLE); - appendStyleOverride(sb, doc.getStyle()); - sb.append("\n\n"); - appendHeader(sb, doc); - List sections = doc.getSections(); - if (sections != null) { - for (AiDocument.Section section : sections) { - appendSection(sb, section); - } - } - sb.append("\n"); - return sb.toString(); + public AiDocumentHtmlRenderer() { + JinjavaConfig config = + JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build(); + this.jinjava = new Jinjava(config); + this.template = loadTemplate(); } - private static void appendStyleOverride(StringBuilder sb, AiDocument.Style style) { - if (style == null) { - return; - } - String primary = safeColor(style.getPrimaryColor()); - String background = safeColor(style.getBackgroundColor()); - String body = safeColor(style.getBodyTextColor()); - if (primary == null && background == null && body == null) { - return; - } - sb.append("\n"); + public String render(AiDocument doc) { + return jinjava.render(template, buildContext(doc)); } - private static void appendHeader(StringBuilder sb, AiDocument doc) { - sb.append("
\n"); - sb.append("
").append(esc(doc.getTitle())).append("
\n"); - if (notBlank(doc.getSubtitle())) { - sb.append("
") - .append(esc(doc.getSubtitle())) - .append("
\n"); + 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())); } - if (notBlank(doc.getReferenceNumber())) { - sb.append("
") - .append(esc(doc.getReferenceNumber())) - .append("
\n"); + + List> sections = new ArrayList<>(); + if (doc.getSections() != null) { + for (AiDocument.Section section : doc.getSections()) { + if (section != null && section.getType() != null) { + sections.add(buildSection(section)); + } + } } - sb.append("
\n"); + context.put("sections", sections); + return context; } - private static void appendSection(StringBuilder sb, AiDocument.Section section) { - if (section == null || section.getType() == null) { - return; - } + 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" -> appendText(sb, section); - case "key_value" -> appendKeyValue(sb, section); - case "line_items" -> appendLineItems(sb, section); - case "bullet_list" -> appendBulletList(sb, section); - case "signature" -> appendSignature(sb, section); + 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 void appendText(StringBuilder sb, AiDocument.Section section) { - sb.append("
\n"); - appendHeading(sb, section); - sb.append("
\n"); - String body = section.getBody() == null ? "" : section.getBody(); - for (String paragraph : body.split("\n\n")) { - sb.append("

").append(esc(paragraph.replace("\n", " "))).append("

\n"); + 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", " ")); } - sb.append("
\n
\n"); + return out; } - private static void appendKeyValue(StringBuilder sb, AiDocument.Section section) { - sb.append("
\n"); - appendHeading(sb, section); - sb.append(" \n \n"); - if (section.getPairs() != null) { - for (List pair : section.getPairs()) { - String label = pair.isEmpty() ? "" : pair.get(0); - String value = pair.size() < 2 ? "" : pair.get(1); - sb.append(" \n \n \n \n"); + 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); } } - sb.append(" \n
") - .append(esc(label)) - .append("") - .append(esc(value)) - .append("
\n
\n"); + return out; } - private static void appendLineItems(StringBuilder sb, AiDocument.Section section) { - sb.append("
\n"); - appendHeading(sb, section); - sb.append(" \n \n \n"); - if (section.getColumns() != null) { - for (String column : section.getColumns()) { - sb.append(" \n"); - } - } - sb.append(" \n \n \n"); - if (section.getRows() != null) { - for (List row : section.getRows()) { - sb.append(" \n"); - for (String cell : row) { - sb.append(" \n"); - } - sb.append(" \n"); - } - } - if (section.getTotalRow() != null && !section.getTotalRow().isEmpty()) { - sb.append(" \n"); - for (String cell : section.getTotalRow()) { - sb.append(" \n"); - } - sb.append(" \n"); - } - sb.append(" \n
").append(esc(column)).append("
").append(esc(cell)).append("
").append(esc(cell)).append("
\n
\n"); + private static List orEmpty(List values) { + return values == null ? List.of() : values; } - private static void appendBulletList(StringBuilder sb, AiDocument.Section section) { - sb.append("
\n"); - appendHeading(sb, section); - sb.append("
    \n"); - if (section.getItems() != null) { - for (String item : section.getItems()) { - sb.append("
  • ").append(esc(item)).append("
  • \n"); - } - } - sb.append("
\n
\n"); + private static List> orEmptyRows(List> rows) { + return rows == null ? List.of() : rows; } - private static void appendSignature(StringBuilder sb, AiDocument.Section section) { - sb.append("
\n"); - appendHeading(sb, section); - sb.append("
\n"); - if (section.getSignatories() != null) { - for (String signatory : section.getSignatories()) { - sb.append("
\n
\n") - .append("
") - .append(esc(signatory)) - .append("
\n
\n"); - } - } - sb.append("
\n
\n"); - } - - private static void appendHeading(StringBuilder sb, AiDocument.Section section) { - if (notBlank(section.getHeading())) { - sb.append("

").append(esc(section.getHeading())).append("

\n"); - } + private static List emptyToNull(List values) { + return values == null || values.isEmpty() ? null : values; } private static String safeColor(String value) { @@ -251,11 +125,11 @@ private static String safeColor(String value) { return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null; } - private static boolean notBlank(String value) { - return value != null && !value.isBlank(); - } - - private static String esc(String value) { - return value == null ? "" : HtmlUtils.htmlEscape(value); + private static String loadTemplate() { + try { + return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } } diff --git a/app/proprietary/src/main/resources/templates/ai/create/document.html.jinja2 b/app/proprietary/src/main/resources/templates/ai/create/document.html.jinja2 new file mode 100644 index 0000000000..0a4a2cfc20 --- /dev/null +++ b/app/proprietary/src/main/resources/templates/ai/create/document.html.jinja2 @@ -0,0 +1,303 @@ +{%- autoescape true -%} + + + + + +{%- if style_primary or style_background or style_body %} + +{%- endif %} + + + +
+
{{ title }}
+ {%- if subtitle %} +
{{ subtitle }}
+ {%- endif %} + {%- if reference_number %} +
{{ reference_number }}
+ {%- endif %} +
+ +{%- for section in sections %} + +{%- if section.type == "text" %} +
+ {%- if section.heading %} +

{{ section.heading }}

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

{{ para }}

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

{{ section.heading }}

+ {%- endif %} + + + {%- for pair in section.pairs %} + + + + + {%- endfor %} + +
{{ pair.label }}{{ pair.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 %} +
    + {%- for item in section.items %} +
  • {{ item }}
  • + {%- endfor %} +
+
+ +{%- elif section.type == "signature" %} +
+ {%- if section.heading %} +

{{ section.heading }}

+ {%- endif %} +
+ {%- for signatory in section.signatories %} +
+
+
{{ signatory }}
+
+ {%- endfor %} +
+
+ +{%- endif %} +{%- endfor %} + + + +{%- endautoescape %} diff --git a/build.gradle b/build.gradle index 5c89fd7af2..0bf7ef396c 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ ext { okhttpBomVersion = "5.3.2" gsonVersion = "2.14.0" guavaVersion = "33.6.0-jre" + jinjavaVersion = "2.8.3" bucket4jVersion = "8.19.0" archunitVersion = "1.4.2" batikVersion = "1.19" From 578e7a377dec3937e7daa2e3b9b25223c663d905 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Mon, 13 Jul 2026 17:37:44 +0100 Subject: [PATCH 3/4] bump jackson-core version --- app/allowed-licenses.json | 12 ++++++++++++ app/proprietary/build.gradle | 9 ++++++++- build.gradle | 9 +++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) 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 b3cc1e2d68..ea36526dad 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -66,7 +66,14 @@ dependencies { implementation "com.google.code.gson:gson:${gsonVersion}" - implementation "com.hubspot.jinjava:jinjava:${jinjavaVersion}" + 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' diff --git a/build.gradle b/build.gradle index 0bf7ef396c..a18b4291a9 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,7 @@ ext { 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" @@ -223,6 +224,14 @@ subprojects { resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}" // CVE-2024-47554: commons-io DoS prevention resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}" + // GHSA-72hv-8253-57qq: jackson-core async parser bypasses maxNumberLength (fixed in 2.21.1). + // Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request vulnerable versions); + // pin the family to the patched line 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}" From 84ae6b03c262dbb67048ece6c34eb4b09a42d271 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Mon, 13 Jul 2026 18:44:02 +0100 Subject: [PATCH 4/4] make aikido happy declare jackson 2 directly so dependency scanners resolve patched version --- app/proprietary/build.gradle | 5 +++++ build.gradle | 5 ++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index ea36526dad..1e51b820ae 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -66,6 +66,11 @@ 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. diff --git a/build.gradle b/build.gradle index a18b4291a9..11aa939208 100644 --- a/build.gradle +++ b/build.gradle @@ -224,9 +224,8 @@ subprojects { resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}" // CVE-2024-47554: commons-io DoS prevention resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}" - // GHSA-72hv-8253-57qq: jackson-core async parser bypasses maxNumberLength (fixed in 2.21.1). - // Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request vulnerable versions); - // pin the family to the patched line and keep modules aligned. + // 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}"