Getting data back as JSON

The {% ai %} tag returns prose, which means the AI decides how your content is laid out. ai_json_list and ai_json_object return data instead, so your template decides.

Use ai_json_list when you want a row per thing:

{% set forms_text %}
{% for form in draft.als_forms_set.all() %}- {{ form.DraftFormName }}: {{ form.DraftFormOID }}
{% endfor %}
{% endset %}

{% set risks = ai_json_list("For each form give a risk rating and the reason for it", forms_text,
                            keys=["name", "risk", "rationale"], effort="high") %}

{% for row in risks %}
<para><run>{{ row.name }} - {{ row.risk }}: {{ row.rationale }}</run></para>
{% endfor %}

and ai_json_object when the question has one answer:

{% set verdict = ai_json_object("Judge whether this draft is ready for review", draft_text,
                                keys=["ready", "reason"]) %}

<para>Ready: {{ verdict.ready }} - {{ verdict.reason }}</para>

This gives you a real Word table or sortable Excel rows in your own house style. It also sidesteps the output handling the {% ai %} tag needs: each value is emitted by your template as an ordinary {{ }} expression, so it is escaped for you.

Choosing between them

They are two functions rather than one function with a switch because the choice changes the whole request, not just what you get back. Each one tells the AI which shape to produce and starts the answer off in that shape, so asking for a list and asking for a single answer are two different questions rather than the same question read two ways.

Pick by what your template does next. If it loops, use ai_json_list. If it reads values straight off the result, use ai_json_object. A list of one is not the same as an object: {{ verdict.ready }} on a list renders nothing at all rather than failing, so this is worth getting right.

Building the content

Both are functions rather than block tags, so the content you want them to work on is assembled first. Jinja's {% set %} block does this - everything between {% set name %} and {% endset %} is rendered as normal and assigned to the variable.

Arguments

Both functions take the same arguments.

Argument

Meaning

instruction

Required. What the AI should produce. For ai_json_list, describe one item rather than the list.

content

Required. The content to work from, usually built with {% set %}.

keys

The keys the answer must have - every item for ai_json_list, the object itself for ai_json_object. The AI is told to use exactly these, and the generation fails if the answer comes back without one. A key can also name its type, as "count:int".

effort

low, medium or high, as for the {% ai %} tag. Defaults to medium.

Always pass keys

keys is optional but worth using every time. It tells the AI exactly what shape to return, and it is what lets the most common failure - the AI calling a field title where your template reads name - be caught and corrected rather than becoming a blank cell in a finished document.

An answer missing one of your keys is handed straight back to the AI, saying which item was wrong and which keys it was asked for, and it answers again. Only if it still cannot produce them does the generation fail, naming the offending item. Without keys there is nothing to check against, so a renamed field is simply rendered as a blank.

Ask only for keys you are going to use. Every key you request is content the AI has to write, which costs time and counts towards your limits.

Say what type a value should be

A key can name the type it must hold, by adding : and one of str, int, number or bool:

keys=["oid", "severity:int", "required:bool", "ratio:number"]

Do this for any value you compare, sort, total or branch on. Left to itself the AI reports values the way it read them - asked for a severity from content saying "Grade 3" it answers the text "Grade 3", and asked whether something is required it answers "Yes".

That matters more than it sounds, because a value of the wrong type usually renders perfectly and behaves wrongly:

What you write

What happens with text instead of a value

{% if row.required %}

Always true. "Yes" and "No" are both non-empty text, and any non-empty text counts as true.

rows|sort(attribute="severity")

Sorted as text, so 12 comes before 3.

row.severity > 2

Compared as text, so this is false for "12".

rows|sum(attribute="severity")

Fails the generation.

Only the last of those is visible. The others produce a document that looks entirely correct.

Naming the type tells the AI what to produce, and rejects the answer if it does not - so it is fixed before it reaches your document rather than after someone notices. Keys with no type are unchanged: nothing is claimed about them and nothing is checked.

Put reasoning keys before conclusions

The order you list keys in is the order the AI answers them, and it writes them one after another - so a key is written knowing only what came before it.

That makes this:

keys=["ready", "reason"]

quite different from this:

keys=["reason", "ready"]

In the first, the AI commits to ready and then writes a reason justifying a decision it has already made. In the second it sets out the reasoning and then answers, so the answer follows from it. The second is usually the one you want, and it costs nothing - your template renders the keys in whatever order it likes, regardless of the order they were produced in.

The same applies to the keys of each item in an ai_json_list call.

Laying the answer out as a table

Because you get data rather than prose, you can put it wherever you like. This is the same call as above, rendered as a Word table:

{% set risks = ai_json_list("For each form give a risk rating and the reason for it", forms_text,
                            keys=["name", "risk", "rationale"]) %}

{% if risks %}
<table style="Table Grid" colwidths="5cm,2cm,9cm">
  <row heading="true">
    <cell><para><run font-bold="true">Form</run></para></cell>
    <cell><para><run font-bold="true">Risk</run></para></cell>
    <cell><para><run font-bold="true">Why</run></para></cell>
  </row>
  {% for row in risks %}
  <row keep-together="true">
    <cell{% if row.risk == "High" %} shade="ffcccc"{% endif %}><para>{{ row.name }}</para></cell>
    <cell><para>{{ row.risk }}</para></cell>
    <cell><para>{{ row.rationale }}</para></cell>
  </row>
  {% endfor %}
</table>
{% else %}
<para>No forms were rated.</para>
{% endif %}

heading="true" repeats the header row when the table breaks across pages, and keep-together="true" stops a row being split down the middle. Note the {% if risks %} - a table with only a header row looks like a mistake, so say plainly that there was nothing to report.

Values are escaped for you, so a rationale containing & or < cannot break the document.

Constrain any value you branch on

The example above shades a cell when the risk is "High". That only works if the AI uses the word "High", and left to itself it may answer "high", "Severe" or "3 - major" - different words on different runs, and your condition silently never matches.

If your template compares a value, say what the permitted values are in the instruction:

{% set risks = ai_json_list("For each form give a risk rating and the reason for it. "
                            "risk must be exactly one of: High, Medium, Low.",
                            forms_text, keys=["name", "risk", "rationale"]) %}

The same applies to anything you sort, group or count on. Where the constraint is a type rather than a set of words - a number, a true/false - declare it on the key instead, as described above: naming permitted values in the instruction relies on the AI following prose, where a declared type is checked.

Checking the answer with a second call

For work where a wrong answer matters more than a missing one, you can pass the first answer back for checking. Give the checker the original content and the items to judge, and ask it for a verdict on each:

{% set findings = ai_json_list("List anything wrong with this form design", form_text,
                               keys=["summary", "detail"]) %}

{% if findings %}
{% set checked %}{{ form_text }}

ITEMS TO CHECK:
{% for f in findings %}
[{{ loop.index0 }}] {{ f.summary }}: {{ f.detail }}
{% endfor %}
{% endset %}

{% set verdicts = ai_json_list("Decide whether each item is genuinely wrong. Re-read the content before "
                               "deciding. Return one item per item you were given, with its index and the "
                               "word keep or discard.",
                               checked, keys=["index:int", "verdict"], effort="high") %}

{# The numbers of the findings the checker kept, e.g. [0, 3, 4] #}
{% set keep = verdicts|selectattr("verdict", "equalto", "keep")|map(attribute="index")|list %}

{% for f in findings %}{% if loop.index0 in keep %}
<para>{{ f.summary }}</para>
{% endif %}{% endfor %}
{% endif %}

The line that builds keep does three things in a row, reading left to right:

  • verdicts is the checker's answer, one item per finding it was given, each with an index and a verdict.

  • selectattr("verdict", "equalto", "keep") drops the items whose verdict is anything other than keep.

  • map(attribute="index") replaces each surviving item with just its index number, and list turns the result back into a list you can use more than once.

So keep ends up as a list of numbers - the positions of the findings worth showing. The loop below then prints finding n only when n appears in that list, which is what loop.index0 in keep tests. index0 is Jinja's zero-based loop counter, and it matches the [0], [1] numbering written into the content the checker was given, which is what ties the two calls together.

Note index:int in the keys. Without it the AI may answer "3" rather than 3, and loop.index0 in keep would then be false for every finding - producing an empty section rather than an error.

A second call is a second cost and counts against the same limits, so this is worth it when a false answer in the document is expensive. Using a higher effort for the check than for the first pass is often a better trade than raising the effort of both.

Keep the index in the checker's answer. Asking it to return only the items worth keeping loses the link back to the original list, and you cannot then tell which of your findings survived.

Expecting a single answer

{% set verdict = ai_json_object("Judge whether this draft is ready for review", draft_text,
                                keys=["ready", "reason"]) %}

Ready: {{ verdict.ready }} - {{ verdict.reason }}

ai_json_object gives you the answer itself, so you read values straight off it. You are asserting that there is exactly one answer, and if the AI replies with a list instead - which it will sometimes do when the instruction reads like a question about a set of things - the generation fails saying so, rather than putting an empty value in your document. If that happens repeatedly, the instruction is probably asking for a list.

Limits and failures

Both functions count against the same per-document limits as the {% ai %} tag - the number of calls, the tokens spent, and your daily allowance. See the AI tag documentation.

Unlike the {% ai %} tag, an answer that is cut short fails the generation rather than being reported. A truncated list of prose is visibly unfinished; a truncated list of data just looks like a shorter list, and there would be nothing in the document to tell you three rows were meant to be twelve.

If the AI returns something that is not valid JSON, or JSON that is not the shape you asked for, it is asked again with an explanation of what was wrong. If it still cannot produce a usable answer the generation fails, and the failure quotes that explanation beside the function and the instruction that asked for it - so a document with several calls in it tells you which one to change, and what the AI kept getting wrong.

A retry is a second call, so it counts towards the same token and call limits as the first. This is one reason to ask only for the keys you use: every key you request is another thing an answer can be rejected for.

Non-deterministic output

As with the {% ai %} tag, the values these functions return are not guaranteed to be identical between runs.