Transforming content with the AI tag

Document templates support an {% ai %} block tag that passes its rendered body to an AI together with an instruction and emits the transformed text in place of the block body. This is useful for summarising, rewording or otherwise reshaping content assembled inside the template.

If you want the AI to return data for your template to lay out - a table of forms and their risks, say - use ai_json_list instead. It gives you real tables and a consistent house style, and avoids the output handling described below.

Basic usage

{% ai instruction="Summarize these notes" %}
    {% for note in draft.activityrecord_set.all() %}
    - {{ note.message }}
    {% endfor %}
{% endai %}

The body of the block is rendered as normal, so loops, variables and filters work exactly as they do elsewhere in the template. The resulting text is then sent to the AI with the supplied instruction and the output of the AI call replaces the body.

The instruction attribute is required. It should be short and clearly describe what the AI is expected to do with the block content.

Handling the output

The tag emits exactly what the AI returned. Nothing is escaped and nothing is converted. This is different from the rest of the template: when you write {{ draft.DraftName }} the value is escaped for you, but the output of an {% ai %} block is not.

That matters because most documents are built out of XML:

  • Word documents are built from DocxMarkup, PDF documents from RML and Excel documents from SpreadsheetML. In all three, a single &, < or > anywhere in the AI's answer will corrupt the document. Word usually fails outright and the generation reports an error; Excel is worse, because it may produce a file that then fails to open.

  • Text templates and AI Summaries are unaffected - they have no markup to break.

So unless you are writing a Text template, wrap the block:

{% filter xml_escape %}
{% ai instruction="Summarize these notes" %}
    {% for note in draft.activityrecord_set.all() %}
    - {{ note.message }}
    {% endfor %}
{% endai %}
{% endfilter %}

An answer containing "Smith & Jones" now reaches the document as text, instead of breaking it.

Choosing a wrapper

{% filter %} accepts a chain of filters, so these combine:

What you want

What to write

Plain text, safe in the document

{% filter xml_escape %}

The AI returns markdown and you want real formatting (Word, Excel)

{% filter markdown|convert_html %}

The AI returns HTML and you want real formatting (Word, Excel)

{% filter convert_html %}

Keep only certain HTML tags, then convert (Word/Excel)

{% filter clean_html(["p", "ul", "li"])|convert_html %}

Remove all HTML tags

{% filter strip_tags|xml_escape %}

convert_html is not available for PDF templates, so a PDF template's realistic choices are xml_escape or strip_tags|xml_escape.

Two things to get right

Wrap the block, not the surrounding text. Ordinary {{ }} values are escaped for you already, so anything else caught inside the wrapper is escaped a second time and the entity itself becomes visible:

{# Wrong - "P&G" appears in the document as "P&amp;G" #}
{% filter xml_escape %}
Draft: {{ draft.DraftName }}
{% ai instruction="Summarize these notes" %}...{% endai %}
{% endfilter %}

{# Right - the wrapper covers only the AI block #}
Draft: {{ draft.DraftName }}
{% filter xml_escape %}
{% ai instruction="Summarize these notes" %}...{% endai %}
{% endfilter %}

Always finish with something that escapes. Whatever a {% filter %} block produces is placed into the document as-is, so a wrapper that only removes tags still leaves an & behind to break the document:

{# Wrong - tags are gone but "Smith & Jones" still corrupts the document #}
{% filter strip_tags %}{% ai instruction="Summarize" %}...{% endai %}{% endfilter %}

{# Right #}
{% filter strip_tags|xml_escape %}{% ai instruction="Summarize" %}...{% endai %}{% endfilter %}

convert_html is the exception: it produces document markup directly and escapes the text it carries, so it does not need xml_escape after it.

Markdown

AI models return markdown whether or not you asked for it - **bold** and - bullet are habits, not choices. Escaped, that reaches your document as literal asterisks and hyphens.

You have two options. Either tell the model not to, in the instruction:

{% filter xml_escape %}
{% ai instruction="Summarize these notes as a single plain-text paragraph, with no markdown formatting" %}
...
{% endai %}
{% endfilter %}

or convert it into real document formatting:

{% filter markdown|convert_html %}
{% ai instruction="Summarize these notes, using a bulleted list" %}
...
{% endai %}
{% endfilter %}

Headings, bullet points, bold and italic survive that conversion. Numbered lists keep their items but lose their numbers, and tables are flattened into plain lines of text - worth knowing, because a table is one of the first things an AI reaches for when asked to compare things. If you want a real table, use ai_json_list and lay it out in the template yourself.

Code blocks, syntax highlighting, images and links do not survive.

Warning

clean_html and markdown both produce HTML, so on a Word, Excel or PDF template they must be followed by convert_html (or by strip_tags|xml_escape). HTML left in the document unconverted is not valid document markup and the generation fails - for a Word template, with Unknown tag p.

Choosing an effort level

{% ai instruction="Draft a narrative summary of this draft" effort="high" %}
...
{% endai %}

effort accepts low, medium or high and defaults to medium. Each level is mapped to an LLM model by your system administrator, and each has its own allowance for how long an answer may be.

Use low for short mechanical work - tidying a list, rewording a sentence. Use high where the answer needs judgement or has to be long. Higher effort levels cost more and take longer, so medium is the sensible default for most blocks.

Effort is deliberately not a model name. Models are retired and replaced over time, and a template that named one directly would stop working when that happened.

Controlling the length of an answer

Ask for the length you want in the instruction:

{% ai instruction="Summarize these notes in no more than three sentences" %}
...
{% endai %}

That is the only control that produces a shorter complete answer. There is deliberately no attribute for capping the number of tokens a block may use: a cap does not make the AI write less, it stops the answer part-way through - usually mid-sentence.

Each effort level does have an upper limit on how long an answer may be, set by your system administrator, as a guard against runaway cost. If an answer hits that limit the document still generates and a message tells you which block was affected. Use a higher effort level, or ask the AI for a shorter answer. A cut-short answer is not stored for reuse, so the message appears every time you generate the document until you address it.

If several blocks are affected, each distinct warning is reported once - a block inside a loop raises the same one on every pass - up to a limit set by your system administrator. Past that limit a closing message says so; address the warnings you can see and generate the document again to reveal any others. The finished Task says how many warnings there were.

Limits

Every AI call costs money and time, so a single document generation is bounded:

  • The prompt for one block may not exceed a maximum number of characters. A loop that accidentally gathers the whole draft into one block will fail here, naming the size it reached.

  • One document may make a limited number of AI calls, and may spend a limited number of tokens across all of them. Whichever is reached first stops the generation with a message naming the limit and the block that hit it.

  • Your daily AI cost limit and your organization's monthly AI budget apply as they do everywhere else in TrialGrid.

Your system administrator sets all of these.

Use the tag inside loops with care. Every {% ai %} invocation is a separate LLM call, so a loop wrapping an {% ai %} tag makes one call per iteration and will reach the per-document limit quickly. Summarize an entire list in one call rather than each item individually, unless per-item transformation is specifically what you need.

Other things to know

  • Failures abort the whole document. If the AI call fails - the service is unavailable, an AI spend limit has been reached, the prompt exceeds the model's context window - the document generation task fails with the AI error as its failure message. A truncated answer is not a failure: see above.

  • Prompt safety. The AI is framed with a fixed prompt that tells it to treat the block content as data rather than instructions, and a "reinforcement" prompt is appended after the content to reduce the chance that the block content derails the model. These prompts are managed centrally as system settings and cannot be overridden from inside a template.

Example: summarising a list of forms

{% filter xml_escape %}
{% ai instruction="Write a one sentence summary of these form names in plain English" %}
{% for form in draft.als_forms_set.all() %}- {{ form.DraftFormName }}
{% endfor %}
{% endai %}
{% endfilter %}

Non-deterministic output

Unlike the rest of the template language, the output of the {% ai %} tag is not guaranteed to be identical between runs.