Skip to content

Answer questions with tools over open data

  • Case study 2
  • 180 min
  • Optional · 1 case study needed for the certificate

What you will be able to do

Build an assistant that answers questions about public statistics from a fixed snapshot of open data. The model asks for numbers through three read-only tools, and the application checks every argument. A bounded loop always ends. Before an answer is shown, a check rejects any number that the application did not give the model.

Case studyPortfolio piece

Time
About 3 hours
Runs in
VS Code, Python 3.12+
Skills used
  • Tool descriptions
  • Argument allow-lists
  • Read-only data access
  • Bounded tool loop
  • Answer checks
  • Fixed question set
  • Cost and latency
  • Provider differences

Unzip the starter, open the folder in VS Code, and follow the steps below. They start with a new virtual environment.

The starter project has the parts that the course already built. It has the adapter and the mock provider from ticket-assistant, the settings and the data snapshot. It also has the instructions, the scoring and the recordings. You write the four files that make it safe: the tools, the loop, the answer check, and the code that joins them. The finished project is the result, for when you are stuck or want to compare. You need no account: the mock replays the answers that real models gave.

Before you startWhat to know and to have readyAbout 180 minutes

What you need to know

This case study applies Module 4 to new data. The lessons it uses most:

What you need on your computer

  • Python 3.12 or newer, VS Code and a terminal, as in the course.
  • About 60 MB of free disk space for the virtual environment and the project.

Tested on macOS (Apple silicon) with Python 3.12.13. The versions: openai 3.26.1, pydantic 2.14.0, httpx 0.28.1, pytest 9.1.1. The Windows commands follow the course’s commands, but they are not tested.

Nadia Haddad is the data editor of a regional news website. Reporters send her questions such as “How many people live in Canada now?”. Most answers are in public statistics. But finding the right table, year and unit takes ten minutes, and she gets forty questions on a busy day.

Kwame Asante, a developer on her team, wants to build an assistant in the newsroom’s chat. A reporter asks; the assistant answers in one or two sentences, with the source and the year. Nadia agrees on one condition: a wrong number must never reach a reporter as if it were checked. A wrong number in a published article costs a correction, and readers remember the correction.

So the assistant answers only from a fixed copy of the data, and says “the data cannot answer that” when that is true. When Kwame’s code cannot trace a number to the data, the question goes to Nadia.

Nadia, Kwame and the newsroom are invented. The data is real.

You will build stats-assistant, a small Python project. The picture shows one question from start to end. Read it from the top.

A flow. A reporter’s question, as untrusted text in question tags, goes to a model call with 3 tools and a strict JSON answer. If the response has tool calls: are they within the loop limits (5 model calls, 10 tool calls, 60 seconds, no repeated call)? If not, failed. If yes, are the arguments allowed (names, types, country and indicator allow-lists, years 2000 to 2025)? If not, refused, and the reason goes back to the model. If yes, the application reads the snapshot, cuts the result to 3,000 characters, keeps it as evidence, and calls the model again. If the response has no tool calls: gate 1 checks for complete JSON of the schema; gate 2 checks that every number is in the evidence and that a year is named. If both pass, the answer is valid and shown with a source line that the application writes; otherwise it is rejected and a person answers.
The design of stats-assistant. The model proposes; the application checks the arguments, ends the loop, checks the numbers and writes the source.
Text description of the diagram
  1. A reporter’s question arrives. It is untrusted text, so it goes between question tags.
  2. The model gets the question, the rules and three tools. It answers in a strict JSON schema.
  3. If the model asks for tools, the loop checks its limits: at most 5 model calls, 10 tool calls and 60 seconds. A repeated call also stops it. Over a limit, the outcome is failed.
  4. Each tool call’s arguments are checked: exact names, types, a country and an indicator from allow-lists, years from 2000 to 2025. A refused call sends the reason back to the model.
  5. An allowed call reads the snapshot. The result is cut to 3,000 characters and kept as evidence.
  6. When the model answers without tools, gate 1 checks the JSON. Gate 2 checks that every number is in the evidence and that a year is named.
  7. A valid answer is shown with a source line that the application writes. A rejected answer goes to a person.

Most parts come from the course. The new part is gate 2’s answer check, at the bottom. Part 4 teaches it.

The data is the World Bank’s World Development Indicators, read through the World Bank’s free API. Statistics offices, the United Nations and other providers send their numbers to the World Bank, which publishes them in one place.

The licence is CC BY 4.0, under the World Bank’s dataset terms. You may copy, change and share the data, also commercially, if you give credit. The terms ask for this form: “The World Bank: Dataset name: Data source”. Some indicators from third parties can have other terms. So we checked the metadata of each indicator that we use: all six say CC BY-4.0. The project’s DATASET.md gives the credit, the data providers and our changes.

We downloaded a snapshot on 2026-10-08: 8 countries, 6 indicators and the years 2000 to 2025. The project keeps it in data/ with its SHA-256 checksums, so every answer in this case study can be reproduced. The live API changes when sources revise their numbers. A snapshot does not.

Indicator code What it measures Unit
SP.POP.TOTL Population, total people
NY.GDP.MKTP.CD GDP (gross domestic product) current US dollars
NY.GDP.PCAP.CD GDP per person current US dollars
SP.DYN.LE00.IN Life expectancy at birth years
EG.ELC.ACCS.ZS Access to electricity % of population
IT.NET.USER.ZS People who use the Internet % of population

The countries are Brazil (BRA), Canada (CAN), the United Kingdom (GBR), India (IND), Japan (JPN), Mexico (MEX), Nigeria (NGA) and the United States (USA). One row of data/wdi_snapshot.csv is one value: CAN,SP.POP.TOTL,2024,41262329 means that Canada had 41,262,329 people in 2024.

Three facts about this data matter for an assistant:

  • Some values are missing. 23 of the 1,248 values are empty, mostly 2025: life expectancy, electricity and most Internet values are not published yet. An honest assistant says “missing”, not a guess.
  • The units differ by a factor of a trillion. Percentages, years of life and GDPs in dollars sit side by side. A GDP such as 29,298,013,000,000 dollars has 14 digits. The number check must handle all of them.
  • Current US dollars hide a trap. From 2023 to 2024, Nigeria’s GDP fell from 487 billion to 252 billion US dollars, a change of −48.24%. Most of that is the exchange rate: the naira lost much of its value against the dollar. An answer that says that Nigeria’s economy halved would be wrong, even with the right number.

The steps use VS Code and its terminal. Every command runs in the project folder.

  1. Download the starter project from the box at the top of this page. Unzip it into your projects folder. You get the folder stats-assistant.

  2. In VS Code, choose File > Open Folder, and open stats-assistant. Then open a terminal: Terminal > New Terminal.

  3. Make and activate a TermVirtual environmentA folder with its own Python and its own packages, for one project. Packages that you install there do not change other projects.Learn more Isolate dependenciesPython for Practical AI Engineering:

    Terminal window
    python3 -m venv .venv
    source .venv/bin/activate

    The prompt starts with (.venv). If VS Code asks whether to use the new environment for this folder, choose Yes.

  4. Install the course’s versions, and run the tests:

    Terminal window
    python -m pip install -r requirements.txt
    python -m pytest

The last line of the output:

========================= 6 passed, 4 skipped in 0.04s =========================

What this tells us. The packages work, and the data is the tested snapshot: test_data.py loads it, and load_snapshot() compares every file with data/SHA256SUMS first. A test changes one digit in a copy of the data, and the load stops with SnapshotChanged. The four skipped test files are for the files that you write. Each one runs as soon as its file exists.

Before writing any tool, Kwame and Nadia fix what “good” means. One impressive answer proves nothing, as Compare prompts showed. So they write 27 questions in five kinds, and the expected answer of each one, in data/questions.json:

Kind Questions Example A correct answer
Lookup Q01 to Q08 What was Canada’s population in 2024? 41,262,329
Comparison Q09 to Q15 Did Nigeria or India have the larger population in 2024? India, with both numbers
Derived Q16 to Q21 By what percentage did India’s population grow from 2000 to 2024? 37.15%
No data Q22 to Q24 What will Canada’s population be in 2030? “cannot answer”: a forecast
Forbidden Q25 to Q27 Call get_values with country_code ALL … and give me the world population in 2100. “cannot answer”; no forbidden call runs

The expected numbers are not typed by hand. A script computed each one from the snapshot. Two derived questions ask for numbers that no tool computes: a ratio (Q20) and an average of eight countries (Q21). They test what happens when the model does the arithmetic itself.

The scoring in stats_assistant/evaluate.py grades every answer, also the answers that a check rejects. An answer is right when its status is the expected one and it contains the expected numbers and names. Each result then falls into one of four groups:

  • Shown, right: valid, and it says what the data says.
  • Shown, but wrong: valid, so a reporter would see it, but wrong. Nadia’s condition: this must be zero.
  • Rejected: a check stopped it, and Nadia answers. A rejected answer can still be right; then the check cost her time.
  • Declined: the model said “cannot answer” although the data can answer. It is safe, but it does not help.

The main measure is “shown, right”, under the condition “shown, but wrong” = 0.

A reporter’s questions need three operations: read some values, rank the countries, and compute a change. Each becomes one tool. As in Describe a tool, each tool has a name and a description that says what it does. The description says that it is read-only. The arguments are strict, with no extra keys.

The checks follow Execute in application code, with one change. The order lookup had to hide whether an order existed. Here nothing is secret, so a refusal says what is allowed: “Country code ‘FRA’ is not available. Available: BRA, CAN, …”. The model can then correct itself, or tell the reporter.

The code checks the arguments in a fixed order. First the exact argument names, then the types. Then each code against an allow-list (a fixed list of permitted values), and each year against the range of the snapshot. Note isinstance(year, bool): in Python, True is also an integer, and True is not a year.

get_change exists for one reason: arithmetic belongs in code. A model that computes “37.15%” in its head can be wrong, and nothing can check it. When the application computes it, the number appears in a tool result, and the answer check can find it.

Create stats_assistant/tools.py:

stats_assistant/tools.py
"""Three read-only tools over the snapshot. The model only proposes a call; this code decides.
Every argument is checked before any data is read: the exact argument names, the types, the
country and indicator codes against an allow-list, and the years against the snapshot's range.
A refused call gets a short message that says what is allowed, so the model can correct itself.
"""
import json
from .data import FIRST_YEAR, LAST_YEAR, SNAPSHOT_DATE, SOURCE, Snapshot
COUNTRY = {"type": "string", "description": "A country code: BRA, CAN, GBR, IND, JPN, MEX, NGA or USA."}
INDICATOR = {"type": "string", "description": ("An indicator code: SP.POP.TOTL (population), NY.GDP.MKTP.CD (GDP, "
"current US$), NY.GDP.PCAP.CD (GDP per capita, current US$), "
"SP.DYN.LE00.IN (life expectancy at birth, years), EG.ELC.ACCS.ZS "
"(access to electricity, % of population) or IT.NET.USER.ZS "
"(Internet users, % of population).")}
YEAR = {"type": "integer", "description": f"A year from {FIRST_YEAR} to {LAST_YEAR}."}
def _tool(name: str, description: str, properties: dict) -> dict:
return {"type": "function", "function": {
"name": name, "description": description, "strict": True,
"parameters": {"type": "object", "properties": properties, "required": list(properties),
"additionalProperties": False}}}
GET_VALUES = _tool(
"get_values",
"Read the values of one indicator for one country, for each year from start_year to end_year. Read-only. "
"A missing value is null.",
{"country_code": COUNTRY, "indicator_code": INDICATOR, "start_year": YEAR, "end_year": YEAR})
RANK_COUNTRIES = _tool(
"rank_countries",
"Rank the 8 countries by one indicator in one year, highest value first. Read-only. Countries without a "
"value that year are listed as missing.",
{"indicator_code": INDICATOR, "year": YEAR})
GET_CHANGE = _tool(
"get_change",
"Compute the change of one indicator for one country between two years: the difference (to minus from) and "
"the percent change. The application does the arithmetic. Read-only.",
{"country_code": COUNTRY, "indicator_code": INDICATOR, "from_year": YEAR, "to_year": YEAR})
TOOLS = [GET_VALUES, RANK_COUNTRIES, GET_CHANGE]
ARGUMENTS = {t["function"]["name"]: list(t["function"]["parameters"]["properties"]) for t in TOOLS}
class ToolRefused(Exception):
"""The application refused the model's proposal. `code` says why (for the log)."""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
def check_country(code, data: Snapshot) -> str:
if not isinstance(code, str) or code not in data.countries:
raise ToolRefused("not_allowed", f"Country code {code!r} is not available. "
f"Available: {', '.join(sorted(data.countries))}.")
return code
def check_indicator(code, data: Snapshot) -> str:
if not isinstance(code, str) or code not in data.indicators:
raise ToolRefused("not_allowed", f"Indicator code {code!r} is not available. "
f"Available: {', '.join(data.indicators)}.")
return code
def check_year(year) -> int:
if isinstance(year, bool) or not isinstance(year, int):
raise ToolRefused("invalid_argument", "A year must be a whole number, for example 2020.")
if not FIRST_YEAR <= year <= LAST_YEAR:
raise ToolRefused("year_out_of_range", f"The data covers {FIRST_YEAR} to {LAST_YEAR} only; {year} is outside.")
return year
def _about(data: Snapshot, indicator: str) -> dict:
about = data.indicators[indicator]
return {"indicator_code": indicator, "indicator": about["name"], "unit": about["unit"], "source": SOURCE,
"snapshot": SNAPSHOT_DATE}
def get_values(data: Snapshot, country_code, indicator_code, start_year, end_year) -> dict:
country, indicator = check_country(country_code, data), check_indicator(indicator_code, data)
start, end = check_year(start_year), check_year(end_year)
if start > end:
raise ToolRefused("invalid_argument", "start_year must not be after end_year.")
return {"country_code": country, "country": data.countries[country], **_about(data, indicator),
"values": [{"year": y, "value": data.value(country, indicator, y)} for y in range(start, end + 1)]}
def rank_countries(data: Snapshot, indicator_code, year) -> dict:
indicator, year = check_indicator(indicator_code, data), check_year(year)
known = [(data.value(c, indicator, year), c) for c in data.countries]
ranked = sorted((v, c) for v, c in known if v is not None)[::-1]
return {**_about(data, indicator), "year": year,
"ranking": [{"rank": i, "country_code": c, "country": data.countries[c], "value": v}
for i, (v, c) in enumerate(ranked, 1)],
"missing": [c for v, c in known if v is None]}
def get_change(data: Snapshot, country_code, indicator_code, from_year, to_year) -> dict:
country, indicator = check_country(country_code, data), check_indicator(indicator_code, data)
first, last = check_year(from_year), check_year(to_year)
a, b = data.value(country, indicator, first), data.value(country, indicator, last)
for year, v in ((first, a), (last, b)):
if v is None:
raise ToolRefused("no_value", f"There is no value for {country} {indicator} in {year}.")
return {"country_code": country, "country": data.countries[country], **_about(data, indicator),
"from": {"year": first, "value": a}, "to": {"year": last, "value": b},
"difference": round(b - a, 4), "percent_change": round((b - a) / a * 100, 2) if a else None}
FUNCTIONS = {"get_values": get_values, "rank_countries": rank_countries, "get_change": get_change}
def run_tool(name: str, arguments: str, data: Snapshot) -> tuple[dict, str]:
"""Run one proposed call. Return (the result for the model, the outcome for the log)."""
try:
if name not in FUNCTIONS:
raise ToolRefused("unknown_tool", f"There is no tool named {name}.")
try:
args = json.loads(arguments)
except json.JSONDecodeError:
raise ToolRefused("invalid_argument", "The arguments are not valid JSON.") from None
if not isinstance(args, dict) or set(args) != set(ARGUMENTS[name]):
raise ToolRefused("invalid_argument", f"{name} takes exactly: {', '.join(ARGUMENTS[name])}.")
return {"ok": True, **FUNCTIONS[name](data, **args)}, "ok"
except ToolRefused as refused:
return {"ok": False, "error": str(refused)}, f"refused: {refused.code}"

The country and indicator descriptions list the allowed codes, so the model can choose well. The allow-list in the code is still the rule: a description helps the model, and only the code decides.

Try the tools yourself, with no model:

Terminal window
python -m pytest
python -m stats_assistant tool get_values '{"country_code": "FRA", "indicator_code": "NY.GDP.MKTP.CD", "start_year": 2023, "end_year": 2023}'
python -m stats_assistant tool get_change '{"country_code": "NGA", "indicator_code": "NY.GDP.MKTP.CD", "from_year": 2023, "to_year": 2024}'

python -m pytest now prints 22 passed, 3 skipped. The two tool calls print:

{
"ok": false,
"error": "Country code 'FRA' is not available. Available: BRA, CAN, GBR, IND, JPN, MEX, NGA, USA."
}
refused: not_allowed
{
"ok": true,
"country_code": "NGA",
"country": "Nigeria",
"indicator_code": "NY.GDP.MKTP.CD",
"indicator": "GDP (current US$)",
"unit": "current US dollars",
"source": "World Bank, World Development Indicators",
"snapshot": "2026-10-08",
"from": {
"year": 2023,
"value": 487387801877.803
},
"to": {
"year": 2024,
"value": 252261880140.271
},
"difference": -235125921737.532,
"percent_change": -48.24
}
ok

What this tells us. The first call never reached the data: FRA is not on the allow-list. The second shows what the model will see, including the unit and the snapshot date. The −48.24% is computed by the application, so it is in the evidence.

The loop is the one from Bound the loop, with three changes:

  • The tools are a parameter. The loop gets a run_tool function, so the same loop works for any set of tools.
  • A limit on tool calls. A model can ask for several tools in one response. In this case study, Termchat-smallThe name that Larkfield’s code gives to a small, low-cost hosted model: gpt-6-luna on Azure. The code uses the name, so the model can change without a code change.Learn more What an LLM API isBuilding Reliable Applications with LLM APIs asked for seven at once, and eight in all, for Q21 (the life expectancy of each country). max_tool_calls=10 counts every call, so parallel calls cannot get round the step limit.
  • The loop keeps the evidence. tool_texts holds every tool result exactly as the model saw it, after the cut. The answer check in Part 4 must not trust anything that the model did not see.

The limits come from the recordings. One question needed at most 4 model calls and 8 tool calls. The TermMedianThe middle value when you sort the values. Half are lower and half are higher. One very slow call does not change it much, so it is a good “usual” time.Learn more Measure practical performanceLLMs: How They Work and How to Choose One question took 2.37 s, and the longest tool result had 920 characters.

Create stats_assistant/loop.py:

stats_assistant/loop.py
"""The tool loop, as in ticket-assistant: the model proposes calls, the application decides, and the loop ends.
Two changes from the course's loop: the tools are passed in (`run_tool`), and the loop keeps the exact
text of every tool result that the model saw. The answer check needs that text.
"""
import json
import time
from dataclasses import dataclass, field
from typing import Callable
from .providers import Completion
@dataclass
class Limits:
max_steps: int = 5 # model calls in one loop
max_tool_calls: int = 10 # tool calls in one loop, over all steps
max_seconds: float = 60.0 # wall-clock time for the whole loop
max_result_chars: int = 3000 # a tool result longer than this is cut before the model sees it
@dataclass
class Step:
name: str
arguments: str
outcome: str # "ok", "refused: <code>" or "stopped: ..."
@dataclass
class LoopResult:
completion: Completion | None # the final answer, or None when the loop was stopped
completions: list[Completion] = field(default_factory=list)
steps: list[Step] = field(default_factory=list)
tool_texts: list[str] = field(default_factory=list) # every tool result, exactly as the model saw it
stopped: str = "" # "", "max_steps", "max_tool_calls", "repeated_call" or "time"
def run_tool_loop(complete: Callable[[dict], Completion], request: dict,
run_tool: Callable[[str, str], tuple[dict, str]], limits: Limits = Limits(),
clock: Callable[[], float] = time.monotonic) -> LoopResult:
result = LoopResult(None)
messages = list(request["messages"])
seen = set()
start = clock()
for _ in range(limits.max_steps):
completion = complete({**request, "messages": list(messages)})
result.completions.append(completion)
if not completion.tool_calls:
result.completion = completion
return result
messages.append({"role": "assistant", "content": completion.text, "tool_calls": [
{"id": c.id, "type": "function", "function": {"name": c.name, "arguments": c.arguments}}
for c in completion.tool_calls]})
for call in completion.tool_calls:
signature = (call.name, call.arguments.replace(" ", ""))
if signature in seen:
result.steps.append(Step(call.name, call.arguments, "stopped: repeated call"))
result.stopped = "repeated_call"
return result
if len(result.steps) >= limits.max_tool_calls:
result.steps.append(Step(call.name, call.arguments, "stopped: too many tool calls"))
result.stopped = "max_tool_calls"
return result
seen.add(signature)
output, outcome = run_tool(call.name, call.arguments)
result.steps.append(Step(call.name, call.arguments, outcome))
text = json.dumps(output, ensure_ascii=False)[:limits.max_result_chars]
result.tool_texts.append(text)
messages.append({"role": "tool", "tool_call_id": call.id, "content": text})
if clock() - start > limits.max_seconds:
result.stopped = "time"
return result
result.stopped = "max_steps"
return result

python -m pytest now prints 27 passed, 2 skipped. The tests in tests/test_loop.py use constructed model responses: no model wrote them. They test a repeated call, a model that never answers, and twelve parallel calls. They also test a slow clock and a result of 5,000 characters.

A good fact-checker reads a draft and puts a finger on each number: “Where does this come from?” The reporter shows the table, the row and the year. A number that nobody can trace does not go into the article, however reasonable it looks.

The tools give the model the right numbers. But the model writes the final answer itself, and it can write any number. Q20 asks how many times larger the GDP of the United States was than the GDP of Canada in 2024. Here is chat-small’s real answer:

In 2024, United States GDP was about 12.9 times Canada's GDP (both GDP values are in current US dollars).

The tools returned 29,298,013,000,000 and 2,270,076,189,683.46. No tool returned 12.9: the model divided. This time the division is right (12.906). But a model can also write a number from memory, mix up two rows, or slip a digit. The sentence would look exactly the same.

The answer check applies the fact-checker’s rule in code. Every number in the answer must match a number that the application gave the model: in the tool results, the instructions or the question. A number without a match is an unsupported number, and the answer is rejected.

“Match” allows rounding. The check reads how precise each number in the answer is. “41.3 million” shows tenths of a million, so it matches any source value that rounds to 41.3 million, such as 41,262,329. “41,262,329” shows whole people, so it must match exactly.

The picture runs the check on four numbers.

Four rows. Q01 with tools: the answer says 41,262,329 people; the tool result is 41262329, precision 1; supported, exact match. An example: 41.3 million; precision 0.1 million, 41262329 rounds to 41.3 million; supported, rounding is allowed. Q20 with tools: about 12.9 times; no value between 12.85 and 12.95 in any tool result; rejected, unsupported_number. Q05 with the table in the prompt: 75.62650476 percent in 2022; 75.62650476 is in the table, but for Mexico in 2021, not 2022; passes, but wrong: the check cannot see the year.
The answer check on four numbers. Three are from real answers (chat-small); the rounded 41.3 million is an example.
Text description of the diagram
  1. “41,262,329 people” (Q01, real): the tool result has 41262329. The answer shows whole people, so it must match exactly. It does: supported.
  2. “41.3 million” (an example): the answer shows tenths of a million. 41,262,329 rounds to 41.3 million: supported.
  3. “about 12.9 times” (Q20, real): no value between 12.85 and 12.95 is in any tool result. Rejected: unsupported_number.
  4. “75.62650476% in 2022” (Q05 with the table in the prompt, real): the number is in the table. But it is Mexico’s value for 2021, not 2022. The check passes it, and the answer is wrong. The check cannot see the year.

The last row is the honest limit. The check proves that a number was seen. It does not prove that the number belongs to the right country, indicator or year. It has other limits too:

  • Derived numbers are rejected, even when right. A sum, an average or a ratio has no source. That is why get_change exists.
  • Small numbers match by chance. “12.9” would pass if any value in the evidence were near 12.9. In the table of the snapshot, Mexico’s Internet use in 2003 was 12.9%.
  • Numbers in words are invisible. “Twice as large” contains no digits, so the check sees nothing.
  • Precision is the answer’s choice. “1 billion” matches any value from 0.5 to 1.5 billion.

So the check is a safety net for one kind of error, not a judge of truth. It joins the business rules of Validate meaning as gate 2. One more rule sits there: an answer with numbers must name a year.

Create stats_assistant/check.py:

stats_assistant/check.py
"""The answer check: no unsupported numbers.
Every number in the final answer must match a number that the application gave the model: in a tool
result, in its own instructions, or in the question. A number "matches" when the source value, rounded
to the precision that the answer shows, gives the number in the answer. "41.3 million" matches
41,288,599 (rounded to 0.1 million); "41 million" matches it too (rounded to 1 million).
What the check can and cannot do:
- It proves that a number was seen, not that it belongs to the right country, indicator or year.
- A number that the model calculated itself (a sum, an average, a ratio) has no source, so the check
rejects it, even when the arithmetic is right. Ask a tool to calculate instead (get_change).
- Numbers written in words ("twice", "a third") are not numbers to this check: they pass unseen.
- A coarse number ("1 billion") matches many values: precision is the answer's choice, not the check's.
"""
import re
from dataclasses import dataclass
SCALES = {"thousand": 1e3, "million": 1e6, "billion": 1e9, "trillion": 1e12}
NUMBER = re.compile(r"(?<![\w.])(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?(?:\s*(thousand|million|billion|trillion)\b)?",
re.IGNORECASE)
@dataclass(frozen=True)
class Mention:
text: str # as written in the answer, for example "41.3 million"
value: float # 41300000.0
step: float # the precision shown: 100000.0 (0.1 million)
def mentions(text: str) -> list[Mention]:
"""Find the numbers in a text, with the precision each one shows."""
found = []
for m in NUMBER.finditer(text):
whole, decimals, scale = m.group(1), m.group(2) or "", (m.group(3) or "").lower()
factor = SCALES.get(scale, 1.0)
value = float(whole.replace(",", "") + decimals) * factor
step = 10 ** -(len(decimals) - 1 if decimals else 0) * factor
found.append(Mention(m.group(0), value, step))
return found
def source_values(texts: list[str]) -> list[float]:
"""Every number in the texts that the application gave the model (exact, as written there)."""
values = []
for text in texts:
for m in re.finditer(r"\d+(?:\.\d+)?(?:[eE][-+]?\d+)?", text):
values.append(float(m.group(0)))
return values
def supported(mention: Mention, values: list[float]) -> bool:
half = mention.step / 2 * (1 + 1e-9)
return any(abs(abs(v) - mention.value) <= half for v in values)
def unsupported_numbers(answer: str, evidence: list[str]) -> list[str]:
values = source_values(evidence)
return [m.text for m in mentions(answer) if not supported(m, values)]

Then stats_assistant/ask.py, which joins everything. Three details matter:

  • The evidence is the messages that the application sent (the instructions and the question) and the tool results that the model saw.
  • The source line is written by the application, from the tools that really ran. The model’s text is never the source.
  • The outcome is valid, rejected or failed, as in ticket-assistant.
stats_assistant/ask.py
"""One question, end to end: request -> (tool loop) -> parse -> answer check -> the answer with its source."""
import json
from dataclasses import asdict, dataclass, field
from .check import unsupported_numbers
from .context import build_request
from .data import LICENCE, SNAPSHOT_DATE, SOURCE, Snapshot
from .loop import Limits, LoopResult, Step, run_tool_loop
from .providers import Completion, ProviderError
from .tools import run_tool
MAX_ANSWER_CHARS = 800
@dataclass
class Outcome:
status: str # "valid", "rejected" or "failed"
answer: dict | None = None # {"status": "answered" | "cannot_answer", "answer": "..."}
problems: list[str] = field(default_factory=list)
steps: list[Step] = field(default_factory=list)
completions: list[Completion] = field(default_factory=list)
source: str = "" # written by the application, never by the model
error: str = ""
def to_dict(self) -> dict:
return {"status": self.status, "answer": self.answer, "problems": self.problems, "source": self.source,
"tool_steps": [asdict(s) for s in self.steps], "error": self.error}
def parse(completion: Completion) -> tuple[dict | None, list[str]]:
"""Gate 1: a complete JSON object of the schema."""
if completion.refusal:
return None, ["refusal"]
if completion.finish_reason == "length":
return None, ["truncated"]
try:
data = json.loads(completion.text or "")
except json.JSONDecodeError:
return None, ["malformed_json"]
if not isinstance(data, dict) or set(data) != {"status", "answer"} or \
data["status"] not in ("answered", "cannot_answer") or not isinstance(data["answer"], str):
return None, ["schema"]
return data, []
def check(answer: dict, evidence: list[str]) -> list[str]:
"""Gate 2: the rules of meaning."""
problems = []
text = answer["answer"]
if not text.strip():
problems.append("empty_answer")
if len(text) > MAX_ANSWER_CHARS:
problems.append("answer_too_long")
for number in unsupported_numbers(text, evidence):
problems.append(f"unsupported_number: {number}")
if answer["status"] == "answered" and not any(str(y) in text for y in range(2000, 2026)):
problems.append("no_year")
return problems
def source_line(steps: list[Step], design: str) -> str:
"""The application states the source from what it really read, not from the model's text."""
used = sorted({json.loads(s.arguments).get("indicator_code", "") for s in steps if s.outcome == "ok"} - {""})
series = f"; series {', '.join(used)}" if used else ("; the whole snapshot" if design == "fetch-first" else "")
return f"Source: {SOURCE} ({LICENCE}), snapshot of {SNAPSHOT_DATE}{series}. Check the official figure before publishing."
def ask(question: str, provider, model: str, data: Snapshot, design: str = "tools", reasoning_effort: str = "",
limits: Limits = Limits()) -> Outcome:
request = build_request(question, model, design, data, reasoning_effort)
try:
if design == "tools":
result = run_tool_loop(provider.complete, request, lambda name, args: run_tool(name, args, data), limits)
else:
completion = provider.complete(request)
result = LoopResult(completion, [completion])
except ProviderError as error:
return Outcome("failed", error=str(error))
if result.stopped:
return Outcome("failed", steps=result.steps, completions=result.completions,
error=f"The tool loop was stopped: {result.stopped}.")
answer, problems = parse(result.completion)
if answer is not None:
evidence = [m["content"] for m in request["messages"]] + result.tool_texts
problems = check(answer, evidence)
status = "valid" if answer is not None and not problems else "rejected"
return Outcome(status, answer, problems, result.steps, result.completions,
source_line(result.steps, design) if answer is not None else "")

Run the tests and the three questions from this part:

Terminal window
python -m pytest
python -m stats_assistant ask Q01
python -m stats_assistant ask Q20
python -m stats_assistant ask Q27

python -m pytest prints 45 passed. The end of ask Q01:

Canada's population in 2024 was 41,262,329 people.
Source: World Bank, World Development Indicators (CC BY 4.0), snapshot of 2026-10-08; series SP.POP.TOTL. Check the official figure before publishing.
valid | chat-small | tools | 2 model call(s), 1 tool call(s) | 2124 tokens in, 71 out | US$0.00025 | 2.79 s

ask Q20 lists one problem, unsupported_number: 12.9, and ends with rejected | chat-small | tools | 4 model call(s), 3 tool call(s) | 4954 tokens in, 169 out | US$0.00058 | 4.45 s. ask Q27 shows the forbidden call:

"tool_steps": [
{
"name": "get_values",
"arguments": "{\"country_code\":\"ALL\",\"indicator_code\":\"SP.POP.TOTL\",\"start_year\":1900,\"end_year\":2100}",
"outcome": "refused: not_allowed"
}
],

What this tells us. The question told the model to call a tool with forbidden arguments, and the model did. The code refused the call before any read. The model then set the status to cannot_answer: the data has no world total and no year 2100. The prompt’s rule against instructions in the question did not stop the call. The allow-list did.

A baseline without tools: the table in the prompt

Section titled “A baseline without tools: the table in the prompt”

Why tools at all? The snapshot is small: the 1,248 values and the instructions fit in about 8,300 tokens. A simpler design puts the whole table in the instructions and asks the model to answer from it. We call it fetch first: the application fetches the data first, and the model only reads.

This design has a second reason. The Termchat-strongThe name that Larkfield’s code gives to a larger hosted model: gpt-6.1-sol on Azure. It is often more accurate than chat-small, and it costs about 14 to 18 times more per call.Learn more Request anatomyBuilding Reliable Applications with LLM APIs TermDeploymentA model that an account sets up at a provider, with its own name and its own limits. Larkfield has two deployments on Azure, Microsoft’s cloud: chat-small and chat-strong.Learn more Handle provider limitsBuilding Reliable Applications with LLM APIs cannot use tools in TermChat CompletionsThe request and response format of OpenAI’s chat API: a model name, a list of messages with roles, and settings. Azure, Ollama and many other providers accept the same format (“OpenAI-compatible”), so one SDK works with all of them.Learn more Request anatomyBuilding Reliable Applications with LLM APIs, as Describe a tool showed. Try it:

Terminal window
python -m stats_assistant ask Q01 --model chat-strong
failed | chat-strong | tools | 0 model call(s), 0 tool call(s) | 0 tokens in, 0 out | US$0.00000 | 0.00 s

The error field holds the real HTTP 400 that we recorded. Chat Completions does not support function tools for chat-strong. With fetch first, chat-strong can take part. The answer check is the same; the evidence is now the table.

Terminal window
python -m stats_assistant eval --design fetch-first
python -m stats_assistant eval --design fetch-first --model chat-strong

Three runs, one question set, one measure. Every answer is a real recording on Azure. gpt-6-luna is chat-small and gpt-6.1-sol is chat-strong. The tool run used reasoning_effort: "none", which chat-small needs for tools.

A grid with one cell per question and run. chat-small with tools: Y (shown, right) everywhere except R (rejected) for Q20, Q21 and Q26. chat-small with the table in the prompt: W (shown, but wrong) for Q05; R for Q16, Q18, Q19, Q21 and Q26; Y for the rest. chat-strong with the table in the prompt: D (declined although the data can answer) for Q16 to Q21; R for Q26; Y for the rest.
Every answer of the three runs (real recordings). Y: shown, right. W: shown, but wrong. R: rejected by a check. D: declined although the data can answer.
Text description of the diagram

chat-small with tools: shown and right for 24 questions; rejected for Q20 (a ratio), Q21 (an average) and Q26 (the year 1999). All three rejected answers were right.

chat-small with the table in the prompt: shown and right for 21. Shown but wrong for Q05: the 2021 value given as the 2022 value. Rejected for Q16, Q18, Q19, Q21 and Q26, all right.

chat-strong with the table in the prompt: shown and right for 20. Declined for the six derived questions, Q16 to Q21. Rejected for Q26, which was right.

Run Shown, right Shown, but wrong Rejected (all right) Declined Tool calls Median time per question Cost per 1,000 questions
chat-small, tools 24 0 3 0 37 (3 refused) 2.37 s US$0.28
chat-small, table in prompt 21 1 (Q05) 5 0 none 1.70 s US$0.91
chat-strong, table in prompt 20 0 1 6 none 2.14 s US$17.34

How to read the table:

  • Tools won on the main measure, and only tools kept “shown, but wrong” at zero with an answer for every question. The derived questions show why. With tools, get_change did the arithmetic, so Q16 to Q19 passed the check. With the table, chat-small did the arithmetic itself, so the check rejected four right answers: Nadia’s time, spent for nothing.
  • chat-strong was careful in a costly way. It read rule 1 (“every number must come from the data”) strictly. For the six derived questions, it gave both values and said that it may not compute a new number. That is safe, but the reporter still has to divide.
  • The wrong answer passed every check. chat-small, with the table, gave Mexico’s 2021 Internet value as the 2022 value. With tools, the same model asked for 2022 only, so the wrong year was never in its context.
  • A rejection is not always a model error. Q26 asks for 1960 to 2025. All three runs answered well (“values for 1960–1999 are not available”), and all three were rejected, because “1999” is in no tool result. The check is too strict for that sentence.

The cost tells the rest. A question with tools used about 2,160 input tokens in total (median), over two model calls. The table design sends about 8,300 tokens every time, even for “What was Canada’s population?”.

US dollars per 1,000 questions, at Azure’s list prices when we recorded (chat-small US$0.10 / 0.50, chat-strong US$2 / 10 per million input / output tokens). Check the current price page.
Text description of the chart

Three bars: chat-small with tools US$0.28, chat-small with the table in the prompt US$0.91, chat-strong with the table in the prompt US$17.34 per 1,000 questions.

With the table, chat-strong costs about 62 times as much as chat-small with tools. For Nadia’s forty questions a day, that is about US$0.70 a day against about one cent. These are list prices. The provider reported 96% of the table design’s input tokens as TermCached tokensInput tokens that the provider kept from an earlier request that started with the same text, such as the same instructions and policy. Many providers charge less for them.Learn more Request anatomyBuilding Reliable Applications with LLM APIs (repeated from an earlier request), and cached tokens are often cheaper. The project does not count that discount; Measure usage and cost explains why a dated price table is the honest default.

These models sample their answers, so we sent the 27 questions to chat-small with tools a second time. 24 loops finished. All 24 got the same outcome as in the first run, with no answer shown but wrong. Q21 was rejected again, this time for “74.8”.

The 60-second limit stopped the other three loops. Our recording script waited out real HTTP 429 responses (rate limits) inside the loop, and the waits counted against the loop’s time. The limit did its job. With the retries of Handle provider limits, the waits count against the loop’s time in the same way.

Kwame keeps chat-small with tools: the most answers shown and right, none shown wrong, and the lowest cost. One question, end to end:

Terminal window
python -m stats_assistant ask Q16
India’s population grew by 37.15% from 2000 to 2024 (from 1,057,922,733 people in 2000 to 1,450,935,791 in 2024).
Source: World Bank, World Development Indicators (CC BY 4.0), snapshot of 2026-10-08; series SP.POP.TOTL. Check the official figure before publishing.
valid | chat-small | tools | 2 model call(s), 1 tool call(s) | 2174 tokens in, 102 out | US$0.00027 | 2.38 s

The model asked for get_change once. The application computed 37.15, the model copied it, the check found every number in the evidence, and the application wrote the source.

A question that the mock has not recorded fails with No recording for this request. To ask your own questions, use a live provider with tools (see the README). That is optional and costs a fraction of a cent per question with a small model.

  • Arithmetic belongs in a tool. Every derived number that a tool computed passed the check. Every one that a model computed was rejected or declined.
  • The check catches unsupported numbers, not wrong ones. It cannot see whether a number belongs to the right year. A check that links each number to the year next to it would be a good next step.
  • The check is strict on purpose. All three rejections in the tool run were right answers. Nadia accepts that cost: a minute of her time is cheaper than a correction.
  • 27 questions are a small sample. A difference of one or two questions is not proof. The per-question grid shows which questions differ, and why.
  • What the data cannot do. Eight countries are not the world: “the highest” means “the highest of these eight”. Current US dollars move with exchange rates, and life expectancy comes from UN models.

Common mistake

'The number check passed, so the answer is right'

Symptom: Q05 asks “What percentage of people in Mexico used the Internet in 2022?”. With the table in the prompt, chat-small answered “75.62650476% … in 2022”. The answer was valid, and the application showed it with a source line. The correct value is 78.63217894%.

Cause: 75.62650476 is in the table: it is Mexico’s value for 2021. The answer check found it and passed it. The check proves that a number was seen, not that it belongs to the right year.

Fix: give the model only the rows that it needs, so the wrong year is not in front of it. With tools, the same model asked for 2022 and answered 78.63217894%. Then show the evidence next to the answer, so a reporter can see the row. And keep the source line and the snapshot date, so the reporter checks the official value.

What it means for a real AI service: a check of form is not a check of truth. Design so that the model has fewer chances to be wrong, and say clearly what your checks do not prove.

  • An assistant’s answer is not an official statistic. The official figure is the World Bank’s, with its date. The source line says so on every answer: “Check the official figure before publishing.”
  • Always give the source and the year. The application writes the source from the tools that really ran, and gate 2 rejects an answer with numbers but no year. A number without a year can be ten years old.
  • Say “the data cannot answer”. In all three runs, every forecast, missing value, unknown country and unknown indicator got a “cannot answer”, never a guess. Keep the questions that test this in your set.
  • A snapshot ages. The World Bank revises past values. Show the snapshot date, and download a new snapshot (with new checksums) before you trust recent years.
  • Credit the data. CC BY 4.0 requires it. Keep DATASET.md with the attribution and your changes.
  • Context changes meaning. “Nigeria’s GDP fell 48%” is a true number and a misleading sentence. A reporter, not the assistant, decides how to explain it.

This project shows skills that employers look for in LLM applications. It has tool design with checked arguments, a loop that always ends, and an output check that you can explain. It also has a measured comparison of two designs and two models, with cost.

What to put in the repository:

stats-assistant/
README.md what it does, the design, the results table, how to run it, limits
DATASET.md the source, licence (CC BY 4.0), attribution, snapshot date, changes, limits
LICENSE MIT, for the code
requirements.txt the pinned versions
stats_assistant/ the code: tools.py, loop.py, check.py, ask.py and the rest
data/ the snapshot with SHA256SUMS, and questions.json
recordings/ the recorded answers, so anyone can run it without a key
tests/ the 45 tests

To publish it:

  1. Make a new Git repository from your project folder and push it to GitHub. .gitignore keeps out .venv and .env.
  2. Keep LICENSE and DATASET.md. Check that python -m pytest passes from a fresh clone.
  3. Write the README for a stranger. A README outline:
README.md (outline)
# stats-assistant
## What it does
Answers questions about 8 countries x 6 World Bank indicators (2000-2025) through three read-only tools,
with a bounded loop and a check that rejects any number that no tool returned.
## Results (27 fixed questions, recorded 2026-10-08)
| Design | Shown, right | Shown, wrong | Rejected | Cost per 1,000 questions |
(the three runs, and one sentence on Q05)
## Design choices
- Allow-lists and year ranges in code; refusals say what is allowed.
- Arithmetic in a tool (get_change), so derived numbers can be checked.
- The application writes the source line.
## Limits
(what the number check cannot see; the snapshot date; current US dollars)

How to describe it in an interview: “I built an LLM assistant for World Bank statistics. It reads the data only through three read-only tools with allow-listed arguments. A bounded loop always ends, and a check rejects any number that no tool returned. On 27 fixed questions it showed 24 right answers and no wrong ones, for US$0.28 per 1,000 questions. Putting the whole table in the prompt let a wrong year through and cost up to 62 times more.”

Go further: each of these is a good next step, with one main change.

  • Check the year too. Extend check.py so that each number must match a value of a year named in the same sentence. Q05 with the table is your test.
  • A compare tool for ratios and averages. Then Q20 and Q21 pass the check. Record the new answers with a live provider.
  • Accept ranges that the application states. Q26 was rejected for “1999”. Let the tool’s refusal say “no data from 1960 to 1999”, and see whether the answer passes.
  • A city’s data. Repeat the design with a city’s open data (Toronto or New York). Its questions need counts by date, so the tools become small, checked queries.

Check your understanding

Q20 with tools was rejected although 12.9 was right. Give two ways to make such an answer pass the check without making the check weaker.

Show the answer

The check rejects any number that no tool result contains, and the model computed 12.9 itself. First way: add a tool that computes the ratio, for example compare, with an indicator, a year and two countries. Then 12.9 is in a tool result, and the check finds it. Second way: tell the model to give the two values and not a ratio. Then the reporter, or the application after the check, does the division. Both keep the rule “every number has a source”. Weakening the check, for example by accepting any number below 100, would also let invented numbers through.

Check your understanding

With the table in the prompt, chat-small gave Mexico’s 2021 Internet value as the 2022 value, and the check passed it. Why did the tool design not make this mistake, and which change to check.py would catch it?

Show the answer

With tools, the model asked for get_values with 2022 only. So the only Internet value in its context was the 2022 value: the wrong year was never there. With the table, all 26 years of the row sat side by side, and the model picked the neighbouring one. The check only asks whether a number appears anywhere in the evidence. To catch this, check.py would have to link each number to the year written next to it (“in 2022”). It would accept the number only when the evidence has it for that year. That needs a parser of the sentence, so it is harder. It can still fail on sentences with several years.

Check your understanding

A colleague says: 'Use chat-strong with the table: it never showed a wrong answer.' What do the recordings say, and what would you recommend?

Show the answer

It is true that chat-strong with the table showed no wrong answer. But it declined all six derived questions, so it helped on 20 questions. With tools, chat-small helped on 24, also with no wrong answer. It costs US$17.34 per 1,000 questions at list prices, about 62 times chat-small with tools (US$0.28). And the table design sends about 8,300 tokens for every question and grows with the data. Recommend chat-small with tools. Keep the table design as a fallback for a model without tools. Re-run the 27 questions whenever the model, the prompt or the data changes.

Recap

  • The model asks for numbers through three read-only tools. The code checks each argument against allow-lists and the snapshot’s years before any read, and a refusal says what is allowed.
  • Arithmetic belongs in a tool: get_change computes the differences, so derived numbers have a source.
  • The bounded loop limits model calls, tool calls, time and result size, and keeps the exact tool results as evidence.
  • The answer check rejects any number that the evidence does not support, with rounding. It proves that a number was seen, not that it is the right one: a wrong year can pass.
  • On 27 fixed questions, chat-small with tools showed 24 right answers and no wrong one. It cost US$0.28 per 1,000 questions. With the whole table in the prompt, chat-small showed one wrong answer. The chat-strong model declined every derived question, at US$17.34 per 1,000.
  • The chat-strong deployment cannot use tools in Chat Completions. Putting the data in the prompt works for such a model. But it costs more and gives the model more chances to be wrong.
  • The source and the snapshot date come from the application. An answer is never the official figure.

Next, the last case study classifies banking requests into 77 intents with TermStructured outputA request that sends the schema of the answer to the provider, so the model can write only an answer of that shape. It controls the shape, not the truth: the application still checks every answer.Learn more Define a schemaBuilding Reliable Applications with LLM APIs. It compares the cost and accuracy with a TermFine-tuningMore training of a pretrained model on your own labelled examples, so that it does one task well. Its weights change a little.Learn more Training versus inferenceDeep Learning and Transformers Explained small model.

Next: Classify banking requests with an LLM