Skip to content

Rendering & Prompts — how DocGen turns a template into a document

A map of where every prompt comes from and how a document is produced, so you don't have to reverse-engineer it from the engine. Written for someone who knows what DocGen does but not how the LLM calls are assembled.

TL;DR - A document is a list of sections; each has a render_mode that decides how it's produced. Two of the four modes call the LLM. - LLM sections are written in one batched synthesis mega-call (render_mode: synthesized) — split into batches of ≤ 8 sections so a single dropped field can't fail the whole document; sections sharing a synth_group batch together. A per-section isolated builder also exists. - An optional, editable system_prompt rides the system role on every LLM call. synthesis_prompt_template is the editable coordination wrapper for the mega-call; synth.brief is a post-compute, framing-only layer (no facts). - Voice (persona), citations, and verify layer on top — voice is now applied to synthesized sections too (prepended once at the top of each batch), not just isolated ones.

All of this is configured over the /v1 API (create/configure/export/update a template or document) — see ../how-to/authoring-and-config-api.md.

Updated 2026-07-12: the one-big-call rollout converted SITE / phoenix / vs-carbon / dealdesk from isolated to synthesized; the mega-call is now batched; system_prompt, synth_group, and synth.brief were added; voice was restored on the synthesized path. Sections below reflect that.


The pipeline

gather → synthesize → verify (optional) → render
Phase What it does LLM calls
gather Run each data_need (MCP tools, web search, file ingest, or a kind='prompt' agentic retrieval) → gathered[output_key]. per prompt-kind data_need
synthesize Fills all synthesized sections in a batched mega-call (≤ 8 sections/batch; sections sharing a synth_group batch together, with a per-batch retry). Optionally assembles the framing synth.brief first. No-op if the doc has none. ~1 per batch
verify (only when verify_cited_sources is set) fact-check each cited section's sources against their gathered evidence. 1 per cited section
render Produce each section by its render_mode: pull from the synth payload, run a Jinja template, or make the section's own LLM call. 1 per isolated section

engine.compute (derived fields → computed.*) runs between gather and render and feeds the render context; it is pure Python, no LLM.


Is there a system prompt?

Yes, optionally. engine/_llm_io.py::build_messages puts an optional system role message before the user message, and synthesize / render / verify pass the document's system_prompt (an editable per-document/template column) as the system= argument. NULL → a single user message (the historical default), so a document that sets no system prompt is unchanged.

Three document-wide surfaces shape the synthesized mega-call, in addition:

  • synthesis_instruction — a shared instruction for all synthesized sections.
  • synthesis_prompt_template — a full Jinja coordination wrapper that replaces the default mega-prompt assembly; this is where cross-section coordination lives, and it's user-editable.
  • synth.brief — an optional, post-compute, framing-only layer surfaced into the mega-prompt (judgment/positioning; emits no facts).

All of these are configurable over the /v1 config API (PUT …/config, or a spec + PUT …/spec) and editable in the demo UI — per-section prompt/output_template/render_mode/output_type/synth_group in the section drawer; voice via the Voices manager + per-doc selector; system_prompt, synthesis_instruction, synthesis_prompt_template, synthesis_grounding, verify_cited_sources, and models-per-step (gather/synthesize/render/verify) on the document action bar; data_needs via the add-data-source dialog.


The four render modes (per section, set by section.render_mode)

render_mode LLM? Prompt / template source Notes
templated No section.output_template (Jinja) Deterministic. Byte-stable. e.g. Phoenix P1–P3.
composed No assembled from snippets + texts (compose_document) Deterministic snippet assembly (JBRK-style).
isolated Yes — 1 call/section section.prompt (Jinja) The section writes itself in its own call. Still supported; the rolled-out templates now use synthesized instead.
synthesized shares the batched mega-call section.prompt is the section's "brief" inside the mega-prompt Written in the batched synthesis call; content pulled from the synth payload at render. e.g. GoA, SITE, phoenix, vs-carbon, dealdesk.

output_type (string | list_str | int | float | bool | cited) shapes the expected output of an LLM section; cited additionally turns on citations + (optionally) verify — see below.


The two LLM prompts

1. The isolated-section prompt (render_mode: isolated)

Built in engine/render.py::_render_custom:

  1. section.prompt is rendered as a Jinja template against the render context (below).
  2. If the document has a voice, the voice preamble is prepended (style only).
  3. If output_type == 'cited', a citation rule is appended and the call requests a structured {content, sources} object (see Citations).
  4. One engine_complete(...) call → the section's content.

2. The synthesis mega-prompt (render_mode: synthesized)

Built in engine/synthesize.py::_compose_mega_promptone call fills every synthesized section. Yes, synthesis has a prompt; this is it. Default assembly:

<intro> + document title/description
+ synthesis_instruction          (optional, template-level)
+ Reference values: texts + this doc's fields
+ Gathered context               (COMPACTED — page_text/page_html dropped,
                                   snippet truncated to 600 chars)
+ "Sections to write:"  one line per section = its `prompt` (the brief)
+ CITATION_RULE                  (if any section is output_type=cited and grounding ≠ off)

The response is a dynamic Pydantic schema with one field per section (output_type sets each field's type), so the single call returns all sections at once.

Override: set synthesis_prompt_template (a full Jinja prompt) on the template to replace the default assembly. The override context additionally exposes gathered_raw (the full, uncompacted gathered data — including page_text), citation_rule, instruction, grounding.

Why gathered is compacted for synthesis: with several search lanes the full page text blows the context window, so synthesis sees snippets and the verify phase reads full evidence later. (This is also why feeding a large source extract to a synthesized writer needs the override path's gathered_raw — or, as Phoenix does, an isolated cited section, whose prompt sees the full gathered/computed directly.)


The render context (what {{ ... }} can reference)

templated and isolated Jinja both get the same names:

Name Is Example
gathered raw gathered data, keyed by data_need.output_key {{ gathered.deal.result.pid }}
computed derived fields from engine.compute (domain math/formatting) {{ computed.deal.total_limit_fmt }}
synth the synthesis payload (synthesized sections' content)
texts org-level reusable text snippets (+ this doc's fields overlaid) {{ texts.company }}
snippets rendered snippet bodies {{ snippets.intro }}
document {title, description} {{ document.title }}

Rendered with StrictUndefined (a missing variable is an error, not blank) and trim_blocks/lstrip_blocks (so {% %} tags can sit on their own indented lines without leaking whitespace).


Voice

A Voice is a writing-style profile (description + optional samples). When a document has one, the voice preamble is prepended as a ## Writing style block — to isolated sections (in _render_custom) and to synthesized sections (once, at the top of each mega-call batch, via _prepend_voice). Style only — the guardrail forbids reusing the samples' facts (voice = how; the document's own data = what). A voiceless document renders byte-identically (no preamble).


Citations & verify (output_type: cited)

Citations were decoupled from synthesis — they work on isolated and synthesized sections.

  1. The LLM returns {content, sources: [{label, url}]} and places inline [n] markers in the prose.
  2. Mechanical check (stamp_cited_sources_verified): a source url must appear in the gathered context's url values (the allowlist). A url that isn't there was invented → blanked + verified: false. Anti-fabrication, enforced in code.
  3. Verify (verify_cited_sources flag → engine/verify.py::verify_sources): one LLM call per cited section judges whether each source's evidence actually supports the claims → stamps support: confirmed | unsupported | unchecked (+ a note). Big sources are relevance-windowed so the right slice is judged.
  4. Render stores the stamped sources on the section; the UI shows inline [n] links + ✓/⚠ chips.

If the structured output is malformed (the model occasionally nests the JSON inside content), render recovers via one unconstrained call + a lenient parse that unwraps it, so citations are kept rather than the section erroring.


Where each piece lives

Concern File
LLM call + message assembly (the system= hook) engine/_llm_io.py (build_messages, engine_complete)
Per-section render + isolated prompt + voice + cited recovery engine/render.py (_render_custom, _render_template)
Synthesis mega-prompt + dynamic schema + CITATION_RULE engine/synthesize.py (_compose_mega_prompt)
Verify fact-check prompt + verify_sources engine/verify.py
Derived fields (computed.*) engine/compute* + solution compute (e.g. fixtures/phoenix_compute.py)

Worked example — the Phoenix firm-order memo

Para Mode How it's produced
P1 Reconfirmation, P2 Outcome, P3 Structure templated Jinja over computed.deal.* — deterministic, byte-equal to gold. No LLM.
P4 Rationale, P5 Underlying Book synthesized, output_type: cited Written in the (single-batch) synthesis call, carrying the odyssey-underwriter voice, grounded in computed.deal.source_extract (the full cat-model + PDF extract the Phoenix MCP serves), citing the phoenix://… source urls, then verified.

So a single Phoenix document mixes deterministic templated paragraphs and voiced, cited, verified LLM paragraphs. (P4/P5 were isolated before the one-big-call rollout; they are now two synthesized sections — a single batch, since 2 ≤ cap.)