Files
88adb7adad 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]>
2026-06-11 14:18:13 +00:00

565 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 "&lt;script&gt;" 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