Merge remote-tracking branch 'origin/main' into SaaS-update

# Conflicts:
#	frontend/editor/src/proprietary/components/chat/ChatContext.tsx
#	frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx
This commit is contained in:
James Brunton
2026-06-12 09:58:40 +01:00
59 changed files with 10405 additions and 227 deletions
+1
View File
@@ -5,6 +5,7 @@ description = "AI Document Engine"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.116.0",
"jinja2>=3.1.0",
"pgvector>=0.3.6",
"psycopg[binary,pool]>=3.2",
"pydantic>=2.0.0",
+2
View File
@@ -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",
+22 -1
View File
@@ -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>
+16
View File
@@ -80,6 +80,15 @@ from .pdf_comments import (
PdfCommentResponse,
TextChunk,
)
from .pdf_create import (
DocumentMeta,
DocumentSections,
PdfCreateCannotDoResponse,
PdfCreateOrchestrateResponse,
PdfCreateRequest,
PdfCreateResponse,
PdfCreateSuccessResponse,
)
from .pdf_edit import (
EditCannotDoResponse,
EditClarificationRequest,
@@ -130,6 +139,8 @@ __all__ = [
"DeleteDocumentResponse",
"PurgeOwnerResponse",
"Discrepancy",
"DocumentMeta",
"DocumentSections",
"DiscrepancyKind",
"EditCannotDoResponse",
"EditClarificationRequest",
@@ -164,6 +175,11 @@ __all__ = [
"PdfCommentRequest",
"PdfCommentResponse",
"PdfContentType",
"PdfCreateCannotDoResponse",
"PdfCreateOrchestrateResponse",
"PdfCreateRequest",
"PdfCreateResponse",
"PdfCreateSuccessResponse",
"PdfEditRequest",
"PdfEditResponse",
"PdfEditTerminalResponse",
+1 -1
View File
@@ -88,11 +88,11 @@ class SupportedCapability(StrEnum):
PDF_EDIT = "pdf_edit"
PDF_QUESTION = "pdf_question"
PDF_REVIEW = "pdf_review"
PDF_CREATE = "pdf_create"
AGENT_DRAFT = "agent_draft"
AGENT_REVISE = "agent_revise"
AGENT_NEXT_ACTION = "agent_next_action"
MATH_AUDITOR_AGENT = "math_auditor_agent"
PDF_TO_MARKDOWN = "pdf_to_markdown"
class ConversationMessage(ApiModel):
+226
View File
@@ -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.
tool_models.py is auto-generated from the Java OpenAPI spec. This file is its
manually-maintained counterpart for tools backed by AI agent pipelines.
Hand-maintained counterpart to the generated tool_models.py, for engine-emitted tools
hidden from the OpenAPI spec: AI-backed agents and deterministic conversions like HTML-to-PDF.
"""
from __future__ import annotations
from enum import StrEnum
from pydantic import Field
from stirling.models.base import ApiModel
from stirling.models.tool_models import ParamToolModel, ToolEndpoint
@@ -15,6 +17,7 @@ from stirling.models.tool_models import ParamToolModel, ToolEndpoint
class AgentToolId(StrEnum):
MATH_AUDITOR_AGENT = "/api/v1/ai/tools/math-auditor-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):
@@ -25,7 +28,12 @@ class PdfCommentAgentParams(ApiModel):
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 AnyParamModel = ParamToolModel | AgentParamModel
@@ -33,4 +41,5 @@ type AnyParamModel = ParamToolModel | AgentParamModel
AGENT_OPERATIONS: dict[AgentToolId, type[AgentParamModel]] = {
AgentToolId.MATH_AUDITOR_AGENT: MathAuditorAgentParams,
AgentToolId.PDF_COMMENT_AGENT: PdfCommentAgentParams,
AgentToolId.CREATE_PDF_FROM_HTML_AGENT: CreatePdfFromHtmlAgentParams,
}
+4 -1
View File
@@ -43,7 +43,10 @@ def _build_anthropic_http_client() -> httpx.AsyncClient:
have died in the pool. See ``STIRLING_HTTP_DEBUG`` traces of slice 6
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),
)
class ConcurrencyLimitedModel(WrapperModel):
+564
View File
@@ -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 "&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
+2
View File
@@ -604,6 +604,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "opentelemetry-sdk" },
{ name = "pgvector" },
{ name = "posthog" },
@@ -630,6 +631,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
{ name = "pgvector", specifier = ">=0.3.6" },
{ name = "posthog", specifier = ">=3.0.0" },