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_modethat 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 asynth_groupbatch together. A per-sectionisolatedbuilder also exists. - An optional, editablesystem_promptrides thesystemrole on every LLM call.synthesis_prompt_templateis the editable coordination wrapper for the mega-call;synth.briefis 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
/v1API (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
isolatedtosynthesized; the mega-call is now batched;system_prompt,synth_group, andsynth.briefwere 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
/v1config API (PUT …/config, or a spec +PUT …/spec) and editable in the demo UI — per-sectionprompt/output_template/render_mode/output_type/synth_groupin the section drawer;voicevia 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_needsvia 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:
section.promptis rendered as a Jinja template against the render context (below).- If the document has a voice, the voice preamble is prepended (style only).
- If
output_type == 'cited', a citation rule is appended and the call requests a structured{content, sources}object (see Citations). - One
engine_complete(...)call → the section's content.
2. The synthesis mega-prompt (render_mode: synthesized)¶
Built in engine/synthesize.py::_compose_mega_prompt — one 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.
- The LLM returns
{content, sources: [{label, url}]}and places inline[n]markers in the prose. - Mechanical check (
stamp_cited_sources_verified): a sourceurlmust appear in the gathered context'surlvalues (the allowlist). A url that isn't there was invented → blanked +verified: false. Anti-fabrication, enforced in code. - Verify (
verify_cited_sourcesflag →engine/verify.py::verify_sources): one LLM call per cited section judges whether each source's evidence actually supports the claims → stampssupport: confirmed | unsupported | unchecked(+ a note). Big sources are relevance-windowed so the right slice is judged. - Render stores the stamped
sourceson 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.)