mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
create agent (#6520)
Added the create agent. Use [these prompts](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/main/docgen/backend/default_templates/sample_prompts.md) to test or try your own :) Here’s the one I use ``` Hey, I need to generate an employee expense report for reimbursement. Company: Summit Consulting Partners Company address: 88 Riverside Plaza, Suite 1400, New York, NY 10069 Accounting department email: [email protected] Employee details: * Employee Name: Michael Tran * Employee ID: EMP-1047 * Department: Client Services * Report Date: January 20th, 2026 * Reporting Period: January 5th, 2026 – January 16th, 2026 * Manager Approver: Laura Simmons Trip purpose: Client onsite meetings with Atlantic Energy Solutions in Boston, MA. Expense items: * Flight (NYC to Boston roundtrip) — $325.40 — January 5th, 2026 — Airline ticket * Hotel (3 nights at Harborview Hotel) — $822.75 — January 5th-8th, 2026 * Taxi from airport to hotel — $48.00 — January 5th, 2026 * Client dinner (3 attendees) — $186.20 — January 6th, 2026 * Parking at JFK Airport — $72.00 — January 5th-8th, 2026 * Breakfast (per diem not used) — $18.50 — January 7th, 2026 * Uber to client office — $22.10 — January 7th, 2026 * Printing + presentation materials — $46.90 — January 8th, 2026 * Lunch with client — $39.75 — January 8th, 2026 * Office supplies (notebooks, pens) — $27.60 — January 10th, 2026 * Mileage reimbursement (client visit in NJ, 42 miles @ $0.67/mile) — $28.14 — January 14th, 2026 * Team lunch meeting (internal) — $64.30 — January 15th, 2026 Reimbursement method should be direct deposit. Add a notes section stating: "All receipts attached. Expenses are business-related and comply with company travel policy." ``` --------- Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
parent
9b877d4f8d
commit
88adb7adad
@@ -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)
|
- Rust and Cargo (required for Tauri desktop app development)
|
||||||
- Tauri CLI (install with `cargo install tauri-cli`)
|
- 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
|
### Setup Steps
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ public class InternalApiClient {
|
|||||||
headers.add("X-API-KEY", apiKey);
|
headers.add("X-API-KEY", apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
|
||||||
|
// without this RestTemplate would use urlencoded instead of the multipart the controller
|
||||||
|
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
|
||||||
|
boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/");
|
||||||
|
boolean hasFilePart =
|
||||||
|
body.values().stream()
|
||||||
|
.flatMap(java.util.List::stream)
|
||||||
|
.anyMatch(v -> v instanceof Resource);
|
||||||
|
if (isAiTool && !hasFilePart) {
|
||||||
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
}
|
||||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||||
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
|
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
|
||||||
|
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package stirling.software.proprietary.controller.api;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.github.pixee.security.Filenames;
|
||||||
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.configuration.RuntimePathConfig;
|
||||||
|
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||||
|
import stirling.software.common.util.ProcessExecutor;
|
||||||
|
import stirling.software.common.util.TempFile;
|
||||||
|
import stirling.software.common.util.TempFileManager;
|
||||||
|
import stirling.software.common.util.WebResponseUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
|
||||||
|
*
|
||||||
|
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
|
||||||
|
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
|
||||||
|
* template so sanitization is intentionally skipped.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Hidden
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/ai/tools")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||||
|
public class CreatePdfAgentController {
|
||||||
|
|
||||||
|
private final TempFileManager tempFileManager;
|
||||||
|
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||||
|
private final RuntimePathConfig runtimePathConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
||||||
|
* be launched at all, or it launched but immediately failed to load a required system library.
|
||||||
|
* Other conversion failures (bad HTML, output errors, etc.) return false so they surface as
|
||||||
|
* real errors rather than a misleading "dependency missing" message.
|
||||||
|
*/
|
||||||
|
private static boolean isMissingDependencyError(IOException e) {
|
||||||
|
String msg = e.getMessage();
|
||||||
|
if (msg == null) return false;
|
||||||
|
// OS could not start the process — binary not on PATH or not at the configured path.
|
||||||
|
if (msg.contains("Cannot run program")) return true;
|
||||||
|
// Process started but crashed immediately loading a shared library.
|
||||||
|
// "cannot load library" — Python/cffi error (Linux and macOS via pip)
|
||||||
|
// "Library not loaded" / "image not found" — macOS dyld error (Homebrew installs)
|
||||||
|
String lower = msg.toLowerCase();
|
||||||
|
if (lower.contains("cannot load library")) return true;
|
||||||
|
if (lower.contains("library not loaded")) return true;
|
||||||
|
if (lower.contains("image not found")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(
|
||||||
|
value = "/create-pdf-from-html-agent",
|
||||||
|
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@Operation(
|
||||||
|
summary = "Convert AI-generated HTML to a PDF",
|
||||||
|
description =
|
||||||
|
"Accepts an HTML document as a plain-text parameter and returns a PDF."
|
||||||
|
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
|
||||||
|
+ " plan step; it is not intended for direct client use.")
|
||||||
|
public ResponseEntity<Resource> createPdfFromHtml(
|
||||||
|
@RequestParam("htmlContent") String htmlContent,
|
||||||
|
@RequestParam("filename") String filename)
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
|
||||||
|
htmlContent.length());
|
||||||
|
|
||||||
|
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
|
||||||
|
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
|
||||||
|
|
||||||
|
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(runtimePathConfig.getWeasyPrintPath());
|
||||||
|
command.add("-e");
|
||||||
|
command.add("utf-8");
|
||||||
|
command.add("-v");
|
||||||
|
// SSRF: the HTML is self-contained and the engine validates style colours, so no
|
||||||
|
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
|
||||||
|
command.add(htmlFile.getAbsolutePath());
|
||||||
|
command.add(pdfFile.getAbsolutePath());
|
||||||
|
|
||||||
|
try {
|
||||||
|
ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)
|
||||||
|
.runCommandWithOutputHandling(command);
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (isMissingDependencyError(e)) {
|
||||||
|
throw new IOException(
|
||||||
|
"AI document creation is not available on this server because a required"
|
||||||
|
+ " system dependency is not installed. Please contact your"
|
||||||
|
+ " system administrator.");
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
String safeFilename = Filenames.toSimpleFileName(filename);
|
||||||
|
if (safeFilename == null || safeFilename.isBlank() || !safeFilename.endsWith(".pdf")) {
|
||||||
|
safeFilename = "generated-document.pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp the standard Stirling metadata onto the WeasyPrint output and write the result
|
||||||
|
// straight to the response temp file. Loading from the file and saving to the file
|
||||||
|
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
|
||||||
|
// which matters for large generated documents.
|
||||||
|
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||||
|
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||||
|
document.save(tempOut.getPath().toFile());
|
||||||
|
} catch (Exception e) {
|
||||||
|
tempOut.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[create-pdf-agent] PDF ready — filename={} bytes={}",
|
||||||
|
safeFilename,
|
||||||
|
Files.size(tempOut.getPath()));
|
||||||
|
|
||||||
|
return WebResponseUtils.pdfFileToWebResponse(tempOut, safeFilename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-1
@@ -17,6 +17,7 @@ import org.springframework.core.io.Resource;
|
|||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.MediaTypeFactory;
|
import org.springframework.http.MediaTypeFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.HttpServerErrorException;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import io.github.pixee.security.Filenames;
|
import io.github.pixee.security.Filenames;
|
||||||
@@ -175,7 +176,6 @@ public class AiWorkflowService {
|
|||||||
initialRequest.setFiles(files);
|
initialRequest.setFiles(files);
|
||||||
initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory()));
|
initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory()));
|
||||||
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
|
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
|
||||||
|
|
||||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
|
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
|
||||||
|
|
||||||
WorkflowState state = new WorkflowState.Pending(initialRequest);
|
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());
|
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
|
||||||
return new WorkflowState.Terminal(
|
return new WorkflowState.Terminal(
|
||||||
cannotContinue(toolTimeoutMessage(e.getEndpointPath(), e)));
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
||||||
return new WorkflowState.Terminal(
|
return new WorkflowState.Terminal(
|
||||||
@@ -605,6 +609,27 @@ public class AiWorkflowService {
|
|||||||
return String.format("The %s tool failed: %s", endpointPath, reason);
|
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
|
* Adapt the AI workflow's {@link ProgressListener} to the engine's {@link
|
||||||
* PolicyProgressListener}: each step start maps to an {@code EXECUTING_TOOL} progress event
|
* PolicyProgressListener}: each step start maps to an {@code EXECUTING_TOOL} progress event
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description = "AI Document Engine"
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.116.0",
|
"fastapi>=0.116.0",
|
||||||
|
"jinja2>=3.1.0",
|
||||||
"pgvector>=0.3.6",
|
"pgvector>=0.3.6",
|
||||||
"psycopg[binary]>=3.2",
|
"psycopg[binary]>=3.2",
|
||||||
"pydantic>=2.0.0",
|
"pydantic>=2.0.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from .execution import ExecutionPlanningAgent
|
from .execution import ExecutionPlanningAgent
|
||||||
from .orchestrator import OrchestratorAgent
|
from .orchestrator import OrchestratorAgent
|
||||||
|
from .pdf_create import PdfCreateAgent
|
||||||
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
|
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
|
||||||
from .pdf_questions import PdfQuestionAgent
|
from .pdf_questions import PdfQuestionAgent
|
||||||
from .pdf_review import PdfReviewAgent
|
from .pdf_review import PdfReviewAgent
|
||||||
@@ -10,6 +11,7 @@ from .user_spec import UserSpecAgent
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"ExecutionPlanningAgent",
|
"ExecutionPlanningAgent",
|
||||||
"OrchestratorAgent",
|
"OrchestratorAgent",
|
||||||
|
"PdfCreateAgent",
|
||||||
"PdfEditAgent",
|
"PdfEditAgent",
|
||||||
"PdfEditParameterSelector",
|
"PdfEditParameterSelector",
|
||||||
"PdfEditPlanSelection",
|
"PdfEditPlanSelection",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pydantic_ai import Agent
|
|||||||
from pydantic_ai.output import ToolOutput
|
from pydantic_ai.output import ToolOutput
|
||||||
from pydantic_ai.tools import RunContext
|
from pydantic_ai.tools import RunContext
|
||||||
|
|
||||||
|
from stirling.agents.pdf_create import PdfCreateAgent
|
||||||
from stirling.agents.pdf_edit import PdfEditAgent
|
from stirling.agents.pdf_edit import PdfEditAgent
|
||||||
from stirling.agents.pdf_questions import PdfQuestionAgent
|
from stirling.agents.pdf_questions import PdfQuestionAgent
|
||||||
from stirling.agents.pdf_review import PdfReviewAgent
|
from stirling.agents.pdf_review import PdfReviewAgent
|
||||||
@@ -26,6 +27,7 @@ from stirling.contracts import (
|
|||||||
format_conversation_history,
|
format_conversation_history,
|
||||||
format_file_names,
|
format_file_names,
|
||||||
)
|
)
|
||||||
|
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
|
||||||
from stirling.services import AppRuntime
|
from stirling.services import AppRuntime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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."
|
"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(
|
ToolOutput(
|
||||||
self.unsupported_capability,
|
self.unsupported_capability,
|
||||||
name="unsupported_capability",
|
name="unsupported_capability",
|
||||||
@@ -92,6 +104,8 @@ class OrchestratorAgent:
|
|||||||
"Use delegate_pdf_review when the user wants the PDF returned with review"
|
"Use delegate_pdf_review when the user wants the PDF returned with review"
|
||||||
" comments attached — anything like 'review this', 'annotate with comments',"
|
" comments attached — anything like 'review this', 'annotate with comments',"
|
||||||
" 'leave feedback on the PDF'. "
|
" '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 "
|
"Use delegate_pdf_ingest for any request to convert a PDF to Markdown "
|
||||||
"or extract its content as readable text. "
|
"or extract its content as readable text. "
|
||||||
"Use unsupported_capability when the user asks about the assistant itself "
|
"Use unsupported_capability when the user asks about the assistant itself "
|
||||||
@@ -133,12 +147,13 @@ class OrchestratorAgent:
|
|||||||
return await self._run_pdf_edit(request)
|
return await self._run_pdf_edit(request)
|
||||||
case SupportedCapability.AGENT_DRAFT:
|
case SupportedCapability.AGENT_DRAFT:
|
||||||
return await self._run_agent_draft(request)
|
return await self._run_agent_draft(request)
|
||||||
|
case SupportedCapability.PDF_CREATE:
|
||||||
|
return await self._run_pdf_create(request)
|
||||||
case (
|
case (
|
||||||
SupportedCapability.ORCHESTRATE
|
SupportedCapability.ORCHESTRATE
|
||||||
| SupportedCapability.AGENT_REVISE
|
| SupportedCapability.AGENT_REVISE
|
||||||
| SupportedCapability.AGENT_NEXT_ACTION
|
| SupportedCapability.AGENT_NEXT_ACTION
|
||||||
| SupportedCapability.MATH_AUDITOR_AGENT
|
| SupportedCapability.MATH_AUDITOR_AGENT
|
||||||
| SupportedCapability.PDF_TO_MARKDOWN
|
|
||||||
):
|
):
|
||||||
raise ValueError(f"Cannot resume orchestrator with capability: {capability}")
|
raise ValueError(f"Cannot resume orchestrator with capability: {capability}")
|
||||||
case _ as unreachable:
|
case _ as unreachable:
|
||||||
@@ -175,6 +190,12 @@ class OrchestratorAgent:
|
|||||||
async def _run_pdf_review(self, request: OrchestratorRequest) -> PdfReviewOrchestrateResponse:
|
async def _run_pdf_review(self, request: OrchestratorRequest) -> PdfReviewOrchestrateResponse:
|
||||||
return await PdfReviewAgent(self.runtime).orchestrate(request)
|
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(
|
async def unsupported_capability(
|
||||||
self,
|
self,
|
||||||
ctx: RunContext[OrchestratorDeps],
|
ctx: RunContext[OrchestratorDeps],
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .agent import PdfCreateAgent
|
||||||
|
|
||||||
|
__all__ = ["PdfCreateAgent"]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-primary: #1e3a5f;
|
||||||
|
--color-subtitle: #475569;
|
||||||
|
--color-ref: #6b7280;
|
||||||
|
--color-label: #374151;
|
||||||
|
--color-body: #1a1a1a;
|
||||||
|
--color-border-light: #e2e8f0;
|
||||||
|
--color-border-heading: #cbd5e1;
|
||||||
|
--font-body: "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-size-base: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-body);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.doc-header {
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
padding-bottom: 10pt;
|
||||||
|
border-bottom: 2pt solid var(--color-primary);
|
||||||
|
}
|
||||||
|
.doc-title {
|
||||||
|
font-size: 20pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.doc-subtitle {
|
||||||
|
font-size: 11pt;
|
||||||
|
color: var(--color-subtitle);
|
||||||
|
margin-top: 3pt;
|
||||||
|
}
|
||||||
|
.doc-reference {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--color-ref);
|
||||||
|
margin-top: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sections ── */
|
||||||
|
section {
|
||||||
|
margin-bottom: 16pt;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
section.line-items-section {
|
||||||
|
page-break-inside: auto;
|
||||||
|
break-inside: auto;
|
||||||
|
}
|
||||||
|
section h2 {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-bottom: 0.5pt solid var(--color-border-heading);
|
||||||
|
padding-bottom: 3pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text ── */
|
||||||
|
.text-body p {
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
}
|
||||||
|
.text-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key-value ── */
|
||||||
|
.kv-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.kv-table td {
|
||||||
|
padding: 3pt 0;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.kv-table td.kv-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-label);
|
||||||
|
width: 36%;
|
||||||
|
padding-right: 10pt;
|
||||||
|
}
|
||||||
|
.kv-table td.kv-value {
|
||||||
|
color: var(--color-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Line items ── */
|
||||||
|
.line-items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
}
|
||||||
|
.line-items-table thead tr {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.line-items-table thead th {
|
||||||
|
padding: 5pt 8pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.line-items-table thead th:not(:first-child) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.line-items-table tbody td {
|
||||||
|
padding: 4pt 8pt;
|
||||||
|
border-bottom: 0.5pt solid var(--color-border-light);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.line-items-table tbody td:not(:first-child) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.line-items-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.line-items-table tbody tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
.line-items-table tr.total-row td {
|
||||||
|
padding: 5pt 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
border-top: 1pt solid var(--color-primary);
|
||||||
|
}
|
||||||
|
.line-items-table tr.total-row td:not(:first-child) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bullet list ── */
|
||||||
|
.bullet-list {
|
||||||
|
padding-left: 14pt;
|
||||||
|
}
|
||||||
|
.bullet-list li {
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
.bullet-list li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature ── */
|
||||||
|
.signature-grid {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8pt;
|
||||||
|
}
|
||||||
|
.signatory {
|
||||||
|
display: inline-block;
|
||||||
|
width: 44%;
|
||||||
|
margin-right: 5%;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 1pt solid var(--color-label);
|
||||||
|
height: 28pt;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
.sig-name {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--color-label);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{%- if doc.style %}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
{%- if doc.style.primary_color %}
|
||||||
|
--color-primary: {{ doc.style.primary_color }};
|
||||||
|
{%- endif %}
|
||||||
|
{%- if doc.style.background_color %}
|
||||||
|
--color-bg: {{ doc.style.background_color }};
|
||||||
|
{%- endif %}
|
||||||
|
{%- if doc.style.body_text_color %}
|
||||||
|
--color-body: {{ doc.style.body_text_color }};
|
||||||
|
--color-label: {{ doc.style.body_text_color }};
|
||||||
|
{%- endif %}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{%- endif %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-title">{{ doc.title }}</div>
|
||||||
|
{%- if doc.subtitle %}
|
||||||
|
<div class="doc-subtitle">{{ doc.subtitle }}</div>
|
||||||
|
{%- endif %}
|
||||||
|
{%- if doc.reference_number %}
|
||||||
|
<div class="doc-reference">{{ doc.reference_number }}</div>
|
||||||
|
{%- endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{%- for section in doc.sections %}
|
||||||
|
|
||||||
|
{%- if section.type == "text" %}
|
||||||
|
<section>
|
||||||
|
{%- if section.heading %}
|
||||||
|
<h2>{{ section.heading }}</h2>
|
||||||
|
{%- endif %}
|
||||||
|
<div class="text-body">
|
||||||
|
{%- for para in section.body.split('\n\n') %}
|
||||||
|
<p>{{ para | replace('\n', ' ') }}</p>
|
||||||
|
{%- endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{%- elif section.type == "key_value" %}
|
||||||
|
<section>
|
||||||
|
{%- if section.heading %}
|
||||||
|
<h2>{{ section.heading }}</h2>
|
||||||
|
{%- endif %}
|
||||||
|
<table class="kv-table">
|
||||||
|
<tbody>
|
||||||
|
{%- for label, value in section.pairs %}
|
||||||
|
<tr>
|
||||||
|
<td class="kv-label">{{ label }}</td>
|
||||||
|
<td class="kv-value">{{ value }}</td>
|
||||||
|
</tr>
|
||||||
|
{%- endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{%- elif section.type == "line_items" %}
|
||||||
|
<section class="line-items-section">
|
||||||
|
{%- if section.heading %}
|
||||||
|
<h2>{{ section.heading }}</h2>
|
||||||
|
{%- endif %}
|
||||||
|
<table class="line-items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{%- for col in section.columns %}
|
||||||
|
<th>{{ col }}</th>
|
||||||
|
{%- endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{%- for row in section.rows %}
|
||||||
|
<tr>
|
||||||
|
{%- for cell in row %}
|
||||||
|
<td>{{ cell }}</td>
|
||||||
|
{%- endfor %}
|
||||||
|
</tr>
|
||||||
|
{%- endfor %}
|
||||||
|
{%- if section.total_row %}
|
||||||
|
<tr class="total-row">
|
||||||
|
{%- for cell in section.total_row %}
|
||||||
|
<td>{{ cell }}</td>
|
||||||
|
{%- endfor %}
|
||||||
|
</tr>
|
||||||
|
{%- endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{%- elif section.type == "bullet_list" %}
|
||||||
|
<section>
|
||||||
|
{%- if section.heading %}
|
||||||
|
<h2>{{ section.heading }}</h2>
|
||||||
|
{%- endif %}
|
||||||
|
<ul class="bullet-list">
|
||||||
|
{%- for item in section.items %}
|
||||||
|
<li>{{ item }}</li>
|
||||||
|
{%- endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{%- elif section.type == "signature" %}
|
||||||
|
<section>
|
||||||
|
{%- if section.heading %}
|
||||||
|
<h2>{{ section.heading }}</h2>
|
||||||
|
{%- endif %}
|
||||||
|
<div class="signature-grid">
|
||||||
|
{%- for signatory in section.signatories %}
|
||||||
|
<div class="signatory">
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-name">{{ signatory }}</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -80,6 +80,15 @@ from .pdf_comments import (
|
|||||||
PdfCommentResponse,
|
PdfCommentResponse,
|
||||||
TextChunk,
|
TextChunk,
|
||||||
)
|
)
|
||||||
|
from .pdf_create import (
|
||||||
|
DocumentMeta,
|
||||||
|
DocumentSections,
|
||||||
|
PdfCreateCannotDoResponse,
|
||||||
|
PdfCreateOrchestrateResponse,
|
||||||
|
PdfCreateRequest,
|
||||||
|
PdfCreateResponse,
|
||||||
|
PdfCreateSuccessResponse,
|
||||||
|
)
|
||||||
from .pdf_edit import (
|
from .pdf_edit import (
|
||||||
EditCannotDoResponse,
|
EditCannotDoResponse,
|
||||||
EditClarificationRequest,
|
EditClarificationRequest,
|
||||||
@@ -130,6 +139,8 @@ __all__ = [
|
|||||||
"DeleteDocumentResponse",
|
"DeleteDocumentResponse",
|
||||||
"PurgeOwnerResponse",
|
"PurgeOwnerResponse",
|
||||||
"Discrepancy",
|
"Discrepancy",
|
||||||
|
"DocumentMeta",
|
||||||
|
"DocumentSections",
|
||||||
"DiscrepancyKind",
|
"DiscrepancyKind",
|
||||||
"EditCannotDoResponse",
|
"EditCannotDoResponse",
|
||||||
"EditClarificationRequest",
|
"EditClarificationRequest",
|
||||||
@@ -164,6 +175,11 @@ __all__ = [
|
|||||||
"PdfCommentRequest",
|
"PdfCommentRequest",
|
||||||
"PdfCommentResponse",
|
"PdfCommentResponse",
|
||||||
"PdfContentType",
|
"PdfContentType",
|
||||||
|
"PdfCreateCannotDoResponse",
|
||||||
|
"PdfCreateOrchestrateResponse",
|
||||||
|
"PdfCreateRequest",
|
||||||
|
"PdfCreateResponse",
|
||||||
|
"PdfCreateSuccessResponse",
|
||||||
"PdfEditRequest",
|
"PdfEditRequest",
|
||||||
"PdfEditResponse",
|
"PdfEditResponse",
|
||||||
"PdfEditTerminalResponse",
|
"PdfEditTerminalResponse",
|
||||||
|
|||||||
@@ -88,11 +88,11 @@ class SupportedCapability(StrEnum):
|
|||||||
PDF_EDIT = "pdf_edit"
|
PDF_EDIT = "pdf_edit"
|
||||||
PDF_QUESTION = "pdf_question"
|
PDF_QUESTION = "pdf_question"
|
||||||
PDF_REVIEW = "pdf_review"
|
PDF_REVIEW = "pdf_review"
|
||||||
|
PDF_CREATE = "pdf_create"
|
||||||
AGENT_DRAFT = "agent_draft"
|
AGENT_DRAFT = "agent_draft"
|
||||||
AGENT_REVISE = "agent_revise"
|
AGENT_REVISE = "agent_revise"
|
||||||
AGENT_NEXT_ACTION = "agent_next_action"
|
AGENT_NEXT_ACTION = "agent_next_action"
|
||||||
MATH_AUDITOR_AGENT = "math_auditor_agent"
|
MATH_AUDITOR_AGENT = "math_auditor_agent"
|
||||||
PDF_TO_MARKDOWN = "pdf_to_markdown"
|
|
||||||
|
|
||||||
|
|
||||||
class ConversationMessage(ApiModel):
|
class ConversationMessage(ApiModel):
|
||||||
|
|||||||
@@ -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
|
||||||
|
# <style> block (which would let WeasyPrint fetch an attacker-controlled url() → SSRF).
|
||||||
|
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]{1,30}$")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentStyle(ApiModel):
|
||||||
|
"""Document colours, inferred by the meta planner and rendered into the engine's Jinja
|
||||||
|
template (never sent to Java). Unsafe colours are dropped to ``None``."""
|
||||||
|
|
||||||
|
primary_color: str | None = Field(default=None)
|
||||||
|
background_color: str | None = Field(default=None)
|
||||||
|
body_text_color: str | None = Field(default=None)
|
||||||
|
|
||||||
|
@field_validator("primary_color", "background_color", "body_text_color", mode="after")
|
||||||
|
@classmethod
|
||||||
|
def _drop_unsafe_color(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value if _SAFE_COLOR_RE.fullmatch(value) else None
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedDocument(ApiModel):
|
||||||
|
"""The full document model passed to Jinja for HTML rendering."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
subtitle: str | None = None
|
||||||
|
reference_number: str | None = None
|
||||||
|
style: DocumentStyle | None = None
|
||||||
|
sections: list[DocumentSection]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Planner models ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class SectionDepth(StrEnum):
|
||||||
|
BRIEF = "brief"
|
||||||
|
STANDARD = "standard"
|
||||||
|
DETAILED = "detailed"
|
||||||
|
|
||||||
|
|
||||||
|
class PlannedSection(ApiModel):
|
||||||
|
"""One section in the document plan. Contains structure and intent — no body text."""
|
||||||
|
|
||||||
|
heading: str
|
||||||
|
type: SectionType
|
||||||
|
depth: SectionDepth
|
||||||
|
key_points: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentMeta(ApiModel):
|
||||||
|
"""Document header fields produced by the first planner call.
|
||||||
|
|
||||||
|
Contains everything except the section list. Kept deliberately flat so the
|
||||||
|
JSON schema stays small enough for grammar compilation on all model tiers.
|
||||||
|
Style is expressed as three flat optional strings rather than a nested object
|
||||||
|
for the same reason; assemble() reconstructs DocumentStyle from them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cannot_do_reason: str | None = None
|
||||||
|
title: str = ""
|
||||||
|
subtitle: str | None = None
|
||||||
|
reference_number: str | None = None
|
||||||
|
tone_brief: str = ""
|
||||||
|
shared_terms: dict[str, str] = Field(default_factory=dict)
|
||||||
|
document_context: str = ""
|
||||||
|
style_primary_color: str | None = None
|
||||||
|
style_background_color: str | None = None
|
||||||
|
style_body_text_color: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentSections(ApiModel):
|
||||||
|
"""Section list produced by the second planner call."""
|
||||||
|
|
||||||
|
sections: list[PlannedSection] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPlan(ApiModel):
|
||||||
|
"""Assembled plan: meta + sections. Not used as a direct LLM output schema."""
|
||||||
|
|
||||||
|
cannot_do_reason: str | None = None
|
||||||
|
title: str = ""
|
||||||
|
subtitle: str | None = None
|
||||||
|
reference_number: str | None = None
|
||||||
|
tone_brief: str = ""
|
||||||
|
shared_terms: dict[str, str] = Field(default_factory=dict)
|
||||||
|
document_context: str = ""
|
||||||
|
style: DocumentStyle | None = None
|
||||||
|
sections: list[PlannedSection] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def assemble(cls, meta: DocumentMeta, sections: DocumentSections) -> DocumentPlan:
|
||||||
|
style_fields = {
|
||||||
|
"primary_color": meta.style_primary_color,
|
||||||
|
"background_color": meta.style_background_color,
|
||||||
|
"body_text_color": meta.style_body_text_color,
|
||||||
|
}
|
||||||
|
inferred_style = DocumentStyle(**style_fields) if any(style_fields.values()) else None
|
||||||
|
return cls(
|
||||||
|
cannot_do_reason=meta.cannot_do_reason,
|
||||||
|
title=meta.title,
|
||||||
|
subtitle=meta.subtitle,
|
||||||
|
reference_number=meta.reference_number,
|
||||||
|
tone_brief=meta.tone_brief,
|
||||||
|
shared_terms=meta.shared_terms,
|
||||||
|
document_context=meta.document_context,
|
||||||
|
style=inferred_style,
|
||||||
|
sections=sections.sections,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WrittenSections(ApiModel):
|
||||||
|
"""Sections produced by one section-writer agent for one chunk."""
|
||||||
|
|
||||||
|
sections: list[DocumentSection]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Request/response contracts ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PdfCreateRequest(ApiModel):
|
||||||
|
user_message: str
|
||||||
|
conversation_history: list[ConversationMessage] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PdfCreateSuccessResponse(ApiModel):
|
||||||
|
outcome: Literal["document_created"] = "document_created"
|
||||||
|
document: GeneratedDocument
|
||||||
|
|
||||||
|
|
||||||
|
class PdfCreateCannotDoResponse(ApiModel):
|
||||||
|
outcome: Literal["cannot_do"] = "cannot_do"
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
type PdfCreateResponse = Annotated[
|
||||||
|
PdfCreateSuccessResponse | PdfCreateCannotDoResponse,
|
||||||
|
Field(discriminator="outcome"),
|
||||||
|
]
|
||||||
|
|
||||||
|
type PdfCreateOrchestrateResponse = Annotated[
|
||||||
|
EditPlanResponse | EditCannotDoResponse,
|
||||||
|
Field(discriminator="outcome"),
|
||||||
|
]
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
"""Agent tool IDs, parameter models, and registry.
|
"""Agent tool IDs, parameter models, and registry.
|
||||||
|
|
||||||
tool_models.py is auto-generated from the Java OpenAPI spec. This file is its
|
Hand-maintained counterpart to the generated tool_models.py, for engine-emitted tools
|
||||||
manually-maintained counterpart for tools backed by AI agent pipelines.
|
hidden from the OpenAPI spec: AI-backed agents and deterministic conversions like HTML-to-PDF.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
from stirling.models.base import ApiModel
|
from stirling.models.base import ApiModel
|
||||||
from stirling.models.tool_models import ParamToolModel, ToolEndpoint
|
from stirling.models.tool_models import ParamToolModel, ToolEndpoint
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ from stirling.models.tool_models import ParamToolModel, ToolEndpoint
|
|||||||
class AgentToolId(StrEnum):
|
class AgentToolId(StrEnum):
|
||||||
MATH_AUDITOR_AGENT = "/api/v1/ai/tools/math-auditor-agent"
|
MATH_AUDITOR_AGENT = "/api/v1/ai/tools/math-auditor-agent"
|
||||||
PDF_COMMENT_AGENT = "/api/v1/ai/tools/pdf-comment-agent"
|
PDF_COMMENT_AGENT = "/api/v1/ai/tools/pdf-comment-agent"
|
||||||
|
CREATE_PDF_FROM_HTML_AGENT = "/api/v1/ai/tools/create-pdf-from-html-agent"
|
||||||
|
|
||||||
|
|
||||||
class MathAuditorAgentParams(ApiModel):
|
class MathAuditorAgentParams(ApiModel):
|
||||||
@@ -25,7 +28,12 @@ class PdfCommentAgentParams(ApiModel):
|
|||||||
prompt: str | None = None
|
prompt: str | None = None
|
||||||
|
|
||||||
|
|
||||||
type AgentParamModel = MathAuditorAgentParams | PdfCommentAgentParams
|
class CreatePdfFromHtmlAgentParams(ApiModel):
|
||||||
|
html_content: str
|
||||||
|
filename: str = Field(pattern=r"^.+\.pdf$")
|
||||||
|
|
||||||
|
|
||||||
|
type AgentParamModel = MathAuditorAgentParams | PdfCommentAgentParams | CreatePdfFromHtmlAgentParams
|
||||||
|
|
||||||
type AnyToolId = ToolEndpoint | AgentToolId
|
type AnyToolId = ToolEndpoint | AgentToolId
|
||||||
type AnyParamModel = ParamToolModel | AgentParamModel
|
type AnyParamModel = ParamToolModel | AgentParamModel
|
||||||
@@ -33,4 +41,5 @@ type AnyParamModel = ParamToolModel | AgentParamModel
|
|||||||
AGENT_OPERATIONS: dict[AgentToolId, type[AgentParamModel]] = {
|
AGENT_OPERATIONS: dict[AgentToolId, type[AgentParamModel]] = {
|
||||||
AgentToolId.MATH_AUDITOR_AGENT: MathAuditorAgentParams,
|
AgentToolId.MATH_AUDITOR_AGENT: MathAuditorAgentParams,
|
||||||
AgentToolId.PDF_COMMENT_AGENT: PdfCommentAgentParams,
|
AgentToolId.PDF_COMMENT_AGENT: PdfCommentAgentParams,
|
||||||
|
AgentToolId.CREATE_PDF_FROM_HTML_AGENT: CreatePdfFromHtmlAgentParams,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ def _build_anthropic_http_client() -> httpx.AsyncClient:
|
|||||||
have died in the pool. See ``STIRLING_HTTP_DEBUG`` traces of slice 6
|
have died in the pool. See ``STIRLING_HTTP_DEBUG`` traces of slice 6
|
||||||
on 2026-05-06 for the concrete failure mode this addresses.
|
on 2026-05-06 for the concrete failure mode this addresses.
|
||||||
"""
|
"""
|
||||||
return httpx.AsyncClient(limits=httpx.Limits(max_keepalive_connections=0))
|
return httpx.AsyncClient(
|
||||||
|
limits=httpx.Limits(max_keepalive_connections=0),
|
||||||
|
timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=5.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
"""Tests for the chunked PdfCreateAgent pipeline.
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
1. Section model validation (each section type round-trips correctly)
|
||||||
|
2. Jinja rendering (_render produces valid HTML for each section type)
|
||||||
|
3. _safe_filename produces clean slugs
|
||||||
|
4. _make_chunks groups sections correctly by token budget
|
||||||
|
5. orchestrate() produces the correct EditPlanResponse via planner + writer mocks
|
||||||
|
6. orchestrate() returns EditCannotDoResponse when meta planner signals cannot_do
|
||||||
|
7. orchestrate() returns EditCannotDoResponse when sections planner returns empty list
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import build_app_settings
|
||||||
|
from pydantic_ai.models.test import TestModel
|
||||||
|
from pydantic_ai.profiles import ModelProfile
|
||||||
|
|
||||||
|
from stirling.agents.pdf_create.agent import (
|
||||||
|
PdfCreateAgent,
|
||||||
|
_make_chunks,
|
||||||
|
_safe_filename,
|
||||||
|
)
|
||||||
|
from stirling.contracts import (
|
||||||
|
EditCannotDoResponse,
|
||||||
|
EditPlanResponse,
|
||||||
|
OrchestratorRequest,
|
||||||
|
)
|
||||||
|
from stirling.contracts.pdf_create import (
|
||||||
|
BulletListSection,
|
||||||
|
DocumentMeta,
|
||||||
|
DocumentSections,
|
||||||
|
DocumentStyle,
|
||||||
|
GeneratedDocument,
|
||||||
|
KeyValueSection,
|
||||||
|
LineItemsSection,
|
||||||
|
PlannedSection,
|
||||||
|
SectionDepth,
|
||||||
|
SectionType,
|
||||||
|
SignatureSection,
|
||||||
|
TextSection,
|
||||||
|
WrittenSections,
|
||||||
|
)
|
||||||
|
from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams
|
||||||
|
from stirling.services import build_runtime
|
||||||
|
from stirling.services.runtime import AppRuntime
|
||||||
|
|
||||||
|
_NATIVE_PROFILE = ModelProfile(supports_json_schema_output=True)
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runtime() -> AppRuntime:
|
||||||
|
return build_runtime(build_app_settings())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent(runtime: AppRuntime) -> PdfCreateAgent:
|
||||||
|
return PdfCreateAgent(runtime)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _invoice_doc() -> GeneratedDocument:
|
||||||
|
return GeneratedDocument(
|
||||||
|
title="Invoice",
|
||||||
|
subtitle="Acme Corp",
|
||||||
|
reference_number="Invoice #INV-001",
|
||||||
|
sections=[
|
||||||
|
KeyValueSection(
|
||||||
|
heading="Details",
|
||||||
|
pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06"), ("Currency", "USD")],
|
||||||
|
),
|
||||||
|
LineItemsSection(
|
||||||
|
heading="Line Items",
|
||||||
|
columns=["Description", "Qty", "Unit Price", "Total"],
|
||||||
|
rows=[
|
||||||
|
["Consulting services", "10", "$500.00", "$5,000.00"],
|
||||||
|
["Expenses", "1", "$200.00", "$200.00"],
|
||||||
|
],
|
||||||
|
total_row=["Total", "", "", "$5,200.00"],
|
||||||
|
),
|
||||||
|
TextSection(
|
||||||
|
heading="Payment Terms",
|
||||||
|
body="Payment is due within 30 days.\n\nPlease reference the invoice number.",
|
||||||
|
),
|
||||||
|
SignatureSection(
|
||||||
|
heading="Authorised By",
|
||||||
|
signatories=["Jane Smith, CEO", "Bob Jones, CFO"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_meta() -> DocumentMeta:
|
||||||
|
return DocumentMeta(
|
||||||
|
title="Invoice",
|
||||||
|
subtitle="Acme Corp",
|
||||||
|
tone_brief="Professional business tone.",
|
||||||
|
shared_terms={"the Client": "Acme Corp"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_sections() -> DocumentSections:
|
||||||
|
return DocumentSections(
|
||||||
|
sections=[
|
||||||
|
PlannedSection(
|
||||||
|
heading="Details",
|
||||||
|
type=SectionType.KEY_VALUE,
|
||||||
|
depth=SectionDepth.BRIEF,
|
||||||
|
key_points=["Date: 2026-05-06", "Due: 2026-06-06"],
|
||||||
|
),
|
||||||
|
PlannedSection(
|
||||||
|
heading="Line Items",
|
||||||
|
type=SectionType.LINE_ITEMS,
|
||||||
|
depth=SectionDepth.STANDARD,
|
||||||
|
key_points=["Consulting services, 10h, $500/h", "Expenses, $200"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _written_sections() -> WrittenSections:
|
||||||
|
return WrittenSections(
|
||||||
|
sections=[
|
||||||
|
KeyValueSection(
|
||||||
|
heading="Details",
|
||||||
|
pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06")],
|
||||||
|
),
|
||||||
|
LineItemsSection(
|
||||||
|
heading="Line Items",
|
||||||
|
columns=["Description", "Qty", "Unit Price", "Total"],
|
||||||
|
rows=[["Consulting services", "10", "$500.00", "$5,000.00"]],
|
||||||
|
total_row=["Total", "", "", "$5,000.00"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orchestrator_request(message: str = "Create an invoice for Acme Corp") -> OrchestratorRequest:
|
||||||
|
return OrchestratorRequest(
|
||||||
|
user_message=message,
|
||||||
|
files=[],
|
||||||
|
conversation_history=[],
|
||||||
|
artifacts=[],
|
||||||
|
enabled_endpoints=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Section model validation ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_section_round_trips() -> None:
|
||||||
|
s = TextSection(heading="Summary", body="Hello\n\nWorld")
|
||||||
|
assert s.type == "text"
|
||||||
|
assert s.heading == "Summary"
|
||||||
|
assert "World" in s.body
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_value_section_round_trips() -> None:
|
||||||
|
s = KeyValueSection(pairs=[("Name", "Alice"), ("Role", "Engineer")])
|
||||||
|
assert s.type == "key_value"
|
||||||
|
assert s.pairs[0] == ("Name", "Alice")
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_items_section_with_total_row() -> None:
|
||||||
|
s = LineItemsSection(
|
||||||
|
columns=["Item", "Amount"],
|
||||||
|
rows=[["Widget", "$10"]],
|
||||||
|
total_row=["Total", "$10"],
|
||||||
|
)
|
||||||
|
assert s.total_row is not None
|
||||||
|
assert s.total_row[1] == "$10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_items_section_optional_total_row() -> None:
|
||||||
|
s = LineItemsSection(columns=["Item", "Amount"], rows=[["Widget", "$10"]])
|
||||||
|
assert s.total_row is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bullet_list_section_round_trips() -> None:
|
||||||
|
s = BulletListSection(items=["Alpha", "Beta", "Gamma"])
|
||||||
|
assert s.type == "bullet_list"
|
||||||
|
assert len(s.items) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_section_round_trips() -> None:
|
||||||
|
s = SignatureSection(signatories=["Alice", "Bob"])
|
||||||
|
assert s.type == "signature"
|
||||||
|
assert "Alice" in s.signatories
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_document_optional_fields() -> None:
|
||||||
|
doc = GeneratedDocument(title="Simple Doc", sections=[TextSection(body="Hello")])
|
||||||
|
assert doc.subtitle is None
|
||||||
|
assert doc.reference_number is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Jinja rendering ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_produces_html(agent: PdfCreateAgent) -> None:
|
||||||
|
doc = _invoice_doc()
|
||||||
|
html = agent._render(doc)
|
||||||
|
assert "<!DOCTYPE html>" in html
|
||||||
|
assert "Invoice" in html
|
||||||
|
assert "INV-001" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_includes_all_section_types(agent: PdfCreateAgent) -> None:
|
||||||
|
doc = GeneratedDocument(
|
||||||
|
title="All Sections",
|
||||||
|
sections=[
|
||||||
|
TextSection(body="Some prose text."),
|
||||||
|
KeyValueSection(pairs=[("Key", "Value")]),
|
||||||
|
LineItemsSection(columns=["A", "B"], rows=[["1", "2"]]),
|
||||||
|
BulletListSection(items=["item one"]),
|
||||||
|
SignatureSection(signatories=["Alice"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
html = agent._render(doc)
|
||||||
|
assert "Some prose text." in html
|
||||||
|
assert "Key" in html and "Value" in html
|
||||||
|
assert "<th>" in html
|
||||||
|
assert "item one" in html
|
||||||
|
assert "Alice" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_escapes_html_in_content(agent: PdfCreateAgent) -> None:
|
||||||
|
doc = GeneratedDocument(
|
||||||
|
title="XSS Test",
|
||||||
|
sections=[TextSection(body="<script>alert('xss')</script>")],
|
||||||
|
)
|
||||||
|
html = agent._render(doc)
|
||||||
|
assert "<script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_total_row_present(agent: PdfCreateAgent) -> None:
|
||||||
|
doc = GeneratedDocument(
|
||||||
|
title="Table",
|
||||||
|
sections=[
|
||||||
|
LineItemsSection(
|
||||||
|
columns=["Item", "Total"],
|
||||||
|
rows=[["Widget", "$10"]],
|
||||||
|
total_row=["Total", "$10"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
html = agent._render(doc)
|
||||||
|
assert "total-row" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_no_total_row_skips_tfoot(agent: PdfCreateAgent) -> None:
|
||||||
|
doc = GeneratedDocument(
|
||||||
|
title="Table",
|
||||||
|
sections=[LineItemsSection(columns=["Item"], rows=[["Widget"]])],
|
||||||
|
)
|
||||||
|
html = agent._render(doc)
|
||||||
|
assert "<tfoot>" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_subtitle_and_reference(agent: PdfCreateAgent) -> None:
|
||||||
|
doc = GeneratedDocument(
|
||||||
|
title="My Doc",
|
||||||
|
subtitle="Subtitle Here",
|
||||||
|
reference_number="REF-42",
|
||||||
|
sections=[TextSection(body="Content.")],
|
||||||
|
)
|
||||||
|
html = agent._render(doc)
|
||||||
|
assert "Subtitle Here" in html
|
||||||
|
assert "REF-42" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ── _safe_filename ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_filename_basic() -> None:
|
||||||
|
assert _safe_filename("My Invoice") == "my-invoice.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_filename_strips_special_chars() -> None:
|
||||||
|
assert _safe_filename("Report: Q1/2026!") == "report-q12026.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_filename_empty_title() -> None:
|
||||||
|
assert _safe_filename("!!!") == "document.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
# ── _make_chunks ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _planned(heading: str, depth: SectionDepth) -> PlannedSection:
|
||||||
|
return PlannedSection(
|
||||||
|
heading=heading,
|
||||||
|
type=SectionType.TEXT,
|
||||||
|
depth=depth,
|
||||||
|
key_points=["placeholder"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_chunks_empty_returns_empty() -> None:
|
||||||
|
assert _make_chunks([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_chunks_single_section_is_one_chunk() -> None:
|
||||||
|
chunks = _make_chunks([_planned("Intro", SectionDepth.STANDARD)])
|
||||||
|
assert len(chunks) == 1
|
||||||
|
assert chunks[0].index == 0
|
||||||
|
assert len(chunks[0].sections) == 1
|
||||||
|
assert chunks[0].context_before is None
|
||||||
|
assert chunks[0].context_after is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_chunks_groups_under_ceiling() -> None:
|
||||||
|
# 5 × STANDARD (550 each) = 2750 — fits in one chunk under ceiling of 3000
|
||||||
|
sections = [_planned(f"Section {i}", SectionDepth.STANDARD) for i in range(5)]
|
||||||
|
chunks = _make_chunks(sections)
|
||||||
|
assert len(chunks) == 1
|
||||||
|
assert len(chunks[0].sections) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_chunks_splits_when_over_ceiling() -> None:
|
||||||
|
# 6 × STANDARD (550 each) = 3300 — must split (3000 ceiling)
|
||||||
|
# First chunk: 5 sections (2750), second: 1 section (550)
|
||||||
|
sections = [_planned(f"Section {i}", SectionDepth.STANDARD) for i in range(6)]
|
||||||
|
chunks = _make_chunks(sections)
|
||||||
|
assert len(chunks) == 2
|
||||||
|
assert len(chunks[0].sections) == 5
|
||||||
|
assert len(chunks[1].sections) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_chunks_oversized_section_gets_own_chunk() -> None:
|
||||||
|
# DETAILED (1200) + 3×STANDARD (1650) = 2850; adding a 4th STANDARD (550) = 3400 > 3000.
|
||||||
|
# So first chunk holds DETAILED + 3 STANDARDs; last STANDARD spills to chunk 2.
|
||||||
|
sections = [
|
||||||
|
_planned("Big Table", SectionDepth.DETAILED),
|
||||||
|
_planned("Terms", SectionDepth.STANDARD),
|
||||||
|
_planned("Notes", SectionDepth.STANDARD),
|
||||||
|
_planned("Extra", SectionDepth.STANDARD),
|
||||||
|
_planned("Appendix", SectionDepth.STANDARD),
|
||||||
|
]
|
||||||
|
chunks = _make_chunks(sections)
|
||||||
|
assert len(chunks) == 2
|
||||||
|
assert chunks[0].sections[0].heading == "Big Table"
|
||||||
|
assert len(chunks[0].sections) == 4
|
||||||
|
assert chunks[1].sections[0].heading == "Appendix"
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_chunks_neighbour_context() -> None:
|
||||||
|
# 6 × STANDARD (550 each) = 3300 → splits into chunk of 5 (2750) + chunk of 1 (550)
|
||||||
|
many = [_planned(f"S{i}", SectionDepth.STANDARD) for i in range(5)]
|
||||||
|
many.append(_planned("Last", SectionDepth.STANDARD))
|
||||||
|
chunks = _make_chunks(many)
|
||||||
|
assert len(chunks) == 2
|
||||||
|
assert chunks[0].context_after is not None
|
||||||
|
assert chunks[1].context_before is not None
|
||||||
|
assert chunks[0].context_before is None
|
||||||
|
assert chunks[1].context_after is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_chunks_preserves_section_order() -> None:
|
||||||
|
headings = [f"Section {i}" for i in range(8)]
|
||||||
|
sections = [_planned(h, SectionDepth.STANDARD) for h in headings]
|
||||||
|
chunks = _make_chunks(sections)
|
||||||
|
reassembled = [s.heading for chunk in chunks for s in chunk.sections]
|
||||||
|
assert reassembled == headings
|
||||||
|
|
||||||
|
|
||||||
|
# ── orchestrate() ─────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_orchestrate_returns_plan_step(agent: PdfCreateAgent) -> None:
|
||||||
|
meta = _simple_meta()
|
||||||
|
sections = _simple_sections()
|
||||||
|
written = _written_sections()
|
||||||
|
|
||||||
|
with (
|
||||||
|
agent._meta_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||||
|
),
|
||||||
|
agent._sections_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
|
||||||
|
),
|
||||||
|
agent._writer.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=written.model_dump_json())),
|
||||||
|
):
|
||||||
|
result = await agent.orchestrate(_orchestrator_request())
|
||||||
|
|
||||||
|
assert isinstance(result, EditPlanResponse)
|
||||||
|
assert len(result.steps) == 1
|
||||||
|
step = result.steps[0]
|
||||||
|
assert step.tool == AgentToolId.CREATE_PDF_FROM_HTML_AGENT
|
||||||
|
assert isinstance(step.parameters, CreatePdfFromHtmlAgentParams)
|
||||||
|
assert step.parameters.filename.endswith(".pdf")
|
||||||
|
assert "<!DOCTYPE html>" in step.parameters.html_content
|
||||||
|
assert "Invoice" in step.parameters.html_content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_orchestrate_cannot_do_from_planner(agent: PdfCreateAgent) -> None:
|
||||||
|
cannot_do_meta = DocumentMeta(cannot_do_reason="This is not a document creation request.")
|
||||||
|
|
||||||
|
with agent._meta_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=cannot_do_meta.model_dump_json())
|
||||||
|
):
|
||||||
|
result = await agent.orchestrate(_orchestrator_request("what is 2+2?"))
|
||||||
|
|
||||||
|
assert isinstance(result, EditCannotDoResponse)
|
||||||
|
assert "not a document" in result.reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_orchestrate_empty_sections_returns_cannot_do(agent: PdfCreateAgent) -> None:
|
||||||
|
meta = DocumentMeta(title="Empty", tone_brief=".")
|
||||||
|
empty_sections = DocumentSections(sections=[])
|
||||||
|
|
||||||
|
with (
|
||||||
|
agent._meta_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||||
|
),
|
||||||
|
agent._sections_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=empty_sections.model_dump_json())
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await agent.orchestrate(_orchestrator_request("do the thing"))
|
||||||
|
|
||||||
|
assert isinstance(result, EditCannotDoResponse)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_orchestrate_assembles_multiple_chunks(agent: PdfCreateAgent) -> None:
|
||||||
|
"""Two chunks of written sections are assembled in order into one document."""
|
||||||
|
meta = DocumentMeta(title="Multi-Chunk Doc", tone_brief="Formal.")
|
||||||
|
sections = DocumentSections(
|
||||||
|
sections=[
|
||||||
|
PlannedSection(heading="Intro", type=SectionType.TEXT, depth=SectionDepth.BRIEF, key_points=["x"]),
|
||||||
|
PlannedSection(
|
||||||
|
heading="Details",
|
||||||
|
type=SectionType.KEY_VALUE,
|
||||||
|
depth=SectionDepth.BRIEF,
|
||||||
|
key_points=["y"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# The writer override is shared across all parallel calls, so use a combined
|
||||||
|
# WrittenSections that contains all sections — both chunks will return the same
|
||||||
|
# payload and we verify the final HTML contains the expected content.
|
||||||
|
combined = WrittenSections(
|
||||||
|
sections=[
|
||||||
|
TextSection(heading="Intro", body="Introduction text."),
|
||||||
|
KeyValueSection(heading="Details", pairs=[("Key", "Value")]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
agent._meta_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||||
|
),
|
||||||
|
agent._sections_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
|
||||||
|
),
|
||||||
|
agent._writer.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=combined.model_dump_json())),
|
||||||
|
):
|
||||||
|
result = await agent.orchestrate(_orchestrator_request("Create a multi-chunk doc"))
|
||||||
|
|
||||||
|
assert isinstance(result, EditPlanResponse)
|
||||||
|
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||||
|
assert "Introduction text." in html
|
||||||
|
assert "Details" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent) -> None:
|
||||||
|
"""Style extracted by the meta planner is applied to the rendered HTML."""
|
||||||
|
meta = DocumentMeta(
|
||||||
|
title="Styled Doc",
|
||||||
|
tone_brief="Professional.",
|
||||||
|
style_primary_color="magenta",
|
||||||
|
)
|
||||||
|
sections = _simple_sections()
|
||||||
|
written = _written_sections()
|
||||||
|
|
||||||
|
with (
|
||||||
|
agent._meta_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||||
|
),
|
||||||
|
agent._sections_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
|
||||||
|
),
|
||||||
|
agent._writer.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=written.model_dump_json())),
|
||||||
|
):
|
||||||
|
result = await agent.orchestrate(_orchestrator_request("Make an invoice, magenta styling"))
|
||||||
|
|
||||||
|
assert isinstance(result, EditPlanResponse)
|
||||||
|
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||||
|
assert "magenta" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_applies_style(agent: PdfCreateAgent) -> None:
|
||||||
|
"""DocumentStyle fields are injected as CSS custom properties in the rendered HTML."""
|
||||||
|
doc = GeneratedDocument(
|
||||||
|
title="Styled",
|
||||||
|
sections=[TextSection(body="Content.")],
|
||||||
|
style=DocumentStyle(primary_color="magenta", background_color="#111111"),
|
||||||
|
)
|
||||||
|
html = agent._render(doc)
|
||||||
|
assert "--color-primary: magenta" in html
|
||||||
|
assert "--color-bg: #111111" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_style_drops_unsafe_colors() -> None:
|
||||||
|
"""Unsafe colours (not named/hex) are dropped, closing the <style> url() injection."""
|
||||||
|
safe = DocumentStyle(primary_color="navy", background_color="#1e3a5f", body_text_color="#fff")
|
||||||
|
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
|
||||||
|
"navy",
|
||||||
|
"#1e3a5f",
|
||||||
|
"#fff",
|
||||||
|
)
|
||||||
|
|
||||||
|
unsafe = DocumentStyle(
|
||||||
|
primary_color="red; background: url(http://evil.test/steal)",
|
||||||
|
background_color="expression(alert(1))",
|
||||||
|
body_text_color="navy; }",
|
||||||
|
)
|
||||||
|
assert unsafe.primary_color is None
|
||||||
|
assert unsafe.background_color is None
|
||||||
|
assert unsafe.body_text_color is None
|
||||||
|
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
|
||||||
|
assert DocumentStyle(primary_color="navy\n").primary_color is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
|
||||||
|
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
|
||||||
|
meta = DocumentMeta(
|
||||||
|
title="Doc",
|
||||||
|
tone_brief="Professional.",
|
||||||
|
style_primary_color="blue; background: url(http://evil.test/)",
|
||||||
|
)
|
||||||
|
sections = _simple_sections()
|
||||||
|
written = _written_sections()
|
||||||
|
|
||||||
|
with (
|
||||||
|
agent._meta_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||||
|
),
|
||||||
|
agent._sections_planner.override(
|
||||||
|
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
|
||||||
|
),
|
||||||
|
agent._writer.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=written.model_dump_json())),
|
||||||
|
):
|
||||||
|
result = await agent.orchestrate(_orchestrator_request("make it blue"))
|
||||||
|
|
||||||
|
assert isinstance(result, EditPlanResponse)
|
||||||
|
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||||
|
assert "evil.test" not in html
|
||||||
|
assert "url(" not in html
|
||||||
Generated
+2
@@ -604,6 +604,7 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "jinja2" },
|
||||||
{ name = "opentelemetry-sdk" },
|
{ name = "opentelemetry-sdk" },
|
||||||
{ name = "pgvector" },
|
{ name = "pgvector" },
|
||||||
{ name = "posthog" },
|
{ name = "posthog" },
|
||||||
@@ -630,6 +631,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||||
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
|
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
|
||||||
{ name = "pgvector", specifier = ">=0.3.6" },
|
{ name = "pgvector", specifier = ">=0.3.6" },
|
||||||
{ name = "posthog", specifier = ">=3.0.0" },
|
{ name = "posthog", specifier = ">=3.0.0" },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function useChat() {
|
|||||||
toggleOpen: () => {},
|
toggleOpen: () => {},
|
||||||
setOpen: (_open: boolean) => {},
|
setOpen: (_open: boolean) => {},
|
||||||
sendMessage: async (_content: string) => {},
|
sendMessage: async (_content: string) => {},
|
||||||
|
cancelMessage: () => {},
|
||||||
clearChat: () => {},
|
clearChat: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ interface ChatContextValue {
|
|||||||
toggleOpen: () => void;
|
toggleOpen: () => void;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
sendMessage: (content: string) => Promise<void>;
|
sendMessage: (content: string) => Promise<void>;
|
||||||
|
cancelMessage: () => void;
|
||||||
/** Abort any in-flight request and reset the chat to an empty conversation. */
|
/** Abort any in-flight request and reset the chat to an empty conversation. */
|
||||||
clearChat: () => void;
|
clearChat: () => void;
|
||||||
}
|
}
|
||||||
@@ -432,32 +433,32 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const files = await Promise.all(descriptors.map(downloadFile));
|
const files = await Promise.all(descriptors.map(downloadFile));
|
||||||
|
|
||||||
const operation: ToolOperation = {
|
|
||||||
toolId: "ai-workflow",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
const isVersionMapping =
|
|
||||||
sourceStubs.length > 0 && files.length === sourceStubs.length;
|
|
||||||
const stubs = files.map((file, i) =>
|
|
||||||
isVersionMapping
|
|
||||||
? createChildStub(sourceStubs[i], operation, file)
|
|
||||||
: createNewStirlingFileStub(file),
|
|
||||||
);
|
|
||||||
const stirlingFiles = files.map((file, i) =>
|
|
||||||
createStirlingFile(file, stubs[i].id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sourceStubs.length > 0) {
|
if (sourceStubs.length > 0) {
|
||||||
// Always consume the inputs so merge/split inputs are removed from the workbench.
|
// Always consume the inputs so merge/split inputs are removed from the workbench.
|
||||||
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
|
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
|
||||||
// merge/split they're fresh roots.
|
// merge/split they're fresh roots.
|
||||||
|
const operation: ToolOperation = {
|
||||||
|
toolId: "ai-workflow",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
const isVersionMapping = files.length === sourceStubs.length;
|
||||||
|
const stubs = files.map((file, i) =>
|
||||||
|
isVersionMapping
|
||||||
|
? createChildStub(sourceStubs[i], operation, file)
|
||||||
|
: createNewStirlingFileStub(file),
|
||||||
|
);
|
||||||
|
const stirlingFiles = files.map((file, i) =>
|
||||||
|
createStirlingFile(file, stubs[i].id),
|
||||||
|
);
|
||||||
await fileActions.consumeFiles(
|
await fileActions.consumeFiles(
|
||||||
sourceStubs.map((s) => s.id),
|
sourceStubs.map((s) => s.id),
|
||||||
stirlingFiles,
|
stirlingFiles,
|
||||||
stubs,
|
stubs,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// No inputs were provided (unlikely for completed workflows, but handle it safely).
|
// No inputs: pass raw files so addFiles assigns consistent IDs. Pre-assigning stub IDs
|
||||||
|
// here would cause a fileId mismatch in filesRef, making getFiles() clone the file
|
||||||
|
// on every render and breaking useFileWithUrl's memoisation (continuous PDF reloads).
|
||||||
await fileActions.addFiles(files, { selectFiles: true });
|
await fileActions.addFiles(files, { selectFiles: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -469,6 +470,10 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
|
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const cancelMessage = useCallback(() => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearChat = useCallback(() => {
|
const clearChat = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
@@ -509,7 +514,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
formData.append(`conversationHistory[${i}].role`, message.role);
|
formData.append(`conversationHistory[${i}].role`, message.role);
|
||||||
formData.append(`conversationHistory[${i}].content`, message.content);
|
formData.append(`conversationHistory[${i}].content`, message.content);
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch("/api/v1/ai/orchestrate/stream", {
|
const response = await fetch("/api/v1/ai/orchestrate/stream", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -652,6 +656,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
toggleOpen,
|
toggleOpen,
|
||||||
setOpen,
|
setOpen,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
cancelMessage,
|
||||||
clearChat,
|
clearChat,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -365,32 +365,32 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const files = await Promise.all(descriptors.map(downloadFile));
|
const files = await Promise.all(descriptors.map(downloadFile));
|
||||||
|
|
||||||
const operation: ToolOperation = {
|
|
||||||
toolId: "ai-workflow",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
const isVersionMapping =
|
|
||||||
sourceStubs.length > 0 && files.length === sourceStubs.length;
|
|
||||||
const stubs = files.map((file, i) =>
|
|
||||||
isVersionMapping
|
|
||||||
? createChildStub(sourceStubs[i], operation, file)
|
|
||||||
: createNewStirlingFileStub(file),
|
|
||||||
);
|
|
||||||
const stirlingFiles = files.map((file, i) =>
|
|
||||||
createStirlingFile(file, stubs[i].id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sourceStubs.length > 0) {
|
if (sourceStubs.length > 0) {
|
||||||
// Always consume the inputs so merge/split inputs are removed from the workbench.
|
// Always consume the inputs so merge/split inputs are removed from the workbench.
|
||||||
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
|
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
|
||||||
// merge/split they're fresh roots.
|
// merge/split they're fresh roots.
|
||||||
|
const operation: ToolOperation = {
|
||||||
|
toolId: "ai-workflow",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
const isVersionMapping = files.length === sourceStubs.length;
|
||||||
|
const stubs = files.map((file, i) =>
|
||||||
|
isVersionMapping
|
||||||
|
? createChildStub(sourceStubs[i], operation, file)
|
||||||
|
: createNewStirlingFileStub(file),
|
||||||
|
);
|
||||||
|
const stirlingFiles = files.map((file, i) =>
|
||||||
|
createStirlingFile(file, stubs[i].id),
|
||||||
|
);
|
||||||
await fileActions.consumeFiles(
|
await fileActions.consumeFiles(
|
||||||
sourceStubs.map((s) => s.id),
|
sourceStubs.map((s) => s.id),
|
||||||
stirlingFiles,
|
stirlingFiles,
|
||||||
stubs,
|
stubs,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// No inputs were provided (unlikely for completed workflows, but handle it safely).
|
// No inputs: pass raw files so addFiles assigns consistent IDs. Pre-assigning stub IDs
|
||||||
|
// here would cause a fileId mismatch in filesRef, making getFiles() clone the file
|
||||||
|
// on every render and breaking useFileWithUrl's memoisation (continuous PDF reloads).
|
||||||
await fileActions.addFiles(files, { selectFiles: true });
|
await fileActions.addFiles(files, { selectFiles: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user