mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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
@@ -2,6 +2,7 @@
|
||||
|
||||
from .execution import ExecutionPlanningAgent
|
||||
from .orchestrator import OrchestratorAgent
|
||||
from .pdf_create import PdfCreateAgent
|
||||
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
|
||||
from .pdf_questions import PdfQuestionAgent
|
||||
from .pdf_review import PdfReviewAgent
|
||||
@@ -10,6 +11,7 @@ from .user_spec import UserSpecAgent
|
||||
__all__ = [
|
||||
"ExecutionPlanningAgent",
|
||||
"OrchestratorAgent",
|
||||
"PdfCreateAgent",
|
||||
"PdfEditAgent",
|
||||
"PdfEditParameterSelector",
|
||||
"PdfEditPlanSelection",
|
||||
|
||||
@@ -8,6 +8,7 @@ from pydantic_ai import Agent
|
||||
from pydantic_ai.output import ToolOutput
|
||||
from pydantic_ai.tools import RunContext
|
||||
|
||||
from stirling.agents.pdf_create import PdfCreateAgent
|
||||
from stirling.agents.pdf_edit import PdfEditAgent
|
||||
from stirling.agents.pdf_questions import PdfQuestionAgent
|
||||
from stirling.agents.pdf_review import PdfReviewAgent
|
||||
@@ -26,6 +27,7 @@ from stirling.contracts import (
|
||||
format_conversation_history,
|
||||
format_file_names,
|
||||
)
|
||||
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
|
||||
from stirling.services import AppRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -76,6 +78,16 @@ class OrchestratorAgent:
|
||||
"Delegate requests to convert a PDF to Markdown or extract its content as readable text."
|
||||
),
|
||||
),
|
||||
ToolOutput(
|
||||
self.delegate_pdf_create,
|
||||
name="delegate_pdf_create",
|
||||
description=(
|
||||
"Delegate requests to create a new PDF document from scratch based on a"
|
||||
" description. Use this when the user wants to generate a new document"
|
||||
" (e.g. 'create an invoice', 'write a report', 'make a contract',"
|
||||
" 'draft a letter'). No input file is required."
|
||||
),
|
||||
),
|
||||
ToolOutput(
|
||||
self.unsupported_capability,
|
||||
name="unsupported_capability",
|
||||
@@ -92,6 +104,8 @@ class OrchestratorAgent:
|
||||
"Use delegate_pdf_review when the user wants the PDF returned with review"
|
||||
" comments attached — anything like 'review this', 'annotate with comments',"
|
||||
" 'leave feedback on the PDF'. "
|
||||
"Use delegate_pdf_create when the user wants to generate a new document from"
|
||||
" scratch with no input file — invoices, reports, letters, contracts, etc. "
|
||||
"Use delegate_pdf_ingest for any request to convert a PDF to Markdown "
|
||||
"or extract its content as readable text. "
|
||||
"Use unsupported_capability when the user asks about the assistant itself "
|
||||
@@ -133,12 +147,13 @@ class OrchestratorAgent:
|
||||
return await self._run_pdf_edit(request)
|
||||
case SupportedCapability.AGENT_DRAFT:
|
||||
return await self._run_agent_draft(request)
|
||||
case SupportedCapability.PDF_CREATE:
|
||||
return await self._run_pdf_create(request)
|
||||
case (
|
||||
SupportedCapability.ORCHESTRATE
|
||||
| SupportedCapability.AGENT_REVISE
|
||||
| SupportedCapability.AGENT_NEXT_ACTION
|
||||
| SupportedCapability.MATH_AUDITOR_AGENT
|
||||
| SupportedCapability.PDF_TO_MARKDOWN
|
||||
):
|
||||
raise ValueError(f"Cannot resume orchestrator with capability: {capability}")
|
||||
case _ as unreachable:
|
||||
@@ -175,6 +190,12 @@ class OrchestratorAgent:
|
||||
async def _run_pdf_review(self, request: OrchestratorRequest) -> PdfReviewOrchestrateResponse:
|
||||
return await PdfReviewAgent(self.runtime).orchestrate(request)
|
||||
|
||||
async def delegate_pdf_create(self, ctx: RunContext[OrchestratorDeps]) -> PdfCreateOrchestrateResponse:
|
||||
return await self._run_pdf_create(ctx.deps.request)
|
||||
|
||||
async def _run_pdf_create(self, request: OrchestratorRequest) -> PdfCreateOrchestrateResponse:
|
||||
return await PdfCreateAgent(self.runtime).orchestrate(request)
|
||||
|
||||
async def unsupported_capability(
|
||||
self,
|
||||
ctx: RunContext[OrchestratorDeps],
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user