From a prompt you already have¶
Bring your OpenAI-style messages as they are. Get the same request, then types, tables and measurement.
import functai
functai.configure(lm="gpt-4.1-mini", temperature=0) # the model behind every output on this page
from functai import ai, _ai
You have a prompt that works. It took a while to get right, and you don't want a library rewriting it. Good: functai can send it exactly as it is. Then, one step at a time and only if the numbers say so, you can let functai take over the parts that are chores: parsing, retries, running it over a table, measuring it.
Your prompt today¶
Something like this, with the OpenAI SDK:
from openai import OpenAI
client = OpenAI()
SYSTEM = ("You route customer messages for a homeware shop. "
"Answer with one word: shipping, billing, product or account.")
def route(message):
reply = client.chat.completions.create(
model="gpt-4.1-mini", temperature=0,
messages=[{"role": "system", "content": SYSTEM},
{"role": "user", "content": message}])
return reply.choices[0].message.content.strip().lower()
The same messages, in functai¶
Paste the messages into template=, and turn each f-string hole into a
{name} that matches a parameter:
SYSTEM = ("You route customer messages for a homeware shop. "
"Answer with one word: shipping, billing, product or account.")
@ai(template=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": "{message}"},
])
def route(message: str) -> str:
"""Route the message."""
Don't take our word that nothing was added. render builds the request
without sending it:
request = route.render("The mug arrived in pieces.")
print("system:", request.system)
for m in request.messages:
print(f"{m.role}:", m.parts[0].text)
system: You route customer messages for a homeware shop. Answer with one word: shipping, billing, product or account.
user: The mug arrived in pieces.
Byte for byte what your code sent. (The docstring isn't in it: your template decides what the model sees.) And it's still a function:
route("The mug arrived in pieces.")
'product'
What you get without changing the prompt¶
It runs on a table, each distinct message once, several at a time:
from dpyr import col
tickets = functai.datasets.tickets()
tickets.select(col.message).mutate(team=route(col.message)).slice_head(n=5)
# dpyr dataframe · source: polars · showing 5 of 5 rows
┌─────────────────────────────────────────────────────────────────────┬──────────┐
│ message ┆ team │
│ --- ┆ --- │
│ str ┆ str │
╞═════════════════════════════════════════════════════════════════════╪══════════╡
│ Hi, my order A-1042 still hasn't arrived and it's been three weeks. ┆ shipping │
│ The mug arrived in pieces. ┆ product │
│ I was charged twice for order B-2210, please fix this. ┆ billing │
│ How do I change the email on my account? ┆ account │
│ The kettle lid doesn't close properly anymore after a month of use. ┆ product │
└─────────────────────────────────────────────────────────────────────┴──────────┘
It can be measured against answers you trust:
yours = functai.evaluate(route, tickets, expected="category", num_threads=8)
yours
Evaluation(route, 80 examples: exact_match 0.76 [0.66, 0.84])
Every call is inspectable: print(functai.phistory()) shows the
conversation, route.render(...) the request before it's sent.
Provider errors are retried, and any model works by changing
lm=: route.using(lm="claude-haiku-4-5").
Step 1: a return type instead of .strip().lower()¶
Your code cleans the reply by hand, and trusts that it is one of the four words. Say so in the type instead:
from typing import Literal
@ai(template=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": "{message}"},
])
def route_typed(message: str) -> Literal["shipping", "billing", "product", "account"]:
"""Route the message."""
route_typed("THE MUG ARRIVED IN PIECES!!")
'product'
The reply is read into the type: an answer that isn't one of the four is
repaired if it's close ("Shipping." → "shipping"), asked again once if
it isn't, and never slips through as a sentence.
Step 2: let the function be the prompt¶
Your template carries two things: an instruction, and the list of answers. The function can carry both, the instruction in the docstring and the answers in the type. Then functai writes the messages:
@ai
def team(message: str) -> Literal["shipping", "billing", "product", "account"]:
"""Route customer messages for a homeware shop to the team that answers them."""
...
team("The mug arrived in pieces.")
'product'
What the model saw, and what it answered:
print(functai.phistory())
[2026-10-02T09:57:20] team → gpt-4.1-mini
System message:
Function: team
Route customer messages for a homeware shop to the team that answers them.
Reply in exactly this form:
<result>
one of: shipping, billing, product, account
</result>
User message:
<message>
The mug arrived in pieces.
</message>
Response:
<result>
product
</result>
(finish: stop; tokens in 65, out 9)
Is that as good as your hand-written prompt? Don't guess, compare, on the same messages:
functai.compare(yours, functai.evaluate(team, tickets, expected="category", num_threads=8))
# dpyr dataframe · source: polars · showing 1 of 1 rows
┌─────────────┬────────┬────────┬──────┬───────────┬──────────┬────────┬───────┬──────┬─────┐
│ metric ┆ before ┆ after ┆ diff ┆ low ┆ high ┆ better ┆ worse ┆ same ┆ n │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ i64 ┆ i64 ┆ i64 ┆ i64 │
╞═════════════╪════════╪════════╪══════╪═══════════╪══════════╪════════╪═══════╪══════╪═════╡
│ exact_match ┆ 0.7625 ┆ 0.8125 ┆ 0.05 ┆ -0.010298 ┆ 0.110298 ┆ 5 ┆ 1 ┆ 74 ┆ 80 │
└─────────────┴────────┴────────┴──────┴───────────┴──────────┴────────┴───────┴──────┴─────┘
If yours is better, keep your template: it is a first-class way to use functai, not a beginner's mode. If they're the same, the function form is easier to change, to optimize, and to extend with more outputs.
Your OpenAI habits, in functai¶
| with the OpenAI SDK | in functai |
|---|---|
model="gpt-4.1-mini" |
@ai(lm="gpt-4.1-mini"), or functai.configure(lm=...) for all |
messages=[...] with f-strings |
template=[...] with {name} holes, or the docstring |
| few-shot user/assistant pairs | @ai(examples=[...]), or functai.bootstrap_few_shot(fn, rows) to pick them from data |
response_format / JSON schema |
the return type: a Literal, a dataclass, a pydantic model |
| parsing and retrying bad JSON | done for you; retries= |
tools=[{json schema}] and the call loop |
tools=[a_python_function]; the loop is run for you |
temperature=0 |
temperature=0 |
| a loop over rows, a thread pool | fn(col.text), fn.map(table), evaluate(..., num_threads=8) |
| "does the new prompt do better?" | compare(evaluate(old, data), evaluate(new, data)) |
Templates can do more than paste: loops over inputs, blocks shown only when an input has a value, a prefilled start of the answer. See Prompt formats and chat templates.
Where next¶
- Is it right?: metrics beyond exact match, including a model as the judge.
- Make it better: let functai choose examples and try instructions.
- Tools: your Python functions, called by the model.