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.

What a template should do with a value that is not thereJinja renders an undefined variable as an empty string by default; StrictUndefined raises instead, which is the behaviour a determination requires.Is every value thesentence needs present inthe context?noRaise, and render nothingnever "exceeds the maximum height of feet"yesRender the sentencecomplete, and traceable to its citationThe same discipline on verdicts: an unknown value raises rather than losingthe verb
A blank in a marketing page is cosmetic. A blank in a determination is a statement with a hole in it that the 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
 (). 
A whole sentence per outcome, not one sentence with conditionalsThe three verdict branches against what each must name, so that a sentence quoted in isolation is still true and still traceable.The sentence namesReason it mustCompliesStandard, citation, measurementQuoted alone as evidenceExceedsAll of the above, plus the marginThe margin is what is disputedIndeterminateStandard, citation, and the basisThe reason varies by causeAny of themParcel id and addressA determination identifies its subject
Determinations get pasted into emails. A sentence that needs its heading to mean something will lose it.

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()),
    }
A summary that cannot disagree with its own documentDocument-level counts are derived from the same frame the detail rows come from, so the two agree by construction rather than by review.One results frameone record per parcel per ruleDetail rows rendered from itone narrative per recordCounts derived from the same framenever a second queryAssert the parts sum to the wholecomplies + exceeds + indeterminate = total
A summary computed from a separate query is a summary that can contradict the table beneath it.

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.

Part of: Audit-ready report generation