Narrative Template Rendering
A proposal narrative that is typed by hand each cycle is a narrative that drifts: a biosketch header formatted three different ways across three submissions, a facilities statement that quietly loses a required sentence, a human-subjects paragraph pasted into an application that never triggered it. Narrative template rendering removes that drift by treating the assembled proposal as a pure function of structured, already-validated data and a fixed agency template — the same inputs must always produce byte-identical output. This section sits inside Document Assembly & Audit, the stage where reviewed data becomes the actual documents a sponsor receives, and it is where a single deterministic rendering discipline decides whether every downstream compliance check can trust what it is measuring. Render the narrative non-deterministically and the page-limit and font enforcement layer is validating a document that no longer matches the record; render it deterministically and the entire package becomes reproducible and auditable.
The organizing principle is a strict separation between content and presentation. Content is structured data — an aims list, a hypothesis string, a significance paragraph, a roster of key personnel — that has already passed schema validation with Pydantic. Presentation is the agency template: the National Institutes of Health (NIH) biosketch header, the National Science Foundation (NSF) project-description ordering, the Department of Defense (DoD) volume structure. The template never invents content and the data never carries formatting. Keeping that boundary sharp is what makes conditional sections, boilerplate injection, and whitespace control tractable instead of a source of one-off exceptions.
Prerequisites and environment setup
The patterns here assume Python 3.10 or newer for modern type-hint syntax (list[Aim], str | None) and Pydantic v2 semantics. Rendering uses Jinja2 for text and structured output and docxtpl for Microsoft Word documents, which is the deliverable format most sponsor forms and narrative attachments actually expect. Pin every rendering dependency so that a narrative assembled today reproduces exactly when it is re-rendered after a data correction or an agency template revision:
python -m venv .venv
source .venv/bin/activate
pip install "jinja2==3.1.4" "docxtpl==0.18.0" "pydantic>=2.6"
docxtpl sits on top of python-docx and drives a Jinja2 environment internally, so the same template dialect governs both plain-text and DOCX rendering. Two assumptions underpin everything below. First, the input data is trusted only after validation: rendering is not the place to discover that a required field is missing, so the context model is constructed from validated data or not at all. Second, the agency template is a versioned artifact under source control, hashed at load time, so the audit trail described in the parent assembly and audit section can prove which template edition produced a given document.
Core mechanism — how Jinja2 and docxtpl render a document
Jinja2 compiles a template string into a Python function once, then calls that function with a context dictionary to produce output. Determinism is not automatic; it is the result of pinning three environment settings that otherwise vary with process state:
autoescapemust be set explicitly. For DOCX,docxtplescapes XML for you, but any plain-text or HTML rendering you do alongside it needsautoescape=Trueso that an ampersand in a project title never corrupts the markup.undefined=StrictUndefinedturns a missing template variable into an immediate error instead of an empty string. A silently blank facilities statement is exactly the failure that reaches a reviewer; a raised exception never leaves the build.- Ordering. Any time the template iterates a mapping, the underlying data must be ordered — a sorted key list or an ordered model — because dictionary iteration order that depends on insertion history will produce two different documents from the same logical data.
from jinja2 import Environment, StrictUndefined, select_autoescape
def build_env() -> Environment:
"""Construct a rendering environment tuned for deterministic output.
StrictUndefined makes a missing variable fail loudly; trim/lstrip block
control keeps template whitespace from leaking into the rendered page.
"""
env = Environment(
autoescape=select_autoescape(default_for_string=True),
undefined=StrictUndefined, # missing var -> UndefinedError, never ""
trim_blocks=True, # drop the newline after a {% %} block tag
lstrip_blocks=True, # strip leading whitespace before a block tag
keep_trailing_newline=False, # normalize the final newline
)
return env
Determinism has a fourth requirement that lives outside the environment object entirely: the render must not read ambient process state. A template that calls now() for a submission date, seeds numbering from a random value, or formats a currency figure through the process locale will produce a different document on a different machine or a different day, and the golden-file test that should catch a regression instead flickers. The discipline is to pass every such value in as data — a frozen generated_at timestamp supplied by the caller, an explicit locale string, pre-sorted collections — so the render is a pure function of its context. Where the template iterates a mapping, feed it a sorted key list rather than the dict directly, because two logically identical records built by different code paths can still iterate in different insertion orders.
For DOCX, docxtpl reads a .docx file whose paragraphs contain Jinja2 tags ({{ hypothesis }}, {% for aim in aims %}), renders them against the context, and writes a new .docx with formatting preserved. Because Word stores text as XML runs, a tag can be split across runs by an errant keystroke during template authoring; docxtpl reassembles tags within a paragraph, but a tag broken across paragraphs will not render — which is why templates are validated as fixtures, not edited casually.
Rendering-safe implementation — a typed context feeding the template
The production pattern never hands a raw dictionary to a template. It builds a typed context model with Pydantic v2, so that the shape of the data the template depends on is declared once and enforced at construction. The template then references only attributes that are guaranteed to exist, and StrictUndefined catches any typo in the template itself.
from pydantic import BaseModel, Field, field_validator
from jinja2 import Environment
class Investigator(BaseModel):
name: str
era_commons_id: str | None = None
role: str
class NarrativeContext(BaseModel):
"""The complete, validated input to a narrative render.
Every field the template reads lives here; nothing is optional-by-accident.
"""
opportunity_id: str
project_title: str = Field(min_length=1, max_length=200)
hypothesis: str
significance: str
aims: list[str] = Field(min_length=1) # at least one Specific Aim
key_personnel: list[Investigator]
has_human_subjects: bool = False # gates a conditional section
human_subjects_narrative: str | None = None
@field_validator("human_subjects_narrative")
@classmethod
def _require_when_flagged(cls, v: str | None, info) -> str | None:
# If the flag is set, the narrative text must be present and non-empty.
if info.data.get("has_human_subjects") and not (v and v.strip()):
raise ValueError("human_subjects_narrative required when flag is set")
return v
def render_docx(template_path: str, ctx: NarrativeContext, out_path: str) -> None:
"""Render a DOCX narrative from a validated context, deterministically."""
from docxtpl import DocxTemplate
doc = DocxTemplate(template_path)
# model_dump with sorted structures -> stable iteration order every run
doc.render(ctx.model_dump(), jinja_env=None) # docxtpl supplies its own env
doc.save(out_path)
The conditional human-subjects section is the canonical example of presentation following content. The template wraps that narrative in {% if has_human_subjects %} … {% endif %}; the section appears only when the structured record flags it, and the validator guarantees the text exists whenever the flag is set. There is no code path where a human-subjects heading renders with an empty body, and none where a non-human-subjects application accidentally includes the section. Boilerplate — the biosketch header block, the standard facilities and equipment statement — is injected the same way: it lives in the template as static text or as an included partial keyed by agency, never pasted into the data.
Whitespace and pagination control belong to this stage precisely because the rendered length is what the downstream page-limit checks measure. The three settings in the environment — trim_blocks, lstrip_blocks, and a normalized trailing newline — exist to stop template control flow from leaking vertical space into the document. A {% for aim in aims %} block written without them emits a blank line after every iteration, so a four-aim narrative silently grows four extra empty paragraphs; in a DOCX those become real vertical space that can push a paragraph across a page boundary. The rule is that spacing between rendered sections is a property of the template’s paragraph styles, never of blank runs injected by iteration. When that holds, the rendered page count is an honest function of the content, and the page-limit and font enforcement engine that later measures it is validating the same length the writer sees — not an artifact of loose templating.
Agency-specific configuration
The same rendering engine must emit structurally different narratives per agency, because NIH, NSF, and DoD prescribe different section orders, different boilerplate, and different conditional triggers. Hard-coding one agency’s structure into the template guarantees a malformed submission for the others. Treat the template set and its injected boilerplate as versioned configuration keyed by agency, resolved against the record produced by NIH FOA schema mapping and its sibling taxonomies.
| Rendering concern | NIH (R-series narrative) | NSF (project description) | DoD (BAA technical volume) |
|---|---|---|---|
| Narrative unit rendered | Specific Aims + Research Strategy | Project Description | Volume I technical narrative |
| Ordered headings | Significance, Innovation, Approach | Overview, Intellectual Merit, Broader Impacts | Objectives, Approach, Deliverables |
| Boilerplate injected | Biosketch header, facilities statement | Results from Prior NSF Support block | Distribution statement, export-control notice |
| Conditional section trigger | Human-subjects / clinical-trial flag | Data-management plan reference | ITAR/EAR handling clause |
| Font/margin envelope to honor | 11 pt, 0.5 in margins | 10 pt (Arial et al.), 1 in margins | Per-Broad Agency Announcement (BAA) |
| Smart-quote policy | Straight quotes in code identifiers | Straight quotes in code identifiers | Straight quotes; ITAR terms verbatim |
Each agency profile names a template file, an ordered heading list, and a boilerplate partial to include; the renderer selects the profile by opportunity, never by branching inside the template. Export-control boilerplate for a DoD Broad Agency Announcement (BAA) is not optional prose — International Traffic in Arms Regulations (ITAR) and Export Administration Regulations (EAR) notices must render verbatim — so that text lives in a locked partial that the data cannot alter. Spelling out the envelope in the profile also lets the renderer emit output that the downstream enforcement layer will accept on the first pass rather than after a rejection.
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class AgencyNarrativeProfile:
agency: str
template: str # path to the versioned .docx template
headings: tuple[str, ...]
boilerplate_partial: str
font_floor_pt: float
PROFILES: dict[str, AgencyNarrativeProfile] = {
"NIH": AgencyNarrativeProfile(
"NIH", "templates/nih_narrative.docx",
("Significance", "Innovation", "Approach"),
"partials/nih_boilerplate.xml", 11.0),
"NSF": AgencyNarrativeProfile(
"NSF", "templates/nsf_project_description.docx",
("Overview", "Intellectual Merit", "Broader Impacts"),
"partials/nsf_prior_support.xml", 10.0),
"DoD": AgencyNarrativeProfile(
"DoD", "templates/dod_volume_i.docx",
("Objectives", "Approach", "Deliverables"),
"partials/dod_export_control.xml", 10.0),
}
Error handling and edge cases
Rendering fails in a small set of recurring ways, and a production renderer converts each into a routed, diagnosable outcome rather than a corrupt document that passes shallow inspection:
- Missing template variable. With
StrictUndefined, a template referencing{{ facilities }}when the context has no such field raisesUndefinedErrorat render time. Catch it, report the offending variable name, and fail the build — never fall back to an empty string, which produces a silently truncated narrative. - Missing or unreadable template.
docxtplraises when the.docxtemplate is absent or corrupt. Treat a missing template as a configuration error keyed to the agency profile, not a data problem, and route it to the operator rather than the grant writer. - Encoding. Templates and data must be UTF-8 end to end. A Latin-1 fragment pasted into a template surfaces as mojibake in the DOCX; normalize all inputs to UTF-8 on load and assert it, because the corruption is invisible until a reviewer opens the file.
- Smart quotes and ligatures. Word autocorrect turns straight quotes into curly ones and hyphens into en-dashes. Inside prose that is cosmetic, but inside a rendered code identifier, a grant number, or an ITAR term it changes the string. Normalize typographic substitutions in the boilerplate partials and keep verbatim regulatory terms out of any auto-correcting field.
- Whitespace and pagination.
trim_blocksandlstrip_blocksstop template control tags from injecting stray blank lines, but a{% for %}over aims can still emit a trailing paragraph that pushes content onto an extra page. Control it in the template with explicit spacing, never by adding blank runs, so the rendered length is a faithful function of the content and the downstream page count stays honest.
from jinja2 import UndefinedError
class RenderError(Exception):
"""Raised when a narrative cannot be produced from its inputs."""
def safe_render(template_path: str, ctx: NarrativeContext, out_path: str) -> None:
try:
render_docx(template_path, ctx, out_path)
except UndefinedError as exc: # missing/typo'd template var
raise RenderError(f"Template references an undefined field: {exc}") from exc
except FileNotFoundError as exc: # missing template artifact
raise RenderError(f"Template not found: {template_path}") from exc
The rule is uniform: fail loudly, name the field or file at fault, and never emit a document that omits a required section while looking complete. An empty facilities statement is worse than a crash, because it clears a visual skim and fails at the sponsor.
Integration with the downstream pipeline
A rendered narrative is not a finished submission; it is one validated component that flows into packaging and is then measured by compliance enforcement. The DOCX (and its PDF conversion) produced here feeds PDF package generation, which stitches the narrative together with forms and biosketches into the single assembled document a sponsor receives. Before that package is trusted, the page-limit and font enforcement engine reads the very font-size and margin metadata the render was tuned to respect — which is why honoring the agency envelope at render time, not after a rejection, is the whole point. The deepest walkthrough in this section, rendering NIH Specific Aims pages from structured data, shows the full loop for the single highest-leverage page of an R01.
The contract between stages is deliberately narrow: rendering never decides whether a document is compliant, it only produces a faithful, reproducible artifact whose length and typography are an honest function of the validated content. That separation is what keeps the assembled package auditable — a reviewer can trace any submitted paragraph back to the exact data record and template version that produced it.
Testing and verification
Rendering earns trust through determinism tests and golden files, not through eyeballing a Word document. The core property is that the same context and template always produce the same bytes; the second property is that a golden reference render is reproduced exactly, so a template change that alters output is caught in review rather than at the sponsor.
import hashlib
import pytest
def _render_bytes(template: str, ctx: NarrativeContext) -> bytes:
import io
from docxtpl import DocxTemplate
doc = DocxTemplate(template)
doc.render(ctx.model_dump())
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
def test_render_is_deterministic(nih_ctx: NarrativeContext) -> None:
a = _render_bytes("templates/nih_narrative.docx", nih_ctx)
b = _render_bytes("templates/nih_narrative.docx", nih_ctx)
assert hashlib.sha256(a).digest() == hashlib.sha256(b).digest()
def test_matches_golden(nih_ctx: NarrativeContext) -> None:
rendered = _render_bytes("templates/nih_narrative.docx", nih_ctx)
with open("tests/golden/nih_narrative.docx", "rb") as fh:
assert rendered == fh.read() # template/data drift fails the build
def test_human_subjects_section_is_conditional(base_ctx: NarrativeContext) -> None:
off = base_ctx.model_copy(update={"has_human_subjects": False})
on = base_ctx.model_copy(update={
"has_human_subjects": True,
"human_subjects_narrative": "Human subjects protections are ...",
})
assert b"Human Subjects" not in _render_bytes("templates/nih_narrative.docx", off)
assert b"Human Subjects" in _render_bytes("templates/nih_narrative.docx", on)
def test_missing_flagged_narrative_fails_validation() -> None:
with pytest.raises(ValueError):
NarrativeContext( # flag set but narrative absent
opportunity_id="PA-25-123", project_title="X", hypothesis="h",
significance="s", aims=["a1"], key_personnel=[],
has_human_subjects=True, human_subjects_narrative=None)
Beyond the automated suite, a manual acceptance pass catches what byte comparison cannot: confirm that boilerplate rendered verbatim, that curly quotes never crept into a code identifier or grant number, that the conditional sections appeared exactly when their flags were set, and that the rendered length leaves headroom under the agency envelope so enforcement passes on the first attempt. Only once those hold should the document be released to packaging. Done this way, narrative template rendering turns validated data into audit-ready, byte-reproducible documents — the deterministic foundation every later assembly and compliance stage depends on.
Related
- Rendering NIH Specific Aims pages from structured data — the deepest walkthrough for the highest-leverage page of an R01.
- PDF package generation — where the rendered narrative is assembled into the submitted document.
- Page-limit & font enforcement — the engine that measures the envelope this stage renders to.
- Schema validation with Pydantic — the gate that hardens the data before it is ever rendered.
- Audit logging & provenance — recording which template and data version produced each document.
Up one level: Document Assembly & Audit Logging