From 88adb7adadbec17df1f7f0b6deba98475f959ab0 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:18:13 +0100 Subject: [PATCH] create agent (#6520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the create agent. Use [these prompts](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/main/docgen/backend/default_templates/sample_prompts.md) to test or try your own :) Here’s the one I use ``` Hey, I need to generate an employee expense report for reimbursement. Company: Summit Consulting Partners Company address: 88 Riverside Plaza, Suite 1400, New York, NY 10069 Accounting department email: expenses@example.com Employee details: * Employee Name: Michael Tran * Employee ID: EMP-1047 * Department: Client Services * Report Date: January 20th, 2026 * Reporting Period: January 5th, 2026 – January 16th, 2026 * Manager Approver: Laura Simmons Trip purpose: Client onsite meetings with Atlantic Energy Solutions in Boston, MA. Expense items: * Flight (NYC to Boston roundtrip) — $325.40 — January 5th, 2026 — Airline ticket * Hotel (3 nights at Harborview Hotel) — $822.75 — January 5th-8th, 2026 * Taxi from airport to hotel — $48.00 — January 5th, 2026 * Client dinner (3 attendees) — $186.20 — January 6th, 2026 * Parking at JFK Airport — $72.00 — January 5th-8th, 2026 * Breakfast (per diem not used) — $18.50 — January 7th, 2026 * Uber to client office — $22.10 — January 7th, 2026 * Printing + presentation materials — $46.90 — January 8th, 2026 * Lunch with client — $39.75 — January 8th, 2026 * Office supplies (notebooks, pens) — $27.60 — January 10th, 2026 * Mileage reimbursement (client visit in NJ, 42 miles @ $0.67/mile) — $28.14 — January 14th, 2026 * Team lunch meeting (internal) — $64.30 — January 15th, 2026 Reimbursement method should be direct deposit. Add a notes section stating: "All receipts attached. Expenses are business-related and comply with company travel policy." ``` --------- Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com> --- DeveloperGuide.md | 11 + .../common/service/InternalApiClient.java | 11 + .../api/CreatePdfAgentController.java | 144 +++++ .../service/AiWorkflowService.java | 27 +- engine/pyproject.toml | 1 + engine/src/stirling/agents/__init__.py | 2 + engine/src/stirling/agents/orchestrator.py | 23 +- .../stirling/agents/pdf_create/__init__.py | 3 + .../src/stirling/agents/pdf_create/agent.py | 443 ++++++++++++++ .../pdf_create/templates/document.html.jinja2 | 301 ++++++++++ engine/src/stirling/contracts/__init__.py | 16 + engine/src/stirling/contracts/common.py | 2 +- engine/src/stirling/contracts/pdf_create.py | 226 +++++++ .../src/stirling/models/agent_tool_models.py | 15 +- engine/src/stirling/services/runtime.py | 5 +- engine/tests/agents/test_pdf_create.py | 564 ++++++++++++++++++ engine/uv.lock | 2 + .../src/core/components/chat/ChatContext.tsx | 1 + .../components/chat/ChatContext.tsx | 39 +- .../components/chat/ChatContext.tsx | 32 +- 20 files changed, 1828 insertions(+), 40 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java create mode 100644 engine/src/stirling/agents/pdf_create/__init__.py create mode 100644 engine/src/stirling/agents/pdf_create/agent.py create mode 100644 engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 create mode 100644 engine/src/stirling/contracts/pdf_create.py create mode 100644 engine/tests/agents/test_pdf_create.py diff --git a/DeveloperGuide.md b/DeveloperGuide.md index f6f87e824..2b8425f2a 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: 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 f72eca14d..37c110c27 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 @@ -97,6 +97,17 @@ public class InternalApiClient { headers.add("X-API-KEY", apiKey); } + // A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so + // without this RestTemplate would use urlencoded instead of the multipart the controller + // expects. File-bearing calls get the right multipart content-type from RestTemplate. + boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/"); + boolean hasFilePart = + body.values().stream() + .flatMap(java.util.List::stream) + .anyMatch(v -> v instanceof Resource); + if (isAiTool && !hasFilePart) { + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + } HttpEntity> entity = new HttpEntity<>(body, headers); RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class); 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/engine/pyproject.toml b/engine/pyproject.toml index 4074c21ee..2e26756c7 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]>=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 +#