Templating Compliance Narratives with Jinja
A compliance report has two parts: the results, which are structured data, and the narrative, which is the prose explaining what the results mean. Generating the second from the first is straightforward templating right up until a value is missing, at which point a careless template renders “The building exceeds the maximum height of feet” — a sentence that reads as authoritative and states nothing. This guide builds templates that cannot produce that sentence: strict undefined handling, filters that format units and nulls explicitly, and a rendering step that fails loudly rather than emitting a gap. It is the prose layer of audit-ready report generation.
Prerequisites
Step-by-step
Step 1: Configure the environment to fail on a missing value
This single setting is most of the value in this guide. Jinja’s default renders an undefined variable as an empty string; StrictUndefined raises instead.
from jinja2 import Environment, FileSystemLoader, StrictUndefined
env = Environment(
loader=FileSystemLoader("templates"),
undefined=StrictUndefined, # a missing value is an error, not a blank
autoescape=True, # HTML output is escaped by default
trim_blocks=True,
lstrip_blocks=True,
)
A compliance narrative is one of the few document types where a missing value must stop the process. A blank in a marketing page is a cosmetic defect; a blank in a determination is a statement with a hole in it that a reader will fill in themselves.
Step 2: Separate the facts from the phrasing
The template’s job is phrasing. Every fact should already exist in the context, computed and formatted before rendering — because a template that computes is a template that has to be tested, and templates are the worst place in a codebase to put logic.
def narrative_context(result: dict, rule: dict, parcel: dict) -> dict:
"""Everything the template needs, decided before rendering begins."""
return {
"parcel_id": parcel["parcel_id"],
"address": parcel.get("situs_address") or "address not recorded",
"district": parcel["zoning_district"],
"rule_id": rule["id"],
"citation": rule["citation"]["section"],
"adopted": rule["citation"]["adopted"],
"measure_label": rule["measure_label"],
"measured": result["measured_value"],
"required": result["threshold"],
"unit": result["unit"],
"verdict": result["verdict"], # complies | exceeds | indeterminate
"margin": result.get("margin"),
"basis": result.get("basis"),
}
Note address resolving its own fallback. Doing that in Python rather than in the template means the fallback text is testable and appears once, instead of being repeated in every template that mentions an address.
Step 3: Write filters for the formats that recur
Units, dates and null-safe numbers appear in every sentence, and a filter is the only way to keep them consistent across a document set.
def ft_in(value, unit="ft"):
"""25.0 -> '25 feet'; 25.5 -> '25.5 feet'. Never a bare number."""
if value is None:
return "not measured"
names = {"ft": "feet", "us-ft": "feet", "m": "metres", "sq ft": "square feet"}
n = f"{value:,.0f}" if float(value).is_integer() else f"{value:,.1f}"
return f"{n} {names.get(unit, unit)}"
def verdict_phrase(verdict):
return {
"complies": "meets",
"exceeds": "does not meet",
"indeterminate": "cannot be determined against",
}[verdict] # KeyError on an unknown verdict, deliberately
env.filters["ft_in"] = ft_in
env.filters["verdict_phrase"] = verdict_phrase
The KeyError on an unknown verdict is intentional in the same spirit as StrictUndefined. A new verdict value added upstream should break rendering rather than silently produce a sentence missing its verb.
Step 4: Write the template as complete sentences per branch
The temptation is one sentence with conditionals inside it. It produces grammatically brittle output and is unreadable. Write a whole sentence per outcome instead.
The standard of § could not be determined for parcel
().
Every branch names the citation, the standard and the measurement, so no rendered sentence depends on a heading or an adjacent table for its meaning. That property matters because determinations get quoted in isolation — a sentence pasted into an email has to still be true and still be traceable.
Step 5: Render into a document, and record the template version
The narrative is part of the evidence, so the template that produced it is part of the provenance.
import hashlib
def render_narrative(template_name: str, context: dict) -> dict:
source = env.loader.get_source(env, template_name)[0]
template = env.get_template(template_name)
text = template.render(**context).strip()
return {
"text": " ".join(text.split()), # normalise the whitespace Jinja leaves
"template": template_name,
"template_sha256": hashlib.sha256(source.encode()).hexdigest()[:16],
"context_keys": sorted(context),
}
Recording the template hash is what makes two reports comparable. When last year’s determination and this year’s differ in wording, the hash says immediately whether the rule changed or only the prose did — a distinction that matters when the reports are being compared for detecting rule drift between pipeline releases.
Step 6: Render a summary that counts what it says
Document-level narratives — “of 1,284 parcels reviewed, 37 did not meet one or more standards” — are where invented numbers most easily creep in, because the counts come from somewhere other than the records being described.
def summary_context(results):
by_verdict = results["verdict"].value_counts().to_dict()
total = int(len(results["parcel_id"].unique()))
return {
"total_parcels": total,
"n_complies": int(by_verdict.get("complies", 0)),
"n_exceeds": int(by_verdict.get("exceeds", 0)),
"n_indeterminate": int(by_verdict.get("indeterminate", 0)),
"rules_evaluated": int(results["rule_id"].nunique()),
}
Deriving every count from the same frame the detail rows come from is what makes the summary and the detail agree by construction rather than by review. A summary computed from a separate query is a summary that can disagree with its own document.
Verification
Render every fixture, assert the counts reconcile, and confirm no template emits an empty or truncated sentence.
import re
for fixture in FIXTURES: # one per verdict, plus null-heavy edge cases
out = render_narrative("determination.j2", narrative_context(*fixture))
assert out["text"], "empty narrative"
assert " " not in out["text"], "double space — a value rendered blank"
assert not re.search(r"\bof\s+(feet|metres)\b", out["text"]), "missing number"
assert out["text"].endswith("."), "sentence truncated"
s = summary_context(results)
assert s["n_complies"] + s["n_exceeds"] + s["n_indeterminate"] == len(results)
# A missing required value must raise rather than render.
broken = dict(narrative_context(*FIXTURES[0]))
del broken["required"]
with pytest.raises(UndefinedError):
env.get_template("determination.j2").render(**broken)
The last block is the one worth writing carefully. It asserts that the safety mechanism actually fires — an untested StrictUndefined is a configuration setting somebody can remove without any test noticing.
Common Pitfalls
- Leaving Jinja’s default undefined behaviour. A missing value becomes a blank, and the sentence around it still reads as a finding.
- Computing in the template. Arithmetic and fallbacks in a template are untestable and duplicated across every template that needs them.
- One sentence with conditionals inside. The output is grammatically fragile and the template becomes unreadable at the third condition.
- Rendering the summary from a different query. The summary and the detail can then disagree, and the document contradicts itself.
- Autoescape off for HTML output. Parcel attributes come from external sources and can contain markup; escaping is not optional in a document that gets published.
- Not recording the template version. Two determinations that differ only in wording become indistinguishable from two that differ in substance.
Frequently Asked Questions
Should the narrative be stored or regenerated?
Stored, with the results. A determination that was issued is a historical fact, and regenerating it from current templates against current data produces a different document with the same identifier. Regeneration is for drafts; issued documents are immutable, which is the same argument made in generating tamper-evident compliance certificates.
Does this work for PDF as well as HTML?
Yes — the narrative is text, and the same context renders into an HTML fragment for a dashboard or into a paragraph for a PDF. Keep the templates output-agnostic by avoiding markup in the sentence templates and composing them into a layout template per output, which is how generating PDF compliance reports with Python consumes them.
How do I handle multiple languages?
Separate template files per locale with the same context contract, rather than conditionals inside one template. The context is already language-neutral — numbers, identifiers, citations — so the only locale-specific parts are the sentence templates and the unit filters, and keeping them in separate files means a translator never touches logic.
Should a language model write the narrative instead?
No. The value of a template is that its output is determined by its inputs — the same results always produce the same sentence, and the sentence cannot contain a fact that was not in the context. A generated narrative gives up both properties in exchange for variety that nobody wants in a determination. Where a model helps is in drafting the templates themselves, which are then reviewed once and fixed.
How many templates does a real report need?
Fewer than expected. One per verdict branch per standard family — setback, height, density, proximity — plus a summary, covers most reports; perhaps a dozen sentence templates total. The number grows with standard families rather than with rules, which is why the phrasing stays manageable as a rule pack grows into the hundreds.
What about the indeterminate outcome’s explanation?
It should come from the result’s basis field rather than from the template, because the reason varies — a missing base flood elevation, a measurement inside its uncertainty band, an unmapped district. The template supplies the frame and the data supplies the reason, which keeps the template from having to enumerate every way a determination can be unavailable.
Related
Part of: Audit-ready report generation
- Generating PDF compliance reports with Python — the document these narratives render into.
- Building interactive HTML compliance dashboards — the same context in a different output.
- Versioning rule references in audit trails — the citations the narrative quotes.
- Generating tamper-evident compliance certificates — why an issued narrative is immutable.
- Redacting sensitive parcel data from logs — what must not reach a shared document.