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 |
|---|---|
|
Required. What the AI should produce. For |
|
Required. The content to work from, usually built with |
|
The keys the answer must have - every item for |
|
|
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 |
|---|---|
|
Always true. |
|
Sorted as text, so 12 comes before 3. |
|
Compared as text, so this is false for |
|
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:
verdictsis the checker's answer, one item per finding it was given, each with anindexand averdict.selectattr("verdict", "equalto", "keep")drops the items whoseverdictis anything other thankeep.map(attribute="index")replaces each surviving item with just itsindexnumber, andlistturns 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.